Last active
February 17, 2025 01:07
-
-
Save abfo/b564087acd47e9a57974e29f80c4c9c7 to your computer and use it in GitHub Desktop.
AI assistant for Todoist using OpenAI and Google Apps Script. Instructions at https://ithoughthecamewithyou.com/post/adding-ai-to-todoist-with-google-apps-script-and-openai
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// config - ok to leave PERPLEXITY_API_TOKEN blank | |
const OPENAI_API_TOKEN = ''; | |
const PERPLEXITY_API_TOKEN = '' | |
const TODOIST_API_TOKEN = ''; | |
const AI_TASK_LABEL = 'ai'; | |
const AI_MESSAGE_PREFIX = 'AI:'; | |
const MAX_GENERATION_ATTEMPTS = 10; | |
const OPENAI_MODEL = 'gpt-4o' | |
const PERPLEXITY_MODEL = 'sonar-pro' | |
const IMAGE_MIME_TYPES = [ | |
'image/jpeg', | |
'image/jpg', | |
'image/png', | |
'image/webp', | |
'image/gif' | |
]; | |
function trigger() { | |
runAssistant(); | |
} | |
function runAssistant() { | |
// process all open tasks with the AI_TASK_LABEL label | |
tasks = getAiTasks(); | |
tasks.forEach(task => { | |
checkTask(task); | |
}); | |
} | |
function checkTask(task) { | |
// if the user was the last message then we generate a response | |
var responseNeeded = true; | |
const comments = getComments(task.id); | |
comments.forEach(comment => { | |
if (comment?.content) { | |
responseNeeded = !comment.content.startsWith(AI_MESSAGE_PREFIX); | |
} | |
}); | |
if(responseNeeded){ | |
processTask(task, comments); | |
} | |
} | |
function processTask(task, comments) { | |
// build the conversation from the task and comments... | |
// the message array is in openai chat completion format | |
const messages = []; | |
const now = new Date(); | |
const timeZone = Session.getScriptTimeZone(); | |
const timeZoneName = Utilities.formatDate(now, timeZone, "z"); | |
// Format the date. This pattern outputs: "August 10, 2025 20:00:00 PST" | |
const formattedDate = Utilities.formatDate(now, timeZone, "MMMM dd, yyyy HH:mm:ss z"); | |
messages.push({ | |
role: 'developer', | |
content: `You are a helpful assistant that works with Todoist tasks. You are given the current task and any comments and try to help as best as you can. If the user is researching you respond with the information they're looking for. If you have a tool that can help with the task you call it. If you believe that you have fully completed the task then you call the complete_task function to close it. The current task ID is ${task.id}. The current date and time is ${formattedDate}.` | |
}); | |
if (task?.content) { | |
messages.push({ | |
role: 'user', | |
content: task.content | |
}); | |
} | |
if (task?.description) { | |
messages.push({ | |
role: 'user', | |
content: task.description | |
}); | |
} | |
comments.forEach(comment => { | |
if (comment?.content) { | |
if (comment.content.startsWith(AI_MESSAGE_PREFIX)) { | |
// AI message | |
messages.push({ | |
role: 'assistant', | |
content: comment.content.substring(AI_MESSAGE_PREFIX.length).trim() | |
}); | |
} else { | |
// User message - might include an image | |
content = [] | |
// the text part of the comment | |
content.push({type: 'text', text: comment.content}); | |
// if there is an immage attachment add it | |
if (comment.attachment) { | |
if (IMAGE_MIME_TYPES.includes(comment.attachment.file_type)) { | |
var url = ''; | |
if (comment.attachment.tn_l) { | |
// use large thumbnail if it exists | |
url = comment.attachment.tn_l[0]; | |
} else { | |
// full attachment - may fail if too large | |
url = comment.attachment.file_url; | |
} | |
// download to base 64 - openai can't access Todoist attachments from the URL | |
// see https://platform.openai.com/docs/guides/vision#uploading-base64-encoded-images | |
base64 = getAttachment(url); | |
content.push({ | |
type: 'image_url', | |
image_url: { | |
url: `data:${comment.attachment.file_type};base64,${base64}` | |
} | |
}); | |
} | |
} | |
messages.push({ | |
role: 'user', | |
content: content | |
}); | |
} | |
} | |
}); | |
if (messages[messages.length - 1].role = 'user') { | |
// if the user is the last in the thread generate an AI response | |
generateAiResponse(messages, task.id, timeZoneName); | |
} | |
} | |
function generateAiResponse(messages, taskId, timeZoneName) { | |
const payload = { | |
model: OPENAI_MODEL, | |
messages: messages, | |
tools: getTools(timeZoneName) | |
}; | |
// loop to allow for tool use | |
for(var retry = 0; retry < MAX_GENERATION_ATTEMPTS; retry++) { | |
const options = { | |
method: 'post', | |
contentType: 'application/json', | |
headers: { | |
'Authorization': 'Bearer ' + OPENAI_API_TOKEN | |
}, | |
payload: JSON.stringify(payload) | |
}; | |
const response = UrlFetchApp.fetch('https://api.openai.com/v1/chat/completions', options); | |
const result = JSON.parse(response.getContentText()); | |
if (result.choices && result.choices.length > 0) { | |
if (result.choices[0].message.tool_calls && result.choices[0].message.tool_calls.length > 0) { | |
// we have at least one tool request, add the requst message to the conversation | |
payload.messages.push(result.choices[0].message); | |
// run all the tools... | |
result.choices[0].message.tool_calls.forEach(tool_call => { | |
const args = JSON.parse(tool_call['function'].arguments); | |
var result = ''; | |
Logger.log(`Calling ${tool_call['function'].name} (call ID ${tool_call.id})`) | |
switch(tool_call['function'].name) { | |
case 'create_event': | |
try { | |
result = createCalendarAppointment(args.title, args.start, args.end, args?.description, args?.location, args?.guests); | |
} catch (error) { | |
result = error.message; | |
} | |
break; | |
case 'complete_task': | |
try { | |
result = closeTask(args.task_id); | |
} catch (error) { | |
result = error.message; | |
} | |
break; | |
case 'answer_question': | |
try { | |
result = generatePerplexityResponse(args.question); | |
} catch (error) { | |
result = error.message; | |
} | |
break; | |
default: | |
result = 'Unknown function?!'; | |
break; | |
} | |
Logger.log(`Tool response: ${result.substring(0, 50)}...`) | |
payload.messages.push({ | |
"role": "tool", | |
"tool_call_id": tool_call.id, | |
"content": result | |
}); | |
}) | |
continue; | |
} else { | |
// message back from AI, post it as a comment to the task | |
const aiMessage = result.choices[0].message.content; | |
Logger.log(`AI Response: ${aiMessage.substring(0, 50)}...`) | |
addComment(taskId, AI_MESSAGE_PREFIX + ' ' + aiMessage) | |
break; | |
} | |
} | |
} | |
} | |
function generatePerplexityResponse(question) { | |
const messages = []; | |
messages.push({ | |
role: 'system', | |
content: 'You are an artificial intelligence assistant and you answer questions for your user. Your answer will be interpreted by another AI so do not inclue any formating or special text in your answer. Be brief and answer the question in a single concise paragraph. You never ask any clarifying questions or suggest any follow up, just respond as best as you can.' | |
}); | |
messages.push({ | |
role: 'user', | |
content: question | |
}); | |
const payload = { | |
model: PERPLEXITY_MODEL, | |
messages: messages | |
}; | |
const options = { | |
method: 'post', | |
contentType: 'application/json', | |
headers: { | |
'Authorization': 'Bearer ' + PERPLEXITY_API_TOKEN | |
}, | |
payload: JSON.stringify(payload) | |
}; | |
const response = UrlFetchApp.fetch('https://api.perplexity.ai/chat/completions', options); | |
const result = JSON.parse(response.getContentText()); | |
return result.choices[0].message.content; | |
} | |
function getTools(timeZoneName) { | |
tools = []; | |
// complete a todoist task by ID, call closeTask() | |
tools.push({ | |
"type": "function", | |
"function": { | |
"name": "complete_task", | |
"description": "Closes or completes a task", | |
"parameters": { | |
"type": "object", | |
"properties": { | |
"task_id": { | |
"type": "string", | |
"description": "The ID of the task to complete" | |
} | |
}, | |
"required": ["task_id"] | |
} | |
} | |
}); | |
// create a meeting on the user's calendar, call createCalendarAppointment() | |
tools.push({ | |
"type": "function", | |
"function": { | |
"name": "create_event", | |
"description": "Adds an event to the user's calendar. The start and end timestamps must be in 'MMMM D, YYYY HH:mm:ss z' javascript format", | |
"parameters": { | |
"type": "object", | |
"properties": { | |
"title": { | |
"type": "string", | |
"description": "Name of the calendar event, i.e. 'Lunch with Bob'." | |
}, | |
"start": { | |
"type": "string", | |
"description": `Start time for the event, i.e. 'August 10, 2025 20:00:00 ${timeZoneName}', assume ${timeZoneName} if the user does not specify` | |
}, | |
"end": { | |
"type": "string", | |
"description": `End time for the event, i.e. 'August 10, 2025 21:00:00 ${timeZoneName}', assume ${timeZoneName} if the user does not specify, assume 1 hour duration if no end time or length is given` | |
}, | |
"description": { | |
"type": "string", | |
"description": "Optional description for the event" | |
}, | |
"location": { | |
"type": "string", | |
"description": "Optional location for the event" | |
}, | |
"guests": { | |
"type": "string", | |
"description": "Optional comma separated list of email addresses to invite to the event. Never provide an email address unless the user specifically provided it" | |
}, | |
}, | |
"required": ["title", "start", "end"] | |
} | |
} | |
}); | |
if (PERPLEXITY_API_TOKEN){ | |
tools.push({ | |
"type": "function", | |
"function": { | |
"name": "answer_question", | |
"description": "Answers a question using Internet search via the Perplexity Sonar API. Use this for information after your knowlege cutoff date, to reserch questions you do not know the answer to, or for local search.", | |
"parameters": { | |
"type": "object", | |
"properties": { | |
"question": { | |
"type": "string", | |
"description": "The question to answer, i.e. 'How many stars are there in the galaxy?'" | |
} | |
}, | |
"required": ["question"] | |
} | |
} | |
}); | |
} | |
return tools; | |
} | |
function createCalendarAppointment(title, start, end, description, location, guests) { | |
options = {} | |
if (description?.length > 0) { | |
options.description = description; | |
} | |
if (location?.length > 0) { | |
options.location = location; | |
} | |
if (guests?.length > 0) { | |
options.guests = guests; | |
options.sendInvites = true; | |
} | |
CalendarApp.getDefaultCalendar().createEvent(title, | |
new Date(start), | |
new Date(end), | |
options); | |
return `Calendar event ${title} has been created.`; | |
} | |
function closeTask(taskId) { | |
const options = { | |
method: 'post', | |
headers: { | |
'Authorization': 'Bearer ' + TODOIST_API_TOKEN | |
} | |
}; | |
UrlFetchApp.fetch(`https://api.todoist.com/rest/v2/tasks/${taskId}/close`, options); | |
return `Task ${taskId} has been closed. You don't need to do anything else and can move to your next step.`; | |
} | |
function addComment(taskId, comment) { | |
const payload = { | |
task_id: taskId, | |
content: comment | |
}; | |
const options = { | |
method: 'post', | |
contentType: 'application/json', | |
headers: { | |
'Authorization': 'Bearer ' + TODOIST_API_TOKEN | |
}, | |
payload: JSON.stringify(payload) | |
}; | |
UrlFetchApp.fetch('https://api.todoist.com/rest/v2/comments', options); | |
} | |
function getComments(taskId) { | |
var response = UrlFetchApp.fetch(`https://api.todoist.com/rest/v2/comments?task_id=${encodeURIComponent(taskId)}`, { | |
headers: { | |
Authorization: 'Bearer ' + TODOIST_API_TOKEN | |
} | |
}); | |
return JSON.parse(response.getContentText()); | |
} | |
function getAiTasks() { | |
var response = UrlFetchApp.fetch(`https://api.todoist.com/rest/v2/tasks?label=${encodeURIComponent(AI_TASK_LABEL)}`, { | |
headers: { | |
Authorization: 'Bearer ' + TODOIST_API_TOKEN | |
} | |
}); | |
return JSON.parse(response.getContentText()); | |
} | |
function getAttachment(url) { | |
var options = { | |
method: "get", | |
headers: { | |
"Authorization": "Bearer " + TODOIST_API_TOKEN | |
} | |
}; | |
var response = UrlFetchApp.fetch(url, options); | |
var fileBlob = response.getBlob(); // Get the file as a Blob | |
var base64String = Utilities.base64Encode(fileBlob.getBytes()); // Convert Blob to Base64 | |
return base64String; | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment