diff --git a/apps/docs/content/docs/en/integrations/google_calendar.mdx b/apps/docs/content/docs/en/integrations/google_calendar.mdx index 4b6011767f8..648d6896e1a 100644 --- a/apps/docs/content/docs/en/integrations/google_calendar.mdx +++ b/apps/docs/content/docs/en/integrations/google_calendar.mdx @@ -48,10 +48,12 @@ Create a new event in Google Calendar. Returns API-aligned fields only. | `summary` | string | Yes | Event title/summary | | `description` | string | No | Event description | | `location` | string | No | Event location | -| `startDateTime` | string | Yes | Start date and time. MUST include timezone offset \(e.g., 2025-06-03T10:00:00-08:00\) OR provide timeZone parameter | -| `endDateTime` | string | Yes | End date and time. MUST include timezone offset \(e.g., 2025-06-03T11:00:00-08:00\) OR provide timeZone parameter | +| `startDateTime` | string | Yes | Start time. Use a datetime with timezone offset \(2025-06-03T10:00:00-08:00\) or a date \(2025-06-03\) for an all-day event | +| `endDateTime` | string | Yes | End time. Use a datetime with timezone offset \(2025-06-03T11:00:00-08:00\) or a date \(2025-06-04\) for an all-day event | | `timeZone` | string | No | Time zone \(e.g., America/Los_Angeles\). Required if datetime does not include offset. Defaults to America/Los_Angeles if not provided. | | `attendees` | array | No | Array of attendee email addresses | +| `recurrence` | string | No | Recurrence rule\(s\) in RFC 5545 format \(e.g., RRULE:FREQ=WEEKLY;BYDAY=MO,WE,FR\). Separate multiple rules with newlines. | +| `addGoogleMeet` | boolean | No | Attach a Google Meet video conference link to the event | | `sendUpdates` | string | No | How to send updates to attendees: all, externalOnly, or none | #### Output @@ -60,10 +62,12 @@ Create a new event in Google Calendar. Returns API-aligned fields only. | --------- | ---- | ----------- | | `id` | string | Event ID | | `htmlLink` | string | Event link | +| `hangoutLink` | string | Google Meet link | | `status` | string | Event status | | `summary` | string | Event title | | `description` | string | Event description | | `location` | string | Event location | +| `recurrence` | json | Recurrence rules | | `start` | json | Event start | | `end` | json | Event end | | `attendees` | json | Event attendees | @@ -81,7 +85,10 @@ List events from Google Calendar. Returns API-aligned fields only. | `calendarId` | string | No | Google Calendar ID \(e.g., primary or calendar@group.calendar.google.com\) | | `timeMin` | string | No | Lower bound for events \(RFC3339 timestamp, e.g., 2025-06-03T00:00:00Z\) | | `timeMax` | string | No | Upper bound for events \(RFC3339 timestamp, e.g., 2025-06-04T00:00:00Z\) | -| `orderBy` | string | No | Order of events returned \(startTime or updated\) | +| `q` | string | No | Free-text search across event summary, description, location, attendees, and organizer | +| `maxResults` | number | No | Maximum number of events to return \(max 2500\) | +| `pageToken` | string | No | Token for retrieving the next page of results | +| `orderBy` | string | No | Order of events returned \(startTime or updated\). Defaults to startTime. | | `showDeleted` | boolean | No | Include deleted events | #### Output @@ -132,10 +139,12 @@ Update an existing event in Google Calendar. Returns API-aligned fields only. | `summary` | string | No | New event title/summary | | `description` | string | No | New event description | | `location` | string | No | New event location | -| `startDateTime` | string | No | New start date and time. MUST include timezone offset \(e.g., 2025-06-03T10:00:00-08:00\) OR provide timeZone parameter | -| `endDateTime` | string | No | New end date and time. MUST include timezone offset \(e.g., 2025-06-03T11:00:00-08:00\) OR provide timeZone parameter | +| `startDateTime` | string | No | New start time. Use a datetime with timezone offset \(2025-06-03T10:00:00-08:00\) or a date \(2025-06-03\) for an all-day event | +| `endDateTime` | string | No | New end time. Use a datetime with timezone offset \(2025-06-03T11:00:00-08:00\) or a date \(2025-06-04\) for an all-day event | | `timeZone` | string | No | Time zone \(e.g., America/Los_Angeles\). Required if datetime does not include offset. | -| `attendees` | array | No | Array of attendee email addresses \(replaces existing attendees\) | +| `attendees` | array | No | Array of attendee email addresses \(replaces the existing attendee list\) | +| `recurrence` | string | No | Recurrence rule\(s\) in RFC 5545 format \(e.g., RRULE:FREQ=WEEKLY;BYDAY=MO,WE,FR\). Separate multiple rules with newlines. | +| `addGoogleMeet` | boolean | No | Attach a Google Meet video conference link to the event | | `sendUpdates` | string | No | How to send updates to attendees: all, externalOnly, or none | #### Output @@ -144,10 +153,12 @@ Update an existing event in Google Calendar. Returns API-aligned fields only. | --------- | ---- | ----------- | | `id` | string | Event ID | | `htmlLink` | string | Event link | +| `hangoutLink` | string | Google Meet link | | `status` | string | Event status | | `summary` | string | Event title | | `description` | string | Event description | | `location` | string | Event location | +| `recurrence` | json | Recurrence rules | | `start` | json | Event start | | `end` | json | Event end | | `attendees` | json | Event attendees | @@ -298,7 +309,7 @@ Invite attendees to an existing Google Calendar event. Returns API-aligned field | `calendarId` | string | No | Google Calendar ID \(e.g., primary or calendar@group.calendar.google.com\) | | `eventId` | string | Yes | Google Calendar event ID to invite attendees to | | `attendees` | array | Yes | Array of attendee email addresses to invite | -| `sendUpdates` | string | No | How to send updates to attendees: all, externalOnly, or none | +| `sendUpdates` | string | No | How to send updates to attendees: all, externalOnly, or none \(defaults to all\) | | `replaceExisting` | boolean | No | Whether to replace existing attendees or add to them \(defaults to false\) | #### Output @@ -317,6 +328,113 @@ Invite attendees to an existing Google Calendar event. Returns API-aligned field | `creator` | json | Event creator | | `organizer` | json | Event organizer | +### `google_calendar_freebusy` + +Query free/busy information for one or more Google Calendars. Returns API-aligned fields only. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `calendarIds` | string | Yes | Comma-separated calendar IDs to query \(e.g., "primary,other@example.com"\) | +| `timeMin` | string | Yes | Start of the time range \(RFC3339 timestamp, e.g., 2025-06-03T00:00:00Z\) | +| `timeMax` | string | Yes | End of the time range \(RFC3339 timestamp, e.g., 2025-06-04T00:00:00Z\) | +| `timeZone` | string | No | IANA time zone \(e.g., "UTC", "America/New_York"\). Defaults to UTC. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `timeMin` | string | Start of the queried time range | +| `timeMax` | string | End of the queried time range | +| `calendars` | json | Per-calendar free/busy data with busy periods and any errors | + +### `google_calendar_create_calendar` + +Create a new secondary calendar. Returns API-aligned fields only. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `summary` | string | Yes | Title of the new calendar | +| `description` | string | No | Description of the new calendar | +| `location` | string | No | Geographic location of the calendar as free-form text | +| `timeZone` | string | No | Time zone of the calendar as an IANA name \(e.g., America/Los_Angeles\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `id` | string | Calendar ID | +| `summary` | string | Calendar title | +| `description` | string | Calendar description | +| `location` | string | Calendar location | +| `timeZone` | string | Calendar time zone | + +### `google_calendar_share_calendar` + +Grant a user, group, or domain access to a calendar. Returns API-aligned fields only. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `calendarId` | string | No | Calendar ID to share \(e.g., primary or calendar@group.calendar.google.com\) | +| `role` | string | Yes | Access role to grant: freeBusyReader, reader, writer, or owner | +| `scopeType` | string | Yes | Type of grantee: user, group, domain, or default \(public\) | +| `scopeValue` | string | No | Email \(user/group\), domain name \(domain\), or empty for default. Required unless scope type is default. | +| `sendNotifications` | boolean | No | Whether to send a notification email about the change. Defaults to true. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `id` | string | ACL rule ID | +| `role` | string | Granted access role | +| `scope` | json | Grantee scope \(type and value\) | + +### `google_calendar_list_acl` + +List the access control rules (sharing) for a calendar. Returns API-aligned fields only. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `calendarId` | string | No | Calendar ID to inspect \(e.g., primary or calendar@group.calendar.google.com\) | +| `maxResults` | number | No | Maximum number of ACL rules to return | +| `pageToken` | string | No | Token for retrieving subsequent pages of results | +| `showDeleted` | boolean | No | Include deleted ACL rules \(with role "none"\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `nextPageToken` | string | Next page token | +| `rules` | array | List of ACL rules | +| ↳ `id` | string | ACL rule ID | +| ↳ `role` | string | Access role | +| ↳ `scope` | json | Grantee scope \(type and value\) | + +### `google_calendar_unshare_calendar` + +Revoke an access control rule (sharing) from a calendar. Returns API-aligned fields only. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `calendarId` | string | No | Calendar ID to modify \(e.g., primary or calendar@group.calendar.google.com\) | +| `ruleId` | string | Yes | ACL rule ID to remove \(e.g., user:person@example.com\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `ruleId` | string | Removed ACL rule ID | +| `deleted` | boolean | Whether removal was successful | + ## Triggers diff --git a/apps/sim/blocks/blocks/google_calendar.ts b/apps/sim/blocks/blocks/google_calendar.ts index 834cf4b896a..05bd744dde5 100644 --- a/apps/sim/blocks/blocks/google_calendar.ts +++ b/apps/sim/blocks/blocks/google_calendar.ts @@ -35,6 +35,11 @@ export const GoogleCalendarBlock: BlockConfig = { { label: 'List Calendars', id: 'list_calendars' }, { label: 'Quick Add (Natural Language)', id: 'quick_add' }, { label: 'Invite Attendees', id: 'invite' }, + { label: 'Check Free/Busy', id: 'freebusy' }, + { label: 'Create Calendar', id: 'create_calendar' }, + { label: 'Share Calendar', id: 'share_calendar' }, + { label: 'List Sharing', id: 'list_acl' }, + { label: 'Remove Sharing', id: 'unshare_calendar' }, ], value: () => 'create', }, @@ -59,7 +64,6 @@ export const GoogleCalendarBlock: BlockConfig = { required: true, }, ...SERVICE_ACCOUNT_SUBBLOCKS, - // Calendar selector (basic mode) - not needed for list_calendars { id: 'calendarId', title: 'Calendar', @@ -71,9 +75,12 @@ export const GoogleCalendarBlock: BlockConfig = { placeholder: 'Select calendar', dependsOn: ['credential'], mode: 'basic', - condition: { field: 'operation', value: 'list_calendars', not: true }, + condition: { + field: 'operation', + value: ['list_calendars', 'create_calendar', 'freebusy'], + not: true, + }, }, - // Manual calendar ID input (advanced mode) - not needed for list_calendars { id: 'manualCalendarId', title: 'Calendar ID', @@ -82,10 +89,13 @@ export const GoogleCalendarBlock: BlockConfig = { placeholder: 'Enter calendar ID (e.g., primary or calendar@gmail.com)', dependsOn: ['credential'], mode: 'advanced', - condition: { field: 'operation', value: 'list_calendars', not: true }, + condition: { + field: 'operation', + value: ['list_calendars', 'create_calendar', 'freebusy'], + not: true, + }, }, - // Create Event Fields { id: 'summary', title: 'Event Title', @@ -178,7 +188,47 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`, condition: { field: 'operation', value: 'create' }, }, - // List Events Fields + { + id: 'recurrence', + title: 'Recurrence Rule', + type: 'long-input', + placeholder: 'RRULE:FREQ=WEEKLY;BYDAY=MO,WE,FR', + condition: { field: 'operation', value: ['create', 'update'] }, + mode: 'advanced', + wandConfig: { + enabled: true, + prompt: `Generate an RFC 5545 recurrence rule (RRULE) for a Google Calendar event based on the user's description. +Examples: +- "every weekday" -> RRULE:FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR +- "every Monday" -> RRULE:FREQ=WEEKLY;BYDAY=MO +- "monthly on the 1st" -> RRULE:FREQ=MONTHLY;BYMONTHDAY=1 +- "daily for 10 occurrences" -> RRULE:FREQ=DAILY;COUNT=10 + +Return ONLY the RRULE string - no explanations, no extra text.`, + placeholder: 'Describe the recurrence (e.g., "every weekday", "monthly on the 1st")...', + }, + }, + { + id: 'addGoogleMeet', + title: 'Add Google Meet', + type: 'dropdown', + condition: { field: 'operation', value: ['create', 'update'] }, + mode: 'advanced', + options: [ + { label: 'No', id: 'false' }, + { label: 'Yes', id: 'true' }, + ], + value: () => 'false', + }, + { + id: 'timeZone', + title: 'Time Zone', + type: 'short-input', + placeholder: 'America/Los_Angeles', + condition: { field: 'operation', value: ['create', 'update'] }, + mode: 'advanced', + }, + { id: 'timeMin', title: 'Start Time Filter', @@ -221,8 +271,37 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`, generationType: 'timestamp', }, }, + { + id: 'q', + title: 'Search Query', + type: 'short-input', + placeholder: 'standup', + condition: { field: 'operation', value: 'list' }, + }, + { + id: 'orderBy', + title: 'Order By', + type: 'dropdown', + condition: { field: 'operation', value: 'list' }, + mode: 'advanced', + options: [ + { label: 'Start time', id: 'startTime' }, + { label: 'Last updated', id: 'updated' }, + ], + value: () => 'startTime', + }, + { + id: 'pageToken', + title: 'Page Token', + type: 'short-input', + placeholder: 'Token from a previous response (nextPageToken)', + condition: { + field: 'operation', + value: ['list', 'instances', 'list_calendars', 'list_acl'], + }, + mode: 'advanced', + }, - // Get Event Fields { id: 'eventId', title: 'Event ID', @@ -235,7 +314,6 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`, required: true, }, - // Update Event Fields { id: 'summary', title: 'New Event Title', @@ -325,7 +403,6 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`, condition: { field: 'operation', value: 'update' }, }, - // Move Event Fields - Destination calendar selector (basic mode) { id: 'destinationCalendar', title: 'Destination Calendar', @@ -340,7 +417,6 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`, required: true, mode: 'basic', }, - // Move Event Fields - Manual destination calendar ID (advanced mode) { id: 'manualDestinationCalendarId', title: 'Destination Calendar ID', @@ -353,7 +429,6 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`, mode: 'advanced', }, - // Instances Fields { id: 'timeMin', title: 'Start Time Filter', @@ -401,10 +476,9 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`, title: 'Max Results', type: 'short-input', placeholder: '250', - condition: { field: 'operation', value: ['instances', 'list_calendars'] }, + condition: { field: 'operation', value: ['list', 'instances', 'list_calendars', 'list_acl'] }, }, - // List Calendars Fields { id: 'minAccessRole', title: 'Minimum Access Role', @@ -419,13 +493,13 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`, ], }, - // Invite Attendees Fields { id: 'attendees', title: 'Attendees (comma-separated emails)', type: 'short-input', placeholder: 'john@example.com, jane@example.com', condition: { field: 'operation', value: 'invite' }, + required: true, }, { id: 'replaceExisting', @@ -438,7 +512,6 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`, ], }, - // Quick Add Fields { id: 'text', title: 'Natural Language Event', @@ -470,10 +543,149 @@ Return ONLY the natural language event text - no explanations.`, type: 'short-input', placeholder: 'john@example.com, jane@example.com', condition: { field: 'operation', value: 'quick_add' }, + }, + + { + id: 'calendarIds', + title: 'Calendars (comma-separated IDs)', + type: 'short-input', + placeholder: 'primary, teammate@example.com', + condition: { field: 'operation', value: 'freebusy' }, + required: true, + }, + { + id: 'timeMin', + title: 'Start Time', + type: 'short-input', + placeholder: '2025-06-03T00:00:00Z', + condition: { field: 'operation', value: 'freebusy' }, + required: true, + wandConfig: { + enabled: true, + prompt: `Generate an ISO 8601 timestamp in UTC based on the user's description. +The timestamp should be in the format: YYYY-MM-DDTHH:MM:SSZ (UTC timezone). +Examples: +- "today" -> Calculate today's date at 00:00:00Z +- "next Monday" -> Calculate next Monday at 00:00:00Z + +Return ONLY the timestamp string - no explanations, no quotes, no extra text.`, + placeholder: 'Describe the start of the range (e.g., "today", "next Monday")...', + generationType: 'timestamp', + }, + }, + { + id: 'timeMax', + title: 'End Time', + type: 'short-input', + placeholder: '2025-06-04T00:00:00Z', + condition: { field: 'operation', value: 'freebusy' }, + required: true, + wandConfig: { + enabled: true, + prompt: `Generate an ISO 8601 timestamp in UTC based on the user's description. +The timestamp should be in the format: YYYY-MM-DDTHH:MM:SSZ (UTC timezone). +Examples: +- "end of today" -> Calculate today's date at 23:59:59Z +- "next Friday" -> Calculate next Friday at 00:00:00Z + +Return ONLY the timestamp string - no explanations, no quotes, no extra text.`, + placeholder: 'Describe the end of the range (e.g., "end of today", "next Friday")...', + generationType: 'timestamp', + }, + }, + + { + id: 'summary', + title: 'Calendar Name', + type: 'short-input', + placeholder: 'Team Calendar', + condition: { field: 'operation', value: 'create_calendar' }, + required: true, + }, + { + id: 'description', + title: 'Calendar Description', + type: 'long-input', + placeholder: 'Shared team events and milestones', + condition: { field: 'operation', value: 'create_calendar' }, + }, + { + id: 'location', + title: 'Calendar Location', + type: 'short-input', + placeholder: 'San Francisco, CA', + condition: { field: 'operation', value: 'create_calendar' }, + }, + { + id: 'timeZone', + title: 'Time Zone', + type: 'short-input', + placeholder: 'America/Los_Angeles', + condition: { field: 'operation', value: ['create_calendar', 'freebusy'] }, + }, + + { + id: 'role', + title: 'Access Role', + type: 'dropdown', + condition: { field: 'operation', value: 'share_calendar' }, + required: true, + options: [ + { label: 'See free/busy only', id: 'freeBusyReader' }, + { label: 'See all event details (Reader)', id: 'reader' }, + { label: 'Make changes (Writer)', id: 'writer' }, + { label: 'Make changes & manage sharing (Owner)', id: 'owner' }, + ], + value: () => 'reader', + }, + { + id: 'scopeType', + title: 'Grantee Type', + type: 'dropdown', + condition: { field: 'operation', value: 'share_calendar' }, + required: true, + options: [ + { label: 'User', id: 'user' }, + { label: 'Group', id: 'group' }, + { label: 'Domain', id: 'domain' }, + { label: 'Public (anyone)', id: 'default' }, + ], + value: () => 'user', + }, + { + id: 'scopeValue', + title: 'Grantee (email or domain)', + type: 'short-input', + placeholder: 'person@example.com', + condition: { + field: 'operation', + value: 'share_calendar', + and: { field: 'scopeType', value: 'default', not: true }, + }, + required: true, + }, + { + id: 'sendNotifications', + title: 'Send Notification Email', + type: 'dropdown', + condition: { field: 'operation', value: 'share_calendar' }, + mode: 'advanced', + options: [ + { label: 'Yes', id: 'true' }, + { label: 'No', id: 'false' }, + ], + value: () => 'true', + }, + + { + id: 'ruleId', + title: 'ACL Rule ID', + type: 'short-input', + placeholder: 'user:person@example.com', + condition: { field: 'operation', value: 'unshare_calendar' }, required: true, }, - // Notification setting (for create, update, delete, move, quick_add, invite) { id: 'sendUpdates', title: 'Send Email Notifications', @@ -502,6 +714,11 @@ Return ONLY the natural language event text - no explanations.`, 'google_calendar_list_calendars', 'google_calendar_quick_add', 'google_calendar_invite', + 'google_calendar_freebusy', + 'google_calendar_create_calendar', + 'google_calendar_share_calendar', + 'google_calendar_list_acl', + 'google_calendar_unshare_calendar', ], config: { tool: (params) => { @@ -526,6 +743,16 @@ Return ONLY the natural language event text - no explanations.`, return 'google_calendar_quick_add' case 'invite': return 'google_calendar_invite' + case 'freebusy': + return 'google_calendar_freebusy' + case 'create_calendar': + return 'google_calendar_create_calendar' + case 'share_calendar': + return 'google_calendar_share_calendar' + case 'list_acl': + return 'google_calendar_list_acl' + case 'unshare_calendar': + return 'google_calendar_unshare_calendar' default: throw new Error(`Invalid Google Calendar operation: ${params.operation}`) } @@ -541,10 +768,8 @@ Return ONLY the natural language event text - no explanations.`, ...rest } = params - // Use canonical 'calendarId' param directly const effectiveCalendarId = calendarId ? String(calendarId).trim() : '' - // Use canonical 'destinationCalendarId' param directly const effectiveDestinationCalendarId = destinationCalendarId ? String(destinationCalendarId).trim() : '' @@ -554,30 +779,36 @@ Return ONLY the natural language event text - no explanations.`, calendarId: effectiveCalendarId || 'primary', } - // Add destination calendar ID for move operation if (operation === 'move' && effectiveDestinationCalendarId) { processedParams.destinationCalendarId = effectiveDestinationCalendarId } - // Convert comma-separated attendees string to array, only if it has content if (attendees && typeof attendees === 'string' && attendees.trim().length > 0) { const attendeeList = attendees .split(',') .map((email) => email.trim()) .filter((email) => email.length > 0) - // Only add attendees if we have valid entries if (attendeeList.length > 0) { processedParams.attendees = attendeeList } } - // Convert replaceExisting string to boolean for invite operation if (operation === 'invite' && replaceExisting !== undefined) { processedParams.replaceExisting = replaceExisting === 'true' } - // Set default sendUpdates to 'all' if not specified for operations that support it + if (processedParams.addGoogleMeet !== undefined) { + processedParams.addGoogleMeet = + processedParams.addGoogleMeet === 'true' || processedParams.addGoogleMeet === true + } + + if (operation === 'share_calendar' && processedParams.sendNotifications !== undefined) { + processedParams.sendNotifications = + processedParams.sendNotifications === 'true' || + processedParams.sendNotifications === true + } + if ( ['create', 'update', 'delete', 'move', 'quick_add', 'invite'].includes(operation) && !processedParams.sendUpdates @@ -585,12 +816,10 @@ Return ONLY the natural language event text - no explanations.`, processedParams.sendUpdates = 'all' } - // Convert maxResults to number if provided if (processedParams.maxResults && typeof processedParams.maxResults === 'string') { processedParams.maxResults = Number.parseInt(processedParams.maxResults, 10) } - // Remove empty minAccessRole if (processedParams.minAccessRole === '') { processedParams.minAccessRole = undefined } @@ -607,38 +836,44 @@ Return ONLY the natural language event text - no explanations.`, oauthCredential: { type: 'string', description: 'Google Calendar access token' }, calendarId: { type: 'string', description: 'Calendar identifier (canonical param)' }, - // Create/Update operation inputs summary: { type: 'string', description: 'Event title' }, description: { type: 'string', description: 'Event description' }, location: { type: 'string', description: 'Event location' }, startDateTime: { type: 'string', description: 'Event start time' }, endDateTime: { type: 'string', description: 'Event end time' }, attendees: { type: 'string', description: 'Attendee email list' }, + recurrence: { type: 'string', description: 'Recurrence rule (RRULE)' }, + addGoogleMeet: { type: 'boolean', description: 'Attach a Google Meet link' }, + timeZone: { type: 'string', description: 'Time zone (IANA name)' }, - // List/Instances operation inputs timeMin: { type: 'string', description: 'Start time filter' }, timeMax: { type: 'string', description: 'End time filter' }, + q: { type: 'string', description: 'Free-text search query' }, + orderBy: { type: 'string', description: 'Event ordering (startTime or updated)' }, maxResults: { type: 'string', description: 'Maximum number of results' }, + pageToken: { type: 'string', description: 'Pagination token from a previous response' }, + + calendarIds: { type: 'string', description: 'Comma-separated calendar IDs' }, + + role: { type: 'string', description: 'Access role to grant' }, + scopeType: { type: 'string', description: 'Grantee type (user, group, domain, default)' }, + scopeValue: { type: 'string', description: 'Grantee email or domain' }, + sendNotifications: { type: 'boolean', description: 'Send sharing notification email' }, + ruleId: { type: 'string', description: 'ACL rule ID to remove' }, - // Get/Update/Delete/Move/Instances/Invite operation inputs eventId: { type: 'string', description: 'Event identifier' }, - // Move operation inputs destinationCalendarId: { type: 'string', description: 'Destination calendar ID (canonical param)', }, - // List Calendars operation inputs minAccessRole: { type: 'string', description: 'Minimum access role filter' }, - // Quick add inputs text: { type: 'string', description: 'Natural language event' }, - // Invite specific inputs replaceExisting: { type: 'string', description: 'Replace existing attendees' }, - // Common inputs sendUpdates: { type: 'string', description: 'Send email notifications' }, }, outputs: { @@ -670,6 +905,11 @@ export const GoogleCalendarV2Block: BlockConfig = { 'google_calendar_list_calendars_v2', 'google_calendar_quick_add_v2', 'google_calendar_invite_v2', + 'google_calendar_freebusy_v2', + 'google_calendar_create_calendar_v2', + 'google_calendar_share_calendar_v2', + 'google_calendar_list_acl_v2', + 'google_calendar_unshare_calendar_v2', ], config: { ...GoogleCalendarBlock.tools?.config, @@ -682,28 +922,30 @@ export const GoogleCalendarV2Block: BlockConfig = { }, }, outputs: { - // Event outputs (create, get, update, move, quick_add, invite) - id: { type: 'string', description: 'Event ID' }, + id: { type: 'string', description: 'Event or calendar ID' }, htmlLink: { type: 'string', description: 'Event link' }, + hangoutLink: { type: 'string', description: 'Google Meet link' }, status: { type: 'string', description: 'Event status' }, - summary: { type: 'string', description: 'Event title' }, - description: { type: 'string', description: 'Event description' }, - location: { type: 'string', description: 'Event location' }, + summary: { type: 'string', description: 'Event or calendar title' }, + description: { type: 'string', description: 'Event or calendar description' }, + location: { type: 'string', description: 'Event or calendar location' }, + recurrence: { type: 'json', description: 'Recurrence rules (RRULE)' }, start: { type: 'json', description: 'Event start' }, end: { type: 'json', description: 'Event end' }, attendees: { type: 'json', description: 'Event attendees' }, creator: { type: 'json', description: 'Event creator' }, organizer: { type: 'json', description: 'Event organizer' }, - // List events outputs events: { type: 'json', description: 'List of events (list operation)' }, - // Delete outputs eventId: { type: 'string', description: 'Deleted event ID' }, - deleted: { type: 'boolean', description: 'Whether deletion was successful' }, - // Instances outputs + deleted: { type: 'boolean', description: 'Whether deletion/removal was successful' }, instances: { type: 'json', description: 'List of recurring event instances' }, - // List calendars outputs calendars: { type: 'json', description: 'List of calendars' }, - // Common outputs + timeMin: { type: 'string', description: 'Start of the queried free/busy range' }, + timeMax: { type: 'string', description: 'End of the queried free/busy range' }, + role: { type: 'string', description: 'Granted access role (share operation)' }, + scope: { type: 'json', description: 'Grantee scope (share operation)' }, + rules: { type: 'json', description: 'List of ACL sharing rules (list sharing operation)' }, + ruleId: { type: 'string', description: 'Removed ACL rule ID (remove sharing operation)' }, nextPageToken: { type: 'string', description: 'Next page token' }, timeZone: { type: 'string', description: 'Calendar time zone' }, }, diff --git a/apps/sim/lib/integrations/integrations.json b/apps/sim/lib/integrations/integrations.json index 5f11ba3c480..360f0940141 100644 --- a/apps/sim/lib/integrations/integrations.json +++ b/apps/sim/lib/integrations/integrations.json @@ -1,5 +1,5 @@ { - "updatedAt": "2026-06-15", + "updatedAt": "2026-06-16", "integrations": [ { "type": "onepassword", @@ -5901,9 +5901,29 @@ { "name": "Invite Attendees", "description": "Invite attendees to an existing Google Calendar event" + }, + { + "name": "Check Free/Busy", + "description": "Query free/busy information for one or more Google Calendars" + }, + { + "name": "Create Calendar", + "description": "Create a new secondary calendar" + }, + { + "name": "Share Calendar", + "description": "Grant a user, group, or domain access to a calendar by creating an ACL rule" + }, + { + "name": "List Sharing", + "description": "List the access control rules (sharing) for a calendar" + }, + { + "name": "Remove Sharing", + "description": "Revoke an access control rule (sharing) from a calendar" } ], - "operationCount": 10, + "operationCount": 15, "triggers": [ { "id": "google_calendar_poller", diff --git a/apps/sim/tools/google_calendar/create.ts b/apps/sim/tools/google_calendar/create.ts index a8d55604eea..97514072be6 100644 --- a/apps/sim/tools/google_calendar/create.ts +++ b/apps/sim/tools/google_calendar/create.ts @@ -1,10 +1,18 @@ import { CALENDAR_API_BASE, + type CalendarAttendee, type GoogleCalendarApiEventResponse, type GoogleCalendarCreateParams, type GoogleCalendarCreateResponse, type GoogleCalendarEventRequestBody, } from '@/tools/google_calendar/types' +import { + assertRecurringTimeZone, + buildEventDateTime, + buildGoogleMeetConferenceData, + normalizeAttendees, + normalizeRecurrence, +} from '@/tools/google_calendar/utils' import type { ToolConfig } from '@/tools/types' export const createTool: ToolConfig = { @@ -54,22 +62,21 @@ export const createTool: ToolConfig ({ @@ -105,21 +126,17 @@ export const createTool: ToolConfig { - // Default timezone if not provided and datetime doesn't include offset - const timeZone = params.timeZone || 'America/Los_Angeles' - const needsTimezone = - !params.startDateTime.includes('+') && !params.startDateTime.includes('-', 10) + const recurrence = normalizeRecurrence(params.recurrence) + const isRecurring = recurrence.length > 0 + + if (isRecurring) { + assertRecurringTimeZone([params.startDateTime, params.endDateTime], params.timeZone) + } const eventData: GoogleCalendarEventRequestBody = { summary: params.summary, - start: { - dateTime: params.startDateTime, - ...(needsTimezone ? { timeZone } : {}), - }, - end: { - dateTime: params.endDateTime, - ...(needsTimezone ? { timeZone } : {}), - }, + start: buildEventDateTime(params.startDateTime, params.timeZone), + end: buildEventDateTime(params.endDateTime, params.timeZone), } if (params.description) { @@ -130,29 +147,17 @@ export const createTool: ToolConfig 0) { + eventData.attendees = attendees } - // Handle both string and array cases for attendees - let attendeeList: string[] = [] - if (params.attendees) { - const attendees = params.attendees as string | string[] - if (Array.isArray(attendees)) { - attendeeList = attendees.filter((email: string) => email && email.trim().length > 0) - } else if (typeof attendees === 'string' && attendees.trim().length > 0) { - // Convert comma-separated string to array - attendeeList = attendees - .split(',') - .map((email: string) => email.trim()) - .filter((email: string) => email.length > 0) - } + if (isRecurring) { + eventData.recurrence = recurrence } - if (attendeeList.length > 0) { - eventData.attendees = attendeeList.map((email: string) => ({ email })) + if (params.addGoogleMeet) { + eventData.conferenceData = buildGoogleMeetConferenceData() } return eventData @@ -169,10 +174,12 @@ export const createTool: ToolConfig = { + id: 'google_calendar_create_calendar', + name: 'Google Calendar Create Calendar', + description: 'Create a new secondary calendar', + version: '1.0.0', + + oauth: { + required: true, + provider: 'google-calendar', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'Access token for Google Calendar API', + }, + summary: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Title of the new calendar', + }, + description: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Description of the new calendar', + }, + location: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Geographic location of the calendar as free-form text', + }, + timeZone: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Time zone of the calendar as an IANA name (e.g., America/Los_Angeles)', + }, + }, + + request: { + url: () => `${CALENDAR_API_BASE}/calendars`, + method: 'POST', + headers: (params: GoogleCalendarCreateCalendarParams) => ({ + Authorization: `Bearer ${params.accessToken}`, + 'Content-Type': 'application/json', + }), + body: (params: GoogleCalendarCreateCalendarParams) => { + const body: Record = { summary: params.summary } + if (params.description) body.description = params.description + if (params.location) body.location = params.location + if (params.timeZone) body.timeZone = params.timeZone + return body + }, + }, + + transformResponse: async (response: Response) => { + const data: GoogleCalendarApiCalendarResponse = await response.json() + + return { + success: true, + output: { + content: `Calendar "${data.summary}" created successfully`, + metadata: { + id: data.id, + summary: data.summary, + description: data.description, + location: data.location, + timeZone: data.timeZone, + }, + }, + } + }, + + outputs: { + content: { type: 'string', description: 'Calendar creation confirmation message' }, + metadata: { + type: 'json', + description: 'Created calendar metadata (id, summary, description, location, timeZone)', + }, + }, +} + +interface GoogleCalendarCreateCalendarV2Response { + success: boolean + output: { + id: string + summary: string + description: string | null + location: string | null + timeZone: string | null + } +} + +export const createCalendarV2Tool: ToolConfig< + GoogleCalendarCreateCalendarParams, + GoogleCalendarCreateCalendarV2Response +> = { + id: 'google_calendar_create_calendar_v2', + name: 'Google Calendar Create Calendar', + description: 'Create a new secondary calendar. Returns API-aligned fields only.', + version: '2.0.0', + oauth: createCalendarTool.oauth, + params: createCalendarTool.params, + request: createCalendarTool.request, + transformResponse: async (response: Response) => { + const data: GoogleCalendarApiCalendarResponse = await response.json() + + return { + success: true, + output: { + id: data.id, + summary: data.summary, + description: data.description ?? null, + location: data.location ?? null, + timeZone: data.timeZone ?? null, + }, + } + }, + outputs: { + id: { type: 'string', description: 'Calendar ID' }, + summary: { type: 'string', description: 'Calendar title' }, + description: { type: 'string', description: 'Calendar description', optional: true }, + location: { type: 'string', description: 'Calendar location', optional: true }, + timeZone: { type: 'string', description: 'Calendar time zone', optional: true }, + }, +} diff --git a/apps/sim/tools/google_calendar/delete.ts b/apps/sim/tools/google_calendar/delete.ts index 9125d93827b..7eb9c86729b 100644 --- a/apps/sim/tools/google_calendar/delete.ts +++ b/apps/sim/tools/google_calendar/delete.ts @@ -70,7 +70,6 @@ export const deleteTool: ToolConfig { - // DELETE returns 204 No Content on success if (response.status === 204 || response.ok) { return { success: true, diff --git a/apps/sim/tools/google_calendar/index.ts b/apps/sim/tools/google_calendar/index.ts index 4d55e57ad90..22d3ca97a9f 100644 --- a/apps/sim/tools/google_calendar/index.ts +++ b/apps/sim/tools/google_calendar/index.ts @@ -1,35 +1,50 @@ import { createTool, createV2Tool } from '@/tools/google_calendar/create' +import { createCalendarTool, createCalendarV2Tool } from '@/tools/google_calendar/create_calendar' import { deleteTool, deleteV2Tool } from '@/tools/google_calendar/delete' import { freebusyTool, freebusyV2Tool } from '@/tools/google_calendar/freebusy' import { getTool, getV2Tool } from '@/tools/google_calendar/get' import { instancesTool, instancesV2Tool } from '@/tools/google_calendar/instances' import { inviteTool, inviteV2Tool } from '@/tools/google_calendar/invite' import { listTool, listV2Tool } from '@/tools/google_calendar/list' +import { listAclTool, listAclV2Tool } from '@/tools/google_calendar/list_acl' import { listCalendarsTool, listCalendarsV2Tool } from '@/tools/google_calendar/list_calendars' import { moveTool, moveV2Tool } from '@/tools/google_calendar/move' import { quickAddTool, quickAddV2Tool } from '@/tools/google_calendar/quick_add' +import { shareCalendarTool, shareCalendarV2Tool } from '@/tools/google_calendar/share_calendar' +import { + unshareCalendarTool, + unshareCalendarV2Tool, +} from '@/tools/google_calendar/unshare_calendar' import { updateTool, updateV2Tool } from '@/tools/google_calendar/update' export const googleCalendarCreateTool = createTool +export const googleCalendarCreateCalendarTool = createCalendarTool export const googleCalendarDeleteTool = deleteTool export const googleCalendarFreeBusyTool = freebusyTool export const googleCalendarGetTool = getTool export const googleCalendarInstancesTool = instancesTool export const googleCalendarInviteTool = inviteTool export const googleCalendarListTool = listTool +export const googleCalendarListAclTool = listAclTool export const googleCalendarListCalendarsTool = listCalendarsTool export const googleCalendarMoveTool = moveTool export const googleCalendarQuickAddTool = quickAddTool +export const googleCalendarShareCalendarTool = shareCalendarTool +export const googleCalendarUnshareCalendarTool = unshareCalendarTool export const googleCalendarUpdateTool = updateTool export const googleCalendarCreateV2Tool = createV2Tool +export const googleCalendarCreateCalendarV2Tool = createCalendarV2Tool export const googleCalendarDeleteV2Tool = deleteV2Tool export const googleCalendarFreeBusyV2Tool = freebusyV2Tool export const googleCalendarGetV2Tool = getV2Tool export const googleCalendarInstancesV2Tool = instancesV2Tool export const googleCalendarInviteV2Tool = inviteV2Tool export const googleCalendarListV2Tool = listV2Tool +export const googleCalendarListAclV2Tool = listAclV2Tool export const googleCalendarListCalendarsV2Tool = listCalendarsV2Tool export const googleCalendarMoveV2Tool = moveV2Tool export const googleCalendarQuickAddV2Tool = quickAddV2Tool +export const googleCalendarShareCalendarV2Tool = shareCalendarV2Tool +export const googleCalendarUnshareCalendarV2Tool = unshareCalendarV2Tool export const googleCalendarUpdateV2Tool = updateV2Tool diff --git a/apps/sim/tools/google_calendar/invite.ts b/apps/sim/tools/google_calendar/invite.ts index 18d9a1d15f0..f785870fbf2 100644 --- a/apps/sim/tools/google_calendar/invite.ts +++ b/apps/sim/tools/google_calendar/invite.ts @@ -1,10 +1,102 @@ import { CALENDAR_API_BASE, + type CalendarAttendee, + type GoogleCalendarApiEventResponse, type GoogleCalendarInviteParams, type GoogleCalendarInviteResponse, } from '@/tools/google_calendar/types' +import { normalizeAttendees } from '@/tools/google_calendar/utils' import type { ToolConfig } from '@/tools/types' +interface InviteResult { + data: GoogleCalendarApiEventResponse + totalAttendees: number + newAttendeesAdded: number + shouldReplace: boolean +} + +/** + * The Google Calendar update method replaces the entire event resource, so to invite + * attendees we read the existing event, merge the attendee list, then PUT it back. + */ +async function inviteAttendees( + response: Response, + params: GoogleCalendarInviteParams | undefined +): Promise { + const existingEvent: GoogleCalendarApiEventResponse = await response.json() + + if (!existingEvent.start || !existingEvent.end || !existingEvent.summary) { + throw new Error('Existing event is missing required fields (start, end, or summary)') + } + + const newAttendeeList = normalizeAttendees(params?.attendees).map((attendee) => attendee.email) + const existingAttendees: CalendarAttendee[] = existingEvent.attendees ?? [] + const shouldReplace = + params?.replaceExisting === true || String(params?.replaceExisting) === 'true' + + const existingEmails = new Set( + existingAttendees.map((attendee) => attendee.email?.toLowerCase() ?? '') + ) + const newAttendeesAdded = shouldReplace + ? newAttendeeList.length + : newAttendeeList.filter((email) => !existingEmails.has(email.toLowerCase())).length + + let finalAttendees: CalendarAttendee[] + if (shouldReplace) { + finalAttendees = newAttendeeList.map((email) => ({ email, responseStatus: 'needsAction' })) + } else { + finalAttendees = [...existingAttendees] + for (const email of newAttendeeList) { + if (!existingEmails.has(email.toLowerCase())) { + finalAttendees.push({ email, responseStatus: 'needsAction' }) + } + } + } + + const updatedEvent: Record = { ...existingEvent, attendees: finalAttendees } + const readOnlyFields = [ + 'id', + 'etag', + 'kind', + 'created', + 'updated', + 'htmlLink', + 'iCalUID', + 'creator', + 'organizer', + ] + for (const field of readOnlyFields) { + delete updatedEvent[field] + } + + const calendarId = params?.calendarId?.trim() || 'primary' + const queryParams = new URLSearchParams() + queryParams.append('sendUpdates', params?.sendUpdates ?? 'all') + const putUrl = `${CALENDAR_API_BASE}/calendars/${encodeURIComponent(calendarId)}/events/${encodeURIComponent(params?.eventId?.trim() ?? '')}?${queryParams.toString()}` + + const putResponse = await fetch(putUrl, { + method: 'PUT', + headers: { + Authorization: `Bearer ${params?.accessToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(updatedEvent), + }) + + if (!putResponse.ok) { + const errorData = await putResponse.json().catch(() => null) + throw new Error(errorData?.error?.message || 'Failed to invite attendees to calendar event') + } + + const data: GoogleCalendarApiEventResponse = await putResponse.json() + return { + data, + totalAttendees: data.attendees?.length ?? 0, + newAttendeesAdded, + shouldReplace, + } +} + export const inviteTool: ToolConfig = { id: 'google_calendar_invite', name: 'Google Calendar Invite Attendees', @@ -45,7 +137,7 @@ export const inviteTool: ToolConfig { - const calendarId = params.calendarId || 'primary' - return `${CALENDAR_API_BASE}/calendars/${encodeURIComponent(calendarId)}/events/${encodeURIComponent(params.eventId)}` + const calendarId = params.calendarId?.trim() || 'primary' + return `${CALENDAR_API_BASE}/calendars/${encodeURIComponent(calendarId)}/events/${encodeURIComponent(params.eventId.trim())}` }, method: 'GET', headers: (params: GoogleCalendarInviteParams) => ({ @@ -68,163 +160,29 @@ export const inviteTool: ToolConfig { - const existingEvent = await response.json() + const { data, totalAttendees, newAttendeesAdded, shouldReplace } = await inviteAttendees( + response, + params + ) - // Validate required fields exist - if (!existingEvent.start || !existingEvent.end || !existingEvent.summary) { - throw new Error('Existing event is missing required fields (start, end, or summary)') - } - - // Process new attendees - handle both string and array formats - let newAttendeeList: string[] = [] - - if (params?.attendees) { - if (Array.isArray(params.attendees)) { - // Already an array from block processing - newAttendeeList = params.attendees.filter( - (email: string) => email && email.trim().length > 0 - ) - } else if ( - typeof (params.attendees as any) === 'string' && - (params.attendees as any).trim().length > 0 - ) { - // Fallback: process comma-separated string if block didn't convert it - newAttendeeList = (params.attendees as any) - .split(',') - .map((email: string) => email.trim()) - .filter((email: string) => email.length > 0) - } - } - - // Calculate final attendees list - const existingAttendees = existingEvent.attendees || [] - let finalAttendees: Array = [] - - // Handle replaceExisting properly - check for both boolean true and string "true" - const shouldReplace = - params?.replaceExisting === true || (params?.replaceExisting as any) === 'true' - - if (shouldReplace) { - // Replace all attendees with just the new ones - finalAttendees = newAttendeeList.map((email: string) => ({ - email, - responseStatus: 'needsAction', - })) - } else { - // Add to existing attendees (preserve all existing ones) - - // Start with ALL existing attendees - preserve them completely - finalAttendees = [...existingAttendees] - - // Get set of existing emails for duplicate checking (case-insensitive) - const existingEmails = new Set( - existingAttendees.map((attendee: any) => attendee.email?.toLowerCase() || '') - ) - - // Add only new attendees that don't already exist - for (const newEmail of newAttendeeList) { - const emailLower = newEmail.toLowerCase() - if (!existingEmails.has(emailLower)) { - finalAttendees.push({ - email: newEmail, - responseStatus: 'needsAction', - }) - } - } - } - - // Use the complete existing event object and only modify the attendees field - // This is crucial because the Google Calendar API update method "does not support patch semantics - // and always updates the entire event resource" according to the documentation - const updatedEvent = { - ...existingEvent, // Start with the complete existing event to preserve all fields - attendees: finalAttendees, // Only modify the attendees field - } - - // Remove read-only fields that shouldn't be included in updates - const readOnlyFields = [ - 'id', - 'etag', - 'kind', - 'created', - 'updated', - 'htmlLink', - 'iCalUID', - 'sequence', - 'creator', - 'organizer', - ] - readOnlyFields.forEach((field) => { - delete updatedEvent[field] - }) - - // Construct PUT URL with query parameters - const calendarId = params?.calendarId || 'primary' - const queryParams = new URLSearchParams() - if (params?.sendUpdates !== undefined) { - queryParams.append('sendUpdates', params.sendUpdates) - } - - const queryString = queryParams.toString() - const putUrl = `${CALENDAR_API_BASE}/calendars/${encodeURIComponent(calendarId)}/events/${encodeURIComponent(params?.eventId || '')}${queryString ? `?${queryString}` : ''}` - - // Send PUT request to update the event - const putResponse = await fetch(putUrl, { - method: 'PUT', - headers: { - Authorization: `Bearer ${params?.accessToken}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify(updatedEvent), - }) - - // Handle the PUT response - if (!putResponse.ok) { - const errorData = await putResponse.json() - throw new Error(errorData.error?.message || 'Failed to invite attendees to calendar event') - } - - const data = await putResponse.json() - const totalAttendees = data.attendees?.length || 0 - - // Calculate how many new attendees were actually added - let newAttendeesAdded = 0 - - if (shouldReplace) { - newAttendeesAdded = newAttendeeList.length - } else { - // Count how many of the new emails weren't already in the existing list - const existingEmails = new Set( - existingAttendees.map((attendee: any) => attendee.email?.toLowerCase() || '') - ) - newAttendeesAdded = newAttendeeList.filter( - (email) => !existingEmails.has(email.toLowerCase()) - ).length - } - - // Improved messaging about email delivery let baseMessage: string if (shouldReplace) { baseMessage = `Successfully updated event "${data.summary}" with ${totalAttendees} attendee${totalAttendees !== 1 ? 's' : ''}` + } else if (newAttendeesAdded > 0) { + baseMessage = `Successfully added ${newAttendeesAdded} new attendee${newAttendeesAdded !== 1 ? 's' : ''} to event "${data.summary}" (total: ${totalAttendees})` } else { - if (newAttendeesAdded > 0) { - baseMessage = `Successfully added ${newAttendeesAdded} new attendee${newAttendeesAdded !== 1 ? 's' : ''} to event "${data.summary}" (total: ${totalAttendees})` - } else { - baseMessage = `No new attendees added to event "${data.summary}" - all specified attendees were already invited (total: ${totalAttendees})` - } + baseMessage = `No new attendees added to event "${data.summary}" - all specified attendees were already invited (total: ${totalAttendees})` } const emailNote = params?.sendUpdates !== 'none' - ? ` Email invitations are being sent asynchronously - delivery may take a few minutes and depends on recipients' Google Calendar settings.` - : ` No email notifications will be sent as requested.` - - const content = baseMessage + emailNote + ? ' Email invitations are being sent asynchronously - delivery may take a few minutes and depends on recipients’ Google Calendar settings.' + : ' No email notifications will be sent as requested.' return { success: true, output: { - content, + content: baseMessage + emailNote, metadata: { id: data.id, htmlLink: data.htmlLink, @@ -258,16 +216,16 @@ interface GoogleCalendarInviteV2Response { success: boolean output: { id: string - htmlLink?: string - status?: string - summary?: string - description?: string - location?: string - start?: any - end?: any - attendees?: any - creator?: any - organizer?: any + htmlLink: string + status: string + summary: string | null + description: string | null + location: string | null + start: GoogleCalendarApiEventResponse['start'] + end: GoogleCalendarApiEventResponse['end'] + attendees: CalendarAttendee[] | null + creator: GoogleCalendarApiEventResponse['creator'] | null + organizer: GoogleCalendarApiEventResponse['organizer'] | null } } @@ -282,104 +240,7 @@ export const inviteV2Tool: ToolConfig { - const existingEvent = await response.json() - - if (!existingEvent.start || !existingEvent.end || !existingEvent.summary) { - throw new Error('Existing event is missing required fields (start, end, or summary)') - } - - let newAttendeeList: string[] = [] - - if (params?.attendees) { - if (Array.isArray(params.attendees)) { - newAttendeeList = params.attendees.filter( - (email: string) => email && email.trim().length > 0 - ) - } else if ( - typeof (params.attendees as any) === 'string' && - (params.attendees as any).trim().length > 0 - ) { - newAttendeeList = (params.attendees as any) - .split(',') - .map((email: string) => email.trim()) - .filter((email: string) => email.length > 0) - } - } - - const existingAttendees = existingEvent.attendees || [] - let finalAttendees: Array = [] - - const shouldReplace = - params?.replaceExisting === true || (params?.replaceExisting as any) === 'true' - - if (shouldReplace) { - finalAttendees = newAttendeeList.map((email: string) => ({ - email, - responseStatus: 'needsAction', - })) - } else { - finalAttendees = [...existingAttendees] - - const existingEmails = new Set( - existingAttendees.map((attendee: any) => attendee.email?.toLowerCase() || '') - ) - - for (const newEmail of newAttendeeList) { - const emailLower = newEmail.toLowerCase() - if (!existingEmails.has(emailLower)) { - finalAttendees.push({ - email: newEmail, - responseStatus: 'needsAction', - }) - } - } - } - - const updatedEvent = { - ...existingEvent, - attendees: finalAttendees, - } - - const readOnlyFields = [ - 'id', - 'etag', - 'kind', - 'created', - 'updated', - 'htmlLink', - 'iCalUID', - 'sequence', - 'creator', - 'organizer', - ] - readOnlyFields.forEach((field) => { - delete updatedEvent[field] - }) - - const calendarId = params?.calendarId || 'primary' - const queryParams = new URLSearchParams() - if (params?.sendUpdates !== undefined) { - queryParams.append('sendUpdates', params.sendUpdates) - } - - const queryString = queryParams.toString() - const putUrl = `${CALENDAR_API_BASE}/calendars/${encodeURIComponent(calendarId)}/events/${encodeURIComponent(params?.eventId || '')}${queryString ? `?${queryString}` : ''}` - - const putResponse = await fetch(putUrl, { - method: 'PUT', - headers: { - Authorization: `Bearer ${params?.accessToken}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify(updatedEvent), - }) - - if (!putResponse.ok) { - const errorData = await putResponse.json() - throw new Error(errorData.error?.message || 'Failed to invite attendees to calendar event') - } - - const data = await putResponse.json() + const { data } = await inviteAttendees(response, params) return { success: true, @@ -393,8 +254,8 @@ export const inviteV2Tool: ToolConfig> + events: GoogleCalendarListV2Event[] } } diff --git a/apps/sim/tools/google_calendar/list_acl.ts b/apps/sim/tools/google_calendar/list_acl.ts new file mode 100644 index 00000000000..28404c04145 --- /dev/null +++ b/apps/sim/tools/google_calendar/list_acl.ts @@ -0,0 +1,152 @@ +import { + CALENDAR_API_BASE, + type GoogleCalendarApiAclListResponse, + type GoogleCalendarListAclParams, + type GoogleCalendarListAclResponse, +} from '@/tools/google_calendar/types' +import type { ToolConfig } from '@/tools/types' + +export const listAclTool: ToolConfig = { + id: 'google_calendar_list_acl', + name: 'Google Calendar List Sharing', + description: 'List the access control rules (sharing) for a calendar', + version: '1.0.0', + + oauth: { + required: true, + provider: 'google-calendar', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'Access token for Google Calendar API', + }, + calendarId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Calendar ID to inspect (e.g., primary or calendar@group.calendar.google.com)', + }, + maxResults: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Maximum number of ACL rules to return', + }, + pageToken: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Token for retrieving subsequent pages of results', + }, + showDeleted: { + type: 'boolean', + required: false, + visibility: 'user-only', + description: 'Include deleted ACL rules (with role "none")', + }, + }, + + request: { + url: (params: GoogleCalendarListAclParams) => { + const calendarId = params.calendarId?.trim() || 'primary' + const queryParams = new URLSearchParams() + if (params.maxResults) queryParams.append('maxResults', params.maxResults.toString()) + if (params.pageToken) queryParams.append('pageToken', params.pageToken) + if (params.showDeleted !== undefined) + queryParams.append('showDeleted', params.showDeleted.toString()) + const queryString = queryParams.toString() + return `${CALENDAR_API_BASE}/calendars/${encodeURIComponent(calendarId)}/acl${queryString ? `?${queryString}` : ''}` + }, + method: 'GET', + headers: (params: GoogleCalendarListAclParams) => ({ + Authorization: `Bearer ${params.accessToken}`, + 'Content-Type': 'application/json', + }), + }, + + transformResponse: async (response: Response) => { + const data: GoogleCalendarApiAclListResponse = await response.json() + const rules = data.items || [] + const rulesCount = rules.length + + return { + success: true, + output: { + content: `Found ${rulesCount} sharing rule${rulesCount !== 1 ? 's' : ''}`, + metadata: { + nextPageToken: data.nextPageToken, + rules: rules.map((rule) => ({ + id: rule.id, + role: rule.role, + scope: rule.scope, + })), + }, + }, + } + }, + + outputs: { + content: { type: 'string', description: 'Summary of found sharing rules count' }, + metadata: { + type: 'json', + description: 'List of ACL rules with pagination token', + }, + }, +} + +interface GoogleCalendarListAclV2Response { + success: boolean + output: { + nextPageToken: string | null + rules: Array<{ id: string; role: string; scope: { type: string; value?: string } }> + } +} + +export const listAclV2Tool: ToolConfig< + GoogleCalendarListAclParams, + GoogleCalendarListAclV2Response +> = { + id: 'google_calendar_list_acl_v2', + name: 'Google Calendar List Sharing', + description: + 'List the access control rules (sharing) for a calendar. Returns API-aligned fields only.', + version: '2.0.0', + oauth: listAclTool.oauth, + params: listAclTool.params, + request: listAclTool.request, + transformResponse: async (response: Response) => { + const data: GoogleCalendarApiAclListResponse = await response.json() + const rules = data.items || [] + + return { + success: true, + output: { + nextPageToken: data.nextPageToken ?? null, + rules: rules.map((rule) => ({ + id: rule.id, + role: rule.role, + scope: rule.scope, + })), + }, + } + }, + outputs: { + nextPageToken: { type: 'string', description: 'Next page token', optional: true }, + rules: { + type: 'array', + description: 'List of ACL rules', + items: { + type: 'object', + properties: { + id: { type: 'string', description: 'ACL rule ID' }, + role: { type: 'string', description: 'Access role' }, + scope: { type: 'json', description: 'Grantee scope (type and value)' }, + }, + }, + }, + }, +} diff --git a/apps/sim/tools/google_calendar/move.ts b/apps/sim/tools/google_calendar/move.ts index e3bdbf28f9f..7332db60de4 100644 --- a/apps/sim/tools/google_calendar/move.ts +++ b/apps/sim/tools/google_calendar/move.ts @@ -121,7 +121,7 @@ export const moveTool: ToolConfig { const data = await response.json() - // Handle attendees if provided let finalEventData = data if (params?.attendees) { let attendeeList: string[] = [] @@ -88,7 +87,6 @@ export const quickAddTool: ToolConfig< if (Array.isArray(attendees)) { attendeeList = attendees.filter((email: string) => email && email.trim().length > 0) } else if (typeof attendees === 'string' && attendees.trim().length > 0) { - // Convert comma-separated string to array attendeeList = attendees .split(',') .map((email: string) => email.trim()) @@ -97,16 +95,13 @@ export const quickAddTool: ToolConfig< if (attendeeList.length > 0) { try { - // Update the event with attendees const calendarId = params.calendarId || 'primary' const eventId = data.id - // Prepare update data const updateData = { attendees: attendeeList.map((email: string) => ({ email })), } - // Build update URL with sendUpdates if specified const updateQueryParams = new URLSearchParams() if (params.sendUpdates !== undefined) { updateQueryParams.append('sendUpdates', params.sendUpdates) @@ -114,7 +109,6 @@ export const quickAddTool: ToolConfig< const updateUrl = `${CALENDAR_API_BASE}/calendars/${encodeURIComponent(calendarId)}/events/${eventId}${updateQueryParams.toString() ? `?${updateQueryParams.toString()}` : ''}` - // Make the update request const updateResponse = await fetch(updateUrl, { method: 'PATCH', headers: { @@ -127,13 +121,11 @@ export const quickAddTool: ToolConfig< if (updateResponse.ok) { finalEventData = await updateResponse.json() } else { - // If update fails, we still return the original event but log the error logger.warn('Failed to add attendees to quick-added event', { error: await updateResponse.text(), }) } } catch (error) { - // If attendee update fails, we still return the original event logger.warn('Error adding attendees to quick-added event', { error }) } } diff --git a/apps/sim/tools/google_calendar/share_calendar.ts b/apps/sim/tools/google_calendar/share_calendar.ts new file mode 100644 index 00000000000..faf6b3f3dcc --- /dev/null +++ b/apps/sim/tools/google_calendar/share_calendar.ts @@ -0,0 +1,160 @@ +import { + CALENDAR_API_BASE, + type GoogleCalendarApiAclRule, + type GoogleCalendarShareCalendarParams, + type GoogleCalendarShareCalendarResponse, +} from '@/tools/google_calendar/types' +import type { ToolConfig } from '@/tools/types' + +const buildAclBody = (params: GoogleCalendarShareCalendarParams) => { + const scope: { type: string; value?: string } = { type: params.scopeType } + if (params.scopeType !== 'default') { + const value = params.scopeValue?.trim() + if (!value) { + throw new Error( + `A grantee is required when scope type is "${params.scopeType}". Provide an email (user/group) or domain name in scopeValue.` + ) + } + scope.value = value + } + return { role: params.role, scope } +} + +const buildAclUrl = (params: GoogleCalendarShareCalendarParams) => { + const calendarId = params.calendarId?.trim() || 'primary' + const queryParams = new URLSearchParams() + if (params.sendNotifications !== undefined) { + queryParams.append('sendNotifications', String(params.sendNotifications)) + } + const queryString = queryParams.toString() + return `${CALENDAR_API_BASE}/calendars/${encodeURIComponent(calendarId)}/acl${queryString ? `?${queryString}` : ''}` +} + +export const shareCalendarTool: ToolConfig< + GoogleCalendarShareCalendarParams, + GoogleCalendarShareCalendarResponse +> = { + id: 'google_calendar_share_calendar', + name: 'Google Calendar Share Calendar', + description: 'Grant a user, group, or domain access to a calendar by creating an ACL rule', + version: '1.0.0', + + oauth: { + required: true, + provider: 'google-calendar', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'Access token for Google Calendar API', + }, + calendarId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Calendar ID to share (e.g., primary or calendar@group.calendar.google.com)', + }, + role: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Access role to grant: freeBusyReader, reader, writer, or owner', + }, + scopeType: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Type of grantee: user, group, domain, or default (public)', + }, + scopeValue: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Email (user/group), domain name (domain), or empty for default. Required unless scope type is default.', + }, + sendNotifications: { + type: 'boolean', + required: false, + visibility: 'user-only', + description: 'Whether to send a notification email about the change. Defaults to true.', + }, + }, + + request: { + url: buildAclUrl, + method: 'POST', + headers: (params: GoogleCalendarShareCalendarParams) => ({ + Authorization: `Bearer ${params.accessToken}`, + 'Content-Type': 'application/json', + }), + body: buildAclBody, + }, + + transformResponse: async (response: Response) => { + const data: GoogleCalendarApiAclRule = await response.json() + + return { + success: true, + output: { + content: `Granted ${data.role} access to ${data.scope?.value || data.scope?.type}`, + metadata: { + id: data.id, + role: data.role, + scope: data.scope, + }, + }, + } + }, + + outputs: { + content: { type: 'string', description: 'Sharing confirmation message' }, + metadata: { + type: 'json', + description: 'Created ACL rule (id, role, scope)', + }, + }, +} + +interface GoogleCalendarShareCalendarV2Response { + success: boolean + output: { + id: string + role: string + scope: { type: string; value?: string } + } +} + +export const shareCalendarV2Tool: ToolConfig< + GoogleCalendarShareCalendarParams, + GoogleCalendarShareCalendarV2Response +> = { + id: 'google_calendar_share_calendar_v2', + name: 'Google Calendar Share Calendar', + description: + 'Grant a user, group, or domain access to a calendar. Returns API-aligned fields only.', + version: '2.0.0', + oauth: shareCalendarTool.oauth, + params: shareCalendarTool.params, + request: shareCalendarTool.request, + transformResponse: async (response: Response) => { + const data: GoogleCalendarApiAclRule = await response.json() + + return { + success: true, + output: { + id: data.id, + role: data.role, + scope: data.scope, + }, + } + }, + outputs: { + id: { type: 'string', description: 'ACL rule ID' }, + role: { type: 'string', description: 'Granted access role' }, + scope: { type: 'json', description: 'Grantee scope (type and value)' }, + }, +} diff --git a/apps/sim/tools/google_calendar/types.ts b/apps/sim/tools/google_calendar/types.ts index 31003dd0a74..b48d884fb30 100644 --- a/apps/sim/tools/google_calendar/types.ts +++ b/apps/sim/tools/google_calendar/types.ts @@ -2,8 +2,7 @@ import type { ToolResponse } from '@/tools/types' export const CALENDAR_API_BASE = 'https://www.googleapis.com/calendar/v3' -// Shared attendee interface that matches Google Calendar API specification -interface CalendarAttendee { +export interface CalendarAttendee { id?: string email: string displayName?: string @@ -18,7 +17,7 @@ interface CalendarAttendee { interface BaseGoogleCalendarParams { accessToken: string - calendarId?: string // defaults to 'primary' if not provided + calendarId?: string } export interface GoogleCalendarCreateParams extends BaseGoogleCalendarParams { @@ -28,14 +27,18 @@ export interface GoogleCalendarCreateParams extends BaseGoogleCalendarParams { startDateTime: string endDateTime: string timeZone?: string - attendees?: string[] // Array of email addresses + attendees?: string[] sendUpdates?: 'all' | 'externalOnly' | 'none' + recurrence?: string | string[] + addGoogleMeet?: boolean } export interface GoogleCalendarListParams extends BaseGoogleCalendarParams { - timeMin?: string // RFC3339 timestamp - timeMax?: string // RFC3339 timestamp + timeMin?: string + timeMax?: string + q?: string maxResults?: number + pageToken?: string singleEvents?: boolean orderBy?: 'startTime' | 'updated' showDeleted?: boolean @@ -55,6 +58,8 @@ export interface GoogleCalendarUpdateParams extends BaseGoogleCalendarParams { timeZone?: string attendees?: string[] sendUpdates?: 'all' | 'externalOnly' | 'none' + recurrence?: string | string[] + addGoogleMeet?: boolean } export interface GoogleCalendarDeleteParams extends BaseGoogleCalendarParams { @@ -63,16 +68,16 @@ export interface GoogleCalendarDeleteParams extends BaseGoogleCalendarParams { } export interface GoogleCalendarQuickAddParams extends BaseGoogleCalendarParams { - text: string // Natural language text like "Meeting with John tomorrow at 3pm" - attendees?: string[] // Array of email addresses (comma-separated string also accepted) + text: string + attendees?: string[] sendUpdates?: 'all' | 'externalOnly' | 'none' } export interface GoogleCalendarInviteParams extends BaseGoogleCalendarParams { eventId: string - attendees: string[] // Array of email addresses to invite + attendees: string[] sendUpdates?: 'all' | 'externalOnly' | 'none' - replaceExisting?: boolean // Whether to replace existing attendees or add to them + replaceExisting?: boolean } interface GoogleCalendarMoveParams extends BaseGoogleCalendarParams { @@ -92,10 +97,10 @@ interface GoogleCalendarInstancesParams extends BaseGoogleCalendarParams { export interface GoogleCalendarFreeBusyParams { accessToken: string - calendarIds: string // Comma-separated calendar IDs (e.g., "primary,other@example.com") - timeMin: string // RFC3339 timestamp (e.g., 2025-06-03T00:00:00Z) - timeMax: string // RFC3339 timestamp (e.g., 2025-06-04T00:00:00Z) - timeZone?: string // IANA time zone (e.g., "UTC", "America/New_York") + calendarIds: string + timeMin: string + timeMax: string + timeZone?: string } interface GoogleCalendarListCalendarsParams { @@ -107,6 +112,40 @@ interface GoogleCalendarListCalendarsParams { showHidden?: boolean } +export interface GoogleCalendarCreateCalendarParams { + accessToken: string + summary: string + description?: string + location?: string + timeZone?: string +} + +type GoogleCalendarAclRole = 'freeBusyReader' | 'reader' | 'writer' | 'owner' +type GoogleCalendarAclScopeType = 'user' | 'group' | 'domain' | 'default' + +export interface GoogleCalendarShareCalendarParams { + accessToken: string + calendarId?: string + role: GoogleCalendarAclRole + scopeType: GoogleCalendarAclScopeType + scopeValue?: string + sendNotifications?: boolean +} + +export interface GoogleCalendarListAclParams { + accessToken: string + calendarId?: string + maxResults?: number + pageToken?: string + showDeleted?: boolean +} + +export interface GoogleCalendarUnshareCalendarParams { + accessToken: string + calendarId?: string + ruleId: string +} + export type GoogleCalendarToolParams = | GoogleCalendarCreateParams | GoogleCalendarListParams @@ -119,14 +158,20 @@ export type GoogleCalendarToolParams = | GoogleCalendarInstancesParams | GoogleCalendarFreeBusyParams | GoogleCalendarListCalendarsParams + | GoogleCalendarCreateCalendarParams + | GoogleCalendarShareCalendarParams + | GoogleCalendarListAclParams + | GoogleCalendarUnshareCalendarParams interface EventMetadata { id: string htmlLink: string + hangoutLink?: string status: string summary: string description?: string location?: string + recurrence?: string[] start: { dateTime?: string date?: string @@ -162,7 +207,6 @@ interface GoogleCalendarToolResponse extends ToolResponse { } } -// Specific response types for each operation export interface GoogleCalendarCreateResponse extends ToolResponse { output: { content: string @@ -242,32 +286,44 @@ interface GoogleCalendarEvent { } } +interface GoogleCalendarEventDateTime { + dateTime?: string + date?: string + timeZone?: string +} + +interface GoogleCalendarConferenceCreateRequest { + createRequest: { + requestId: string + conferenceSolutionKey: { type: string } + } +} + export interface GoogleCalendarEventRequestBody { summary: string description?: string location?: string - start: { - dateTime: string - timeZone?: string - } - end: { - dateTime: string - timeZone?: string - } + start: GoogleCalendarEventDateTime + end: GoogleCalendarEventDateTime attendees?: Array<{ email: string }> + recurrence?: string[] + conferenceData?: GoogleCalendarConferenceCreateRequest } export interface GoogleCalendarApiEventResponse { id: string status: string htmlLink: string + hangoutLink?: string created?: string updated?: string summary: string description?: string location?: string + recurrence?: string[] + recurringEventId?: string start: { dateTime?: string date?: string @@ -287,6 +343,7 @@ export interface GoogleCalendarApiEventResponse { email: string displayName?: string } + conferenceData?: Record reminders?: { useDefault: boolean overrides?: Array<{ @@ -296,6 +353,34 @@ export interface GoogleCalendarApiEventResponse { } } +export interface GoogleCalendarApiCalendarResponse { + kind: string + etag: string + id: string + summary: string + description?: string + location?: string + timeZone?: string +} + +export interface GoogleCalendarApiAclRule { + kind: string + etag: string + id: string + role: string + scope: { + type: string + value?: string + } +} + +export interface GoogleCalendarApiAclListResponse { + kind: string + etag: string + nextPageToken?: string + items: GoogleCalendarApiAclRule[] +} + export interface GoogleCalendarApiListResponse { kind: string etag: string @@ -402,6 +487,50 @@ interface GoogleCalendarListCalendarsResponse extends ToolResponse { } } +export interface GoogleCalendarCreateCalendarResponse extends ToolResponse { + output: { + content: string + metadata: { + id: string + summary: string + description?: string + location?: string + timeZone?: string + } + } +} + +export interface GoogleCalendarShareCalendarResponse extends ToolResponse { + output: { + content: string + metadata: { + id: string + role: string + scope: { type: string; value?: string } + } + } +} + +export interface GoogleCalendarListAclResponse extends ToolResponse { + output: { + content: string + metadata: { + nextPageToken?: string + rules: Array<{ id: string; role: string; scope: { type: string; value?: string } }> + } + } +} + +export interface GoogleCalendarUnshareCalendarResponse extends ToolResponse { + output: { + content: string + metadata: { + ruleId: string + deleted: boolean + } + } +} + export type GoogleCalendarResponse = | GoogleCalendarCreateResponse | GoogleCalendarListResponse @@ -414,3 +543,7 @@ export type GoogleCalendarResponse = | GoogleCalendarInstancesResponse | GoogleCalendarFreeBusyResponse | GoogleCalendarListCalendarsResponse + | GoogleCalendarCreateCalendarResponse + | GoogleCalendarShareCalendarResponse + | GoogleCalendarListAclResponse + | GoogleCalendarUnshareCalendarResponse diff --git a/apps/sim/tools/google_calendar/unshare_calendar.ts b/apps/sim/tools/google_calendar/unshare_calendar.ts new file mode 100644 index 00000000000..95a42dfb748 --- /dev/null +++ b/apps/sim/tools/google_calendar/unshare_calendar.ts @@ -0,0 +1,122 @@ +import { + CALENDAR_API_BASE, + type GoogleCalendarUnshareCalendarParams, + type GoogleCalendarUnshareCalendarResponse, +} from '@/tools/google_calendar/types' +import type { ToolConfig } from '@/tools/types' + +const buildUnshareUrl = (params: GoogleCalendarUnshareCalendarParams) => { + const calendarId = params.calendarId?.trim() || 'primary' + return `${CALENDAR_API_BASE}/calendars/${encodeURIComponent(calendarId)}/acl/${encodeURIComponent(params.ruleId.trim())}` +} + +export const unshareCalendarTool: ToolConfig< + GoogleCalendarUnshareCalendarParams, + GoogleCalendarUnshareCalendarResponse +> = { + id: 'google_calendar_unshare_calendar', + name: 'Google Calendar Remove Sharing', + description: 'Revoke an access control rule (sharing) from a calendar', + version: '1.0.0', + + oauth: { + required: true, + provider: 'google-calendar', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'Access token for Google Calendar API', + }, + calendarId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Calendar ID to modify (e.g., primary or calendar@group.calendar.google.com)', + }, + ruleId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'ACL rule ID to remove (e.g., user:person@example.com)', + }, + }, + + request: { + url: buildUnshareUrl, + method: 'DELETE', + headers: (params: GoogleCalendarUnshareCalendarParams) => ({ + Authorization: `Bearer ${params.accessToken}`, + 'Content-Type': 'application/json', + }), + }, + + transformResponse: async (response: Response, params) => { + if (response.status === 204 || response.ok) { + return { + success: true, + output: { + content: 'Sharing rule successfully removed', + metadata: { + ruleId: params?.ruleId || '', + deleted: true, + }, + }, + } + } + + const errorData = await response.json().catch(() => null) + throw new Error(errorData?.error?.message || 'Failed to remove sharing rule') + }, + + outputs: { + content: { type: 'string', description: 'Removal confirmation message' }, + metadata: { + type: 'json', + description: 'Removal details including rule ID', + }, + }, +} + +interface GoogleCalendarUnshareCalendarV2Response { + success: boolean + output: { + ruleId: string + deleted: boolean + } +} + +export const unshareCalendarV2Tool: ToolConfig< + GoogleCalendarUnshareCalendarParams, + GoogleCalendarUnshareCalendarV2Response +> = { + id: 'google_calendar_unshare_calendar_v2', + name: 'Google Calendar Remove Sharing', + description: + 'Revoke an access control rule (sharing) from a calendar. Returns API-aligned fields only.', + version: '2.0.0', + oauth: unshareCalendarTool.oauth, + params: unshareCalendarTool.params, + request: unshareCalendarTool.request, + transformResponse: async (response: Response, params) => { + if (response.status === 204 || response.ok) { + return { + success: true, + output: { + ruleId: params?.ruleId || '', + deleted: true, + }, + } + } + + const errorData = await response.json().catch(() => null) + throw new Error(errorData?.error?.message || 'Failed to remove sharing rule') + }, + outputs: { + ruleId: { type: 'string', description: 'Removed ACL rule ID' }, + deleted: { type: 'boolean', description: 'Whether removal was successful' }, + }, +} diff --git a/apps/sim/tools/google_calendar/update.ts b/apps/sim/tools/google_calendar/update.ts index c59048db3e6..fa795afe02f 100644 --- a/apps/sim/tools/google_calendar/update.ts +++ b/apps/sim/tools/google_calendar/update.ts @@ -1,11 +1,22 @@ import { CALENDAR_API_BASE, + type CalendarAttendee, type GoogleCalendarApiEventResponse, + type GoogleCalendarEventRequestBody, type GoogleCalendarUpdateParams, type GoogleCalendarUpdateResponse, } from '@/tools/google_calendar/types' +import { + assertRecurringTimeZone, + buildEventDateTime, + buildGoogleMeetConferenceData, + normalizeAttendees, + normalizeRecurrence, +} from '@/tools/google_calendar/utils' import type { ToolConfig } from '@/tools/types' +type EventPatchBody = Partial + export const updateTool: ToolConfig = { id: 'google_calendar_update', name: 'Google Calendar Update Event', @@ -59,27 +70,41 @@ export const updateTool: ToolConfig { - const updateData: Record = {} + body: (params: GoogleCalendarUpdateParams): EventPatchBody => { + const updateData: EventPatchBody = {} + const recurrence = normalizeRecurrence(params.recurrence) + const isRecurring = recurrence.length > 0 + + if (isRecurring) { + assertRecurringTimeZone([params.startDateTime, params.endDateTime], params.timeZone) + } if (params.summary !== undefined) { updateData.summary = params.summary @@ -122,38 +156,24 @@ export const updateTool: ToolConfig 0) { + updateData.attendees = attendees + } + + if (isRecurring) { + updateData.recurrence = recurrence } - // Handle attendees - convert to array format - if (params.attendees !== undefined) { - let attendeeList: string[] = [] - const attendees = params.attendees as string | string[] - - if (Array.isArray(attendees)) { - attendeeList = attendees.filter((email: string) => email && email.trim().length > 0) - } else if (typeof attendees === 'string' && attendees.trim().length > 0) { - attendeeList = attendees - .split(',') - .map((email: string) => email.trim()) - .filter((email: string) => email.length > 0) - } - - updateData.attendees = attendeeList.map((email: string) => ({ email })) + if (params.addGoogleMeet) { + updateData.conferenceData = buildGoogleMeetConferenceData() } return updateData @@ -170,10 +190,12 @@ export const updateTool: ToolConfig { + if (!attendees) return [] + + const list = Array.isArray(attendees) + ? attendees + : attendees.split(',').map((email) => email.trim()) + + return list.filter((email) => email.length > 0).map((email) => ({ email })) +} + +/** + * Recurring events require a named `timeZone` on their timed start/end — the Calendar API + * rejects them otherwise, and an RFC3339 offset is not a substitute (an IANA zone cannot be + * derived from a fixed offset). Throws a clear error so we fail fast with guidance instead of + * silently guessing a zone (which would misalign the recurrence) or sending an invalid request. + * All-day recurring events (date-only values) do not need a timezone and are allowed. + */ +export function assertRecurringTimeZone( + dateTimes: Array, + timeZone: string | undefined +): void { + if (timeZone) return + const hasTimedValue = dateTimes.some((value) => value?.includes('T')) + if (hasTimedValue) { + throw new Error( + 'Recurring events require a time zone. Provide the timeZone parameter (an IANA name, e.g. America/New_York).' + ) + } +} + +/** Normalize recurrence rules (single string, newline-separated string, or array) into an array. */ +export function normalizeRecurrence(recurrence: string | string[] | undefined): string[] { + if (!recurrence) return [] + + const list = Array.isArray(recurrence) ? recurrence : recurrence.split('\n') + + return list.map((rule) => rule.trim()).filter((rule) => rule.length > 0) +} + +/** Build a `conferenceData.createRequest` payload that asks Google to attach a Meet link. */ +export function buildGoogleMeetConferenceData(): GoogleCalendarEventRequestBody['conferenceData'] { + return { + createRequest: { + requestId: generateId(), + conferenceSolutionKey: { type: 'hangoutsMeet' }, + }, + } +} diff --git a/apps/sim/tools/registry.ts b/apps/sim/tools/registry.ts index 1f069c3be05..6aead74285e 100644 --- a/apps/sim/tools/registry.ts +++ b/apps/sim/tools/registry.ts @@ -1169,6 +1169,8 @@ import { } from '@/tools/google_bigquery' import { googleBooksVolumeDetailsTool, googleBooksVolumeSearchTool } from '@/tools/google_books' import { + googleCalendarCreateCalendarTool, + googleCalendarCreateCalendarV2Tool, googleCalendarCreateTool, googleCalendarCreateV2Tool, googleCalendarDeleteTool, @@ -1181,6 +1183,8 @@ import { googleCalendarInstancesV2Tool, googleCalendarInviteTool, googleCalendarInviteV2Tool, + googleCalendarListAclTool, + googleCalendarListAclV2Tool, googleCalendarListCalendarsTool, googleCalendarListCalendarsV2Tool, googleCalendarListTool, @@ -1189,6 +1193,10 @@ import { googleCalendarMoveV2Tool, googleCalendarQuickAddTool, googleCalendarQuickAddV2Tool, + googleCalendarShareCalendarTool, + googleCalendarShareCalendarV2Tool, + googleCalendarUnshareCalendarTool, + googleCalendarUnshareCalendarV2Tool, googleCalendarUpdateTool, googleCalendarUpdateV2Tool, } from '@/tools/google_calendar' @@ -6332,8 +6340,12 @@ export const tools: Record = { microsoft_planner_update_task_details: microsoftPlannerUpdateTaskDetailsTool, google_calendar_create: googleCalendarCreateTool, google_calendar_create_v2: googleCalendarCreateV2Tool, + google_calendar_create_calendar: googleCalendarCreateCalendarTool, + google_calendar_create_calendar_v2: googleCalendarCreateCalendarV2Tool, google_calendar_delete: googleCalendarDeleteTool, google_calendar_delete_v2: googleCalendarDeleteV2Tool, + google_calendar_freebusy: googleCalendarFreeBusyTool, + google_calendar_freebusy_v2: googleCalendarFreeBusyV2Tool, google_calendar_get: googleCalendarGetTool, google_calendar_get_v2: googleCalendarGetV2Tool, google_calendar_instances: googleCalendarInstancesTool, @@ -6342,12 +6354,18 @@ export const tools: Record = { google_calendar_invite_v2: googleCalendarInviteV2Tool, google_calendar_list: googleCalendarListTool, google_calendar_list_v2: googleCalendarListV2Tool, + google_calendar_list_acl: googleCalendarListAclTool, + google_calendar_list_acl_v2: googleCalendarListAclV2Tool, google_calendar_list_calendars: googleCalendarListCalendarsTool, google_calendar_list_calendars_v2: googleCalendarListCalendarsV2Tool, google_calendar_move: googleCalendarMoveTool, google_calendar_move_v2: googleCalendarMoveV2Tool, google_calendar_quick_add: googleCalendarQuickAddTool, google_calendar_quick_add_v2: googleCalendarQuickAddV2Tool, + google_calendar_share_calendar: googleCalendarShareCalendarTool, + google_calendar_share_calendar_v2: googleCalendarShareCalendarV2Tool, + google_calendar_unshare_calendar: googleCalendarUnshareCalendarTool, + google_calendar_unshare_calendar_v2: googleCalendarUnshareCalendarV2Tool, google_calendar_update: googleCalendarUpdateTool, google_calendar_update_v2: googleCalendarUpdateV2Tool, google_contacts_create: googleContactsCreateTool, @@ -6356,8 +6374,6 @@ export const tools: Record = { google_contacts_list: googleContactsListTool, google_contacts_search: googleContactsSearchTool, google_contacts_update: googleContactsUpdateTool, - google_calendar_freebusy: googleCalendarFreeBusyTool, - google_calendar_freebusy_v2: googleCalendarFreeBusyV2Tool, google_forms_get_responses: googleFormsGetResponsesTool, google_forms_get_form: googleFormsGetFormTool, google_forms_create_form: googleFormsCreateFormTool,