Skip to content

Commit 991fa34

Browse files
committed
feat: add Google Calendar integration via Composio
- Add calendar connect/disconnect tools in interaction worker - Load calendar tools (create, update, delete, find free slots) in execution agent - Add webhook handler for calendar event reminders (Event Starting Soon trigger) - Add COMPOSIO_CALENDAR_AUTH_CONFIG_ID environment variable - Update getComposioTools to support specific tool requests with higher limits
1 parent d8bc624 commit 991fa34

File tree

5 files changed

+333
-8
lines changed

5 files changed

+333
-8
lines changed

apps/execution-worker/src/durable-objects/execution-agent.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,44 @@ export class ExecutionAgent extends Agent<WorkerEnv, ExecutionState> {
9999
userId: input.userId,
100100
});
101101
}
102+
103+
// Add Google Calendar tools if user has an active connection
104+
const calendarConnection = await checkUserConnection(
105+
this.env.COMPOSIO_API_KEY,
106+
input.userId,
107+
"googlecalendar",
108+
);
109+
110+
if (calendarConnection.connected) {
111+
logger.info("Loading Calendar tools for user", {
112+
userId: input.userId,
113+
});
114+
115+
// Request specific calendar tools - must use exact Composio action names
116+
const calendarTools = await getComposioTools(
117+
this.env.COMPOSIO_API_KEY,
118+
input.userId,
119+
["googlecalendar"],
120+
[
121+
"GOOGLECALENDAR_EVENTS_LIST_ALL_CALENDARS",
122+
"GOOGLECALENDAR_CREATE_EVENT",
123+
"GOOGLECALENDAR_UPDATE_EVENT",
124+
"GOOGLECALENDAR_DELETE_EVENT",
125+
"GOOGLECALENDAR_FIND_FREE_SLOTS",
126+
"GOOGLECALENDAR_QUICK_ADD",
127+
],
128+
);
129+
130+
Object.assign(tools, calendarTools);
131+
132+
logger.info("Calendar tools loaded successfully", {
133+
calendarToolCount: Object.keys(calendarTools).length,
134+
});
135+
} else {
136+
logger.info("No active Calendar connection for user", {
137+
userId: input.userId,
138+
});
139+
}
102140
}
103141

104142
return tools;

apps/execution-worker/src/services/composio-webhook-handler.ts

Lines changed: 196 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,57 @@ type ComposioV2EmailPayload = {
3737
};
3838
};
3939

