Skip to content

Instantly share code, notes, and snippets.

@abfo
Last active February 17, 2025 01:07
Show Gist options
  • Save abfo/b564087acd47e9a57974e29f80c4c9c7 to your computer and use it in GitHub Desktop.
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
// 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