diff --git a/workspace-server/src/index.ts b/workspace-server/src/index.ts index 674d678..6f4ca38 100644 --- a/workspace-server/src/index.ts +++ b/workspace-server/src/index.ts @@ -800,12 +800,119 @@ async function main() { 'The ID of the calendar to delete the event from. Defaults to the primary calendar.', ), }, - }, - calendarService.deleteEvent, - ); - - server.registerTool( - 'chat.listSpaces', + }, + calendarService.deleteEvent, + ); + + server.registerTool( + 'calendar.createCalendar', + { + description: 'Creates a new calendar.', + inputSchema: { + summary: z.string().describe('Calendar name/title.'), + description: z + .string() + .optional() + .describe('Optional calendar description.'), + timeZone: z + .string() + .optional() + .describe('Optional IANA timezone (e.g., America/New_York).'), + }, + }, + calendarService.createCalendar, + ); + + server.registerTool( + 'calendar.createRecurringEvent', + { + description: 'Creates a recurring event in a calendar.', + inputSchema: { + calendarId: z + .string() + .optional() + .describe( + 'The ID of the calendar to create the recurring event in. Defaults to primary.', + ), + summary: z.string().describe('The summary or title of the event.'), + description: z + .string() + .optional() + .describe('The description of the event.'), + start: z.object({ + dateTime: z + .string() + .describe( + 'The start time in strict ISO 8601 format with seconds and timezone.', + ), + }), + end: z.object({ + dateTime: z + .string() + .describe( + 'The end time in strict ISO 8601 format with seconds and timezone.', + ), + }), + attendees: z + .array(z.string()) + .optional() + .describe('The email addresses of the attendees.'), + recurrence: z + .array(z.string()) + .min(1) + .describe( + 'Recurrence rules (e.g., ["RRULE:FREQ=WEEKLY;BYDAY=MO;COUNT=10"]).', + ), + reminders: z + .object({ + useDefault: z.boolean().optional(), + overrides: z + .array( + z.object({ + method: z.enum(['email', 'popup']), + minutes: z.number().min(0), + }), + ) + .optional(), + }) + .optional() + .describe('Optional reminder configuration for the recurring event.'), + }, + }, + calendarService.createRecurringEvent, + ); + + server.registerTool( + 'calendar.setEventReminders', + { + description: + 'Sets custom reminders for an existing calendar event (or resets to default reminders).', + inputSchema: { + eventId: z.string().describe('The ID of the event to update reminders for.'), + calendarId: z + .string() + .optional() + .describe('The ID of the calendar containing the event.'), + useDefault: z + .boolean() + .optional() + .describe('Whether to use default calendar reminders (default: true).'), + overrides: z + .array( + z.object({ + method: z.enum(['email', 'popup']), + minutes: z.number().min(0), + }), + ) + .optional() + .describe('Custom reminder overrides.'), + }, + }, + calendarService.setEventReminders, + ); + + server.registerTool( + 'chat.listSpaces', { description: 'Lists the spaces the user is a member of.', inputSchema: {}, diff --git a/workspace-server/src/services/CalendarService.ts b/workspace-server/src/services/CalendarService.ts index 980c725..b2062b9 100644 --- a/workspace-server/src/services/CalendarService.ts +++ b/workspace-server/src/services/CalendarService.ts @@ -58,6 +58,7 @@ export interface UpdateEventInput { start?: { dateTime: string }; end?: { dateTime: string }; attendees?: string[]; + sendUpdates?: 'all' | 'externalOnly' | 'none'; addGoogleMeet?: boolean; attachments?: EventAttachment[]; } @@ -77,6 +78,34 @@ export interface FindFreeTimeInput { duration: number; } +export interface CreateRecurringEventInput { + calendarId?: string; + summary: string; + description?: string; + start: { dateTime: string }; + end: { dateTime: string }; + attendees?: string[]; + recurrence: string[]; + sendUpdates?: 'all' | 'externalOnly' | 'none'; + reminders?: { + useDefault?: boolean; + overrides?: Array<{ method: 'email' | 'popup'; minutes: number }>; + }; +} + +export interface CreateCalendarInput { + summary: string; + description?: string; + timeZone?: string; +} + +export interface SetEventRemindersInput { + eventId: string; + calendarId?: string; + useDefault?: boolean; + overrides?: Array<{ method: 'email' | 'popup'; minutes: number }>; +} + export class CalendarService { private primaryCalendarId: string | null = null; @@ -113,9 +142,17 @@ export class CalendarService { } } + /** + * Standardized error message extraction helper. + * Converts any error to a string message safely. + */ + private getErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); + } + private createValidationErrorResponse(error: unknown) { const errorMessage = - error instanceof Error ? error.message : 'Validation failed'; + error instanceof Error ? this.getErrorMessage(error) : 'Validation failed'; let helpMessage = 'Please use strict ISO 8601 format with seconds and timezone. Examples: 2024-01-15T10:30:00Z (UTC) or 2024-01-15T10:30:00-05:00 (EST)'; @@ -191,8 +228,7 @@ export class CalendarService { ], }; } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); + const errorMessage = this.getErrorMessage(error); logToFile(`Error during calendar.list: ${errorMessage}`); return { content: [ @@ -282,8 +318,7 @@ export class CalendarService { ], }; } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); + const errorMessage = this.getErrorMessage(error); logToFile(`Error during calendar.createEvent: ${errorMessage}`); return { content: [ @@ -296,6 +331,73 @@ export class CalendarService { } }; + createCalendar = async (input: CreateCalendarInput) => { + const { summary, description, timeZone } = input; + logToFile(`Creating calendar with summary: ${summary}`); + try { + const calendar = await this.getCalendar(); + const res = await calendar.calendars.insert({ + requestBody: { summary, description, timeZone }, + }); + return { content: [{ type: 'text' as const, text: JSON.stringify(res.data) }] }; + } catch (error) { + const errorMessage = this.getErrorMessage(error); + logToFile(`Error during calendar.createCalendar: ${errorMessage}`); + return { content: [{ type: 'text' as const, text: JSON.stringify({ error: errorMessage }) }] }; + } + }; + + createRecurringEvent = async (input: CreateRecurringEventInput) => { + const { calendarId, summary, description, start, end, attendees, recurrence, sendUpdates, reminders } = input; + try { + iso8601DateTimeSchema.parse(start.dateTime); + iso8601DateTimeSchema.parse(end.dateTime); + if (attendees) emailArraySchema.parse(attendees); + if (!recurrence || recurrence.length === 0) throw new Error('recurrence must contain at least one RRULE string'); + } catch (error) { + return this.createValidationErrorResponse(error); + } + const finalCalendarId = calendarId || (await this.getPrimaryCalendarId()); + logToFile(`Creating recurring event in calendar: ${finalCalendarId}`); + + // Determine sendUpdates value + let finalSendUpdates = sendUpdates; + if (finalSendUpdates === undefined) { + finalSendUpdates = attendees?.length ? 'all' : 'none'; + } + if (finalSendUpdates) { + logToFile(`Sending updates: ${finalSendUpdates}`); + } + + try { + const event: calendar_v3.Schema$Event = { summary, description, start, end, attendees: attendees?.map((email) => ({ email })), recurrence }; + if (reminders) event.reminders = { useDefault: reminders.useDefault, overrides: reminders.overrides }; + const calendar = await this.getCalendar(); + const res = await calendar.events.insert({ calendarId: finalCalendarId, requestBody: event, sendUpdates: finalSendUpdates }); + logToFile(`Successfully created recurring event: ${res.data.id}`); + return { content: [{ type: 'text' as const, text: JSON.stringify(res.data) }] }; + } catch (error) { + const errorMessage = this.getErrorMessage(error); + logToFile(`Error during calendar.createRecurringEvent: ${errorMessage}`); + return { content: [{ type: 'text' as const, text: JSON.stringify({ error: errorMessage }) }] }; + } + }; + + setEventReminders = async (input: SetEventRemindersInput) => { + const { eventId, calendarId, useDefault = true, overrides } = input; + const finalCalendarId = calendarId || (await this.getPrimaryCalendarId()); + logToFile(`Setting reminders for event ${eventId} in ${finalCalendarId}`); + try { + const calendar = await this.getCalendar(); + const res = await calendar.events.patch({ calendarId: finalCalendarId, eventId, requestBody: { reminders: { useDefault, overrides } } }); + return { content: [{ type: 'text' as const, text: JSON.stringify(res.data) }] }; + } catch (error) { + const errorMessage = this.getErrorMessage(error); + logToFile(`Error during calendar.setEventReminders: ${errorMessage}`); + return { content: [{ type: 'text' as const, text: JSON.stringify({ error: errorMessage }) }] }; + } + }; + listEvents = async (input: ListEventsInput) => { const { calendarId, @@ -351,8 +453,7 @@ export class CalendarService { ], }; } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); + const errorMessage = this.getErrorMessage(error); logToFile(`Error during calendar.listEvents: ${errorMessage}`); return { content: [ @@ -387,7 +488,7 @@ export class CalendarService { } catch (error) { const errorMessage = (error as any).response?.data?.error?.message || - (error instanceof Error ? error.message : String(error)); + this.getErrorMessage(error); logToFile(`Error during calendar.getEvent: ${errorMessage}`); return { content: [ @@ -426,7 +527,7 @@ export class CalendarService { } catch (error) { const errorMessage = (error as any).response?.data?.error?.message || - (error instanceof Error ? error.message : String(error)); + this.getErrorMessage(error); logToFile(`Error during calendar.deleteEvent: ${errorMessage}`); return { content: [ @@ -448,6 +549,7 @@ export class CalendarService { start, end, attendees, + sendUpdates, addGoogleMeet, attachments, } = input; @@ -473,6 +575,15 @@ export class CalendarService { if (attachments?.length) logToFile(`Attachments: ${attachments.length} file(s)`); + // Determine sendUpdates value + let finalSendUpdates = sendUpdates; + if (finalSendUpdates === undefined) { + finalSendUpdates = attendees?.length ? 'all' : 'none'; + } + if (finalSendUpdates) { + logToFile(`Sending updates: ${finalSendUpdates}`); + } + try { const calendar = await this.getCalendar(); @@ -489,6 +600,7 @@ export class CalendarService { calendarId: finalCalendarId, eventId, requestBody, + sendUpdates: finalSendUpdates, }; this.applyMeetAndAttachments( requestBody, @@ -509,8 +621,7 @@ export class CalendarService { ], }; } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); + const errorMessage = this.getErrorMessage(error); logToFile(`Error during calendar.updateEvent: ${errorMessage}`); return { content: [ @@ -611,8 +722,7 @@ export class CalendarService { ], }; } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); + const errorMessage = this.getErrorMessage(error); logToFile(`Error during calendar.respondToEvent: ${errorMessage}`); return { content: [ @@ -803,8 +913,7 @@ export class CalendarService { ], }; } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); + const errorMessage = this.getErrorMessage(error); logToFile(`Error during calendar.findFreeTime: ${errorMessage}`); return { content: [