40+
// Composio V2 webhook format for calendar triggers
41+
// Trigger types from Composio:
42+
// - googlecalendar_event_starting_soon: Event is within configured minutes from starting
43+
// - googlecalendar_event_created: New event created
44+
// - googlecalendar_event_updated: Existing event modified
45+
// - googlecalendar_event_canceled_or_deleted: Event cancelled/deleted
46+
// - googlecalendar_attendee_response_changed: RSVP changed
47+
// - googlecalendar_calendar_event_sync: Full event data sync
48+
type ComposioV2CalendarPayload = {
49+
type:
50+
| "googlecalendar_event_starting_soon"
51+
| "googlecalendar_event_created"
52+
| "googlecalendar_event_updated"
53+
| "googlecalendar_event_canceled_or_deleted"
54+
| "googlecalendar_attendee_response_changed"
55+
| "googlecalendar_calendar_event_sync";
56+
timestamp: string;
57+
log_id: string;
58+
data: {
59+
id: string;
60+
event_id: string;
61+
calendar_id?: string;
62+
summary: string;
63+
description?: string;
64+
start?: {
65+
dateTime?: string;
66+
date?: string;
67+
timeZone?: string;
68+
};
69+
end?: {
70+
dateTime?: string;
71+
date?: string;
72+
timeZone?: string;
73+
};
74+
attendees?: Array<{
75+
email: string;
76+
displayName?: string;
77+
responseStatus?: string;
78+
}>;
79+
location?: string;
80+
// Event Starting Soon specific fields
81+
countdown_minutes?: number;
82+
start_time?: string;
83+
connection_id: string;
84+
connection_nano_id: string;
85+
trigger_nano_id: string;
86+
trigger_id: string;
87+
user_id: string; // This is our Poppy userId
88+
};
89+
};
90+
4091
// Legacy email trigger format
4192
type EmailTriggerPayload = {
4293
event: "trigger";
@@ -73,7 +124,8 @@ type OAuthCallbackPayload = {
73124
type ComposioWebhookPayload =
74125
| OAuthCallbackPayload
75126
| EmailTriggerPayload
76-
| ComposioV2EmailPayload;
127+
| ComposioV2EmailPayload
128+
| ComposioV2CalendarPayload;
77129

78130
type EmailInfo = {
79131
messageId: string;
@@ -98,6 +150,20 @@ const isV2EmailPayload = (
98150
return "type" in payload && payload.type === "gmail_new_gmail_message";
99151
};
100152

153+
const isV2CalendarPayload = (
154+
payload: ComposioWebhookPayload,
155+
): payload is ComposioV2CalendarPayload => {
156+
return (
157+
"type" in payload &&
158+
(payload.type === "googlecalendar_event_starting_soon" ||
159+
payload.type === "googlecalendar_event_created" ||
160+
payload.type === "googlecalendar_event_updated" ||
161+
payload.type === "googlecalendar_event_canceled_or_deleted" ||
162+
payload.type === "googlecalendar_attendee_response_changed" ||
163+
payload.type === "googlecalendar_calendar_event_sync")
164+
);
165+
};
166+
101167
const isOAuthCallback = (
102168
payload: ComposioWebhookPayload,
103169
): payload is OAuthCallbackPayload => {
@@ -116,6 +182,10 @@ export const handleComposioWebhook = async (
116182
return handleV2EmailTrigger(payload, env);
117183
}
118184

185+
if (isV2CalendarPayload(payload)) {
186+
return handleV2CalendarTrigger(payload, env);
187+
}
188+
119189
if (isEmailTrigger(payload)) {
120190
return handleEmailTrigger(payload, env);
121191
}
@@ -419,3 +489,128 @@ Notify the user about this email. Keep it brief but informative.`;
419489
return { success: false, message: "Failed to notify user" };
420490
}
421491
};
492+
493+
const handleV2CalendarTrigger = async (
494+
payload: ComposioV2CalendarPayload,
495+
env: WorkerEnv,
496+
): Promise<{ success: boolean; message: string }> => {
497+
const calendarLogger = logger.withTags({ module: "v2-calendar-trigger" });
498+
499+
calendarLogger.info("Processing V2 calendar trigger", {
500+
type: payload.type,
501+
userId: payload.data.user_id,
502+
eventSummary: payload.data.summary,
503+
});
504+
505+
const userId = payload.data.user_id;
506+
507+
// Only notify for "Event Starting Soon" trigger
508+
// Other events are informational - user initiated them or we don't need to notify
509+
if (payload.type !== "googlecalendar_event_starting_soon") {
510+
calendarLogger.info("Calendar event logged (non-reminder)", {
511+
type: payload.type,
512+
eventSummary: payload.data.summary,
513+
});
514+
return { success: true, message: "Calendar event logged" };
515+
}
516+
517+
// Notify user about upcoming event
518+
const eventInfo = {
519+
eventId: payload.data.event_id,
520+
summary: payload.data.summary,
521+
description: payload.data.description,
522+
start:
523+
payload.data.start_time ||
524+
payload.data.start?.dateTime ||
525+
payload.data.start?.date,
526+
location: payload.data.location,
527+
attendees: payload.data.attendees?.map((a) => a.email).join(", "),
528+
countdownMinutes: payload.data.countdown_minutes,
529+
};
530+
531+
const db = getDb(env.HYPERDRIVE.connectionString);
532+
return notifyUserAboutCalendarEvent(userId, eventInfo, db, env);
533+
};
534+
535+
const notifyUserAboutCalendarEvent = async (
536+
userId: string,
537+
eventInfo: {
538+
eventId: string;
539+
summary: string;
540+
description?: string;
541+
start?: string;
542+
location?: string;
543+
attendees?: string;
544+
countdownMinutes?: number;
545+
},
546+
db: Database,
547+
env: WorkerEnv,
548+
): Promise<{ success: boolean; message: string }> => {
549+
const notifyLogger = logger.withTags({ module: "calendar-notify" });
550+
551+
// Find user's conversation
552+
const userConversation = await db
553+
.select({ conversationId: conversationParticipants.conversationId })
554+
.from(conversationParticipants)
555+
.innerJoin(
556+
conversations,
557+
eq(conversations.id, conversationParticipants.conversationId),
558+
)
559+
.where(eq(conversationParticipants.userId, userId))
560+
.orderBy(desc(conversations.updatedAt))
561+
.limit(1);
562+
563+
if (userConversation.length === 0) {
564+
notifyLogger.warn("No conversation found", { userId });
565+
return { success: false, message: "No conversation found" };
566+
}
567+
568+
const conversationId = userConversation[0].conversationId;
569+
570+
const interactionAgent = await db.query.agents.findFirst({
571+
where: eq(agents.conversationId, conversationId),
572+
});
573+
574+
if (!interactionAgent) {
575+
notifyLogger.warn("No interaction agent found", { conversationId });
576+
return { success: false, message: "No interaction agent found" };
577+
}
578+
579+
const countdownText = eventInfo.countdownMinutes
580+
? `Starting in ${eventInfo.countdownMinutes} minutes`
581+
: `Time: ${eventInfo.start || "Not specified"}`;
582+
583+
const taskDescription = `[CALENDAR REMINDER] Upcoming event:
584+
Event: ${eventInfo.summary}
585+
${countdownText}
586+
${eventInfo.location ? `Location: ${eventInfo.location}` : ""}
587+
${eventInfo.attendees ? `Attendees: ${eventInfo.attendees}` : ""}
588+
${eventInfo.description ? `Description: ${eventInfo.description}` : ""}
589+
590+
Notify the user about this upcoming calendar event. Keep it brief but informative.`;
591+
592+
const executionAgentId = env.EXECUTION_AGENT.idFromName(interactionAgent.id);
593+
const executionAgent = env.EXECUTION_AGENT.get(executionAgentId);
594+
595+
try {
596+
await executionAgent.executeTask({
597+
agentId: interactionAgent.id,
598+
conversationId,
599+
taskDescription,
600+
userId,
601+
});
602+
603+
notifyLogger.info("Calendar notification dispatched", {
604+
userId,
605+
eventSummary: eventInfo.summary,
606+
});
607+
608+
return { success: true, message: "User notified" };
609+
} catch (error) {
610+
notifyLogger.error("Failed to dispatch calendar notification", {
611+
error: error instanceof Error ? error.message : String(error),
612+
userId,
613+
});
614+
return { success: false, message: "Failed to notify user" };
615+
}
616+
};

apps/execution-worker/src/tools/gmail.ts

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,28 +3,34 @@ import { VercelProvider } from "@composio/vercel";
33
import { logger } from "@poppy/hono-helpers";
44

55
/**
6-
* Gets Gmail tools from Composio for use with Vercel AI SDK.
6+
* Gets tools from Composio for use with Vercel AI SDK.
77
* Uses the user's Poppy userId as the Composio userId.
88
*/
99
export const getComposioTools = async (
1010
apiKey: string,
1111
userId: string,
1212
toolkits: string[] = ["gmail"],
13+
specificTools?: string[],
1314
) => {
1415
try {
1516
const composio = new Composio({
1617
apiKey,
1718
provider: new VercelProvider(),
1819
});
1920

20-
// Get tools using toolkits - userId must have an active connection
21-
const tools = await composio.tools.get(userId, {
22-
toolkits,
23-
});
21+
// If specific tools are requested, use the tools parameter
22+
// Otherwise use toolkits with a high limit
23+
const queryParams = specificTools
24+
? { tools: specificTools }
25+
: { toolkits, limit: 100 };
26+
27+
const tools = await composio.tools.get(userId, queryParams);
2428

2529
logger.info("Successfully loaded Composio tools", {
2630
toolCount: Object.keys(tools).length,
31+
toolNames: Object.keys(tools),
2732
toolkits,
33+
specificTools,
2834
userId,
2935
});
3036

@@ -34,6 +40,7 @@ export const getComposioTools = async (
3440
error: error instanceof Error ? error.message : String(error),
3541
userId,
3642
toolkits,
43+
specificTools,
3744
});
3845
return {};
3946
}

0 commit comments

Comments
 (0)