diff --git a/apps/bubble-studio/src/pages/CredentialsPage.tsx b/apps/bubble-studio/src/pages/CredentialsPage.tsx index 9db51192..a0955e14 100644 --- a/apps/bubble-studio/src/pages/CredentialsPage.tsx +++ b/apps/bubble-studio/src/pages/CredentialsPage.tsx @@ -120,6 +120,7 @@ const getServiceNameForCredentialType = ( [CredentialType.CLERK_API_KEY]: 'Clerk', [CredentialType.GRANOLA_API_KEY]: 'Granola', [CredentialType.MEMBERFUL_CRED]: 'Memberful', + [CredentialType.ZOOM_CRED]: 'Zoom', }; return typeToServiceMap[credentialType] || credentialType; diff --git a/packages/bubble-core/package.json b/packages/bubble-core/package.json index 6fa10d4e..e49e0b32 100644 --- a/packages/bubble-core/package.json +++ b/packages/bubble-core/package.json @@ -1,6 +1,6 @@ { "name": "@bubblelab/bubble-core", - "version": "0.1.318", + "version": "0.1.319", "type": "module", "license": "Apache-2.0", "main": "./dist/index.js", diff --git a/packages/bubble-core/src/bubble-factory.ts b/packages/bubble-core/src/bubble-factory.ts index 444d19ad..c5cfc201 100644 --- a/packages/bubble-core/src/bubble-factory.ts +++ b/packages/bubble-core/src/bubble-factory.ts @@ -194,6 +194,7 @@ export class BubbleFactory { 'granola', 'memberful', 'luma', + 'zoom', ]; } @@ -472,6 +473,9 @@ export class BubbleFactory { './bubbles/service-bubble/memberful/index.js' ); const { LumaBubble } = await import('./bubbles/service-bubble/luma.js'); + const { ZoomBubble } = await import( + './bubbles/service-bubble/zoom/index.js' + ); // Create the default factory instance this.register('hello-world', HelloWorldBubble as BubbleClassWithMetadata); @@ -651,6 +655,7 @@ export class BubbleFactory { this.register('granola', GranolaBubble as BubbleClassWithMetadata); this.register('memberful', MemberfulBubble as BubbleClassWithMetadata); this.register('luma', LumaBubble as BubbleClassWithMetadata); + this.register('zoom', ZoomBubble as BubbleClassWithMetadata); // After all default bubbles are registered, auto-populate bubbleDependencies if (!BubbleFactory.dependenciesPopulated) { diff --git a/packages/bubble-core/src/bubbles/service-bubble/zoom/index.ts b/packages/bubble-core/src/bubbles/service-bubble/zoom/index.ts new file mode 100644 index 00000000..f8dccee3 --- /dev/null +++ b/packages/bubble-core/src/bubbles/service-bubble/zoom/index.ts @@ -0,0 +1,8 @@ +export { ZoomBubble } from './zoom.js'; +export { + ZoomParamsSchema, + ZoomResultSchema, + type ZoomParams, + type ZoomResult, + type ZoomParamsInput, +} from './zoom.schema.js'; diff --git a/packages/bubble-core/src/bubbles/service-bubble/zoom/zoom.integration.flow.ts b/packages/bubble-core/src/bubbles/service-bubble/zoom/zoom.integration.flow.ts new file mode 100644 index 00000000..facd2362 --- /dev/null +++ b/packages/bubble-core/src/bubbles/service-bubble/zoom/zoom.integration.flow.ts @@ -0,0 +1,232 @@ +import { BubbleFlow, type WebhookEvent } from '../../../index.js'; +import { ZoomBubble } from './zoom.js'; + +export interface Output { + testResults: { + operation: string; + success: boolean; + details?: string; + }[]; +} + +export interface TestPayload extends WebhookEvent { + testName?: string; +} + +/** + * Integration flow test for the Zoom bubble. + * Exercises every operation against a real Zoom account using the user's + * connected ZOOM_CRED OAuth token. Each operation tries to chain off the + * prior operation's results so the flow only needs the user to have at + * least one past meeting + recording. + */ +export class ZoomIntegrationTest extends BubbleFlow<'webhook/http'> { + async handle(_payload: TestPayload): Promise { + const results: Output['testResults'] = []; + + // 1. get_user — confirm the OAuth token works + const userResult = await new ZoomBubble({ + operation: 'get_user', + user_id: 'me', + }).action(); + + results.push({ + operation: 'get_user', + success: userResult.success, + details: userResult.success + ? `Authenticated as ${(userResult.user as { email?: string })?.email ?? 'unknown'}` + : userResult.error, + }); + + // 2. create_meeting — schedule something an hour in the future + const startTime = new Date(Date.now() + 60 * 60 * 1000) + .toISOString() + .replace(/\.\d{3}Z$/, 'Z'); + + const createResult = await new ZoomBubble({ + operation: 'create_meeting', + user_id: 'me', + topic: 'BubbleLab Integration Test — Spaces & Üñïçødé', + type: 2, + start_time: startTime, + duration: 15, + timezone: 'America/Los_Angeles', + agenda: 'Created by ZoomIntegrationTest', + }).action(); + + const createdMeetingId = createResult.success + ? String((createResult.meeting as { id?: number | string })?.id ?? '') + : ''; + + results.push({ + operation: 'create_meeting', + success: createResult.success, + details: createResult.success + ? `Created meeting ${createdMeetingId}` + : createResult.error, + }); + + // 3. get_meeting — fetch the meeting we just created + if (createdMeetingId) { + const getResult = await new ZoomBubble({ + operation: 'get_meeting', + meeting_id: createdMeetingId, + }).action(); + + results.push({ + operation: 'get_meeting', + success: getResult.success, + details: getResult.success + ? `Topic: ${(getResult.meeting as { topic?: string })?.topic}` + : getResult.error, + }); + } else { + results.push({ + operation: 'get_meeting', + success: false, + details: 'Skipped — create_meeting did not return an ID', + }); + } + + // 4. list_meetings — should include the new one + const listResult = await new ZoomBubble({ + operation: 'list_meetings', + user_id: 'me', + type: 'scheduled', + page_size: 30, + }).action(); + + results.push({ + operation: 'list_meetings', + success: listResult.success, + details: listResult.success + ? `Found ${listResult.meetings?.length ?? 0} meetings (total ${listResult.total_records ?? 0})` + : listResult.error, + }); + + // 5. list_user_recordings — last 30 days + const today = new Date().toISOString().slice(0, 10); + const monthAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000) + .toISOString() + .slice(0, 10); + + const recordingsResult = await new ZoomBubble({ + operation: 'list_user_recordings', + user_id: 'me', + from: monthAgo, + to: today, + page_size: 30, + }).action(); + + const firstRecordingMeetingUuid = + recordingsResult.success && recordingsResult.meetings?.length + ? String( + (recordingsResult.meetings[0] as { uuid?: string }).uuid ?? + (recordingsResult.meetings[0] as { id?: number | string }).id ?? + '' + ) + : ''; + const firstRecordingMeetingId = + recordingsResult.success && recordingsResult.meetings?.length + ? String( + (recordingsResult.meetings[0] as { id?: number | string }).id ?? '' + ) + : ''; + + results.push({ + operation: 'list_user_recordings', + success: recordingsResult.success, + details: recordingsResult.success + ? `Found ${recordingsResult.meetings?.length ?? 0} recordings` + : recordingsResult.error, + }); + + // 6. list_past_instances — only meaningful for recurring meetings + if (firstRecordingMeetingId) { + const pastInstances = await new ZoomBubble({ + operation: 'list_past_instances', + meeting_id: firstRecordingMeetingId, + }).action(); + + results.push({ + operation: 'list_past_instances', + success: pastInstances.success, + details: pastInstances.success + ? `Found ${pastInstances.meetings?.length ?? 0} past instances` + : pastInstances.error, + }); + } else { + results.push({ + operation: 'list_past_instances', + success: false, + details: 'Skipped — no recordings to derive a meeting ID from', + }); + } + + // 7. get_past_meeting — chain off the first recording + if (firstRecordingMeetingUuid) { + const pastMeeting = await new ZoomBubble({ + operation: 'get_past_meeting', + meeting_id: encodeURIComponent(firstRecordingMeetingUuid), + }).action(); + + results.push({ + operation: 'get_past_meeting', + success: pastMeeting.success, + details: pastMeeting.success + ? `Past meeting topic: ${(pastMeeting.meeting as { topic?: string })?.topic}` + : pastMeeting.error, + }); + } else { + results.push({ + operation: 'get_past_meeting', + success: false, + details: 'Skipped — no recordings to derive a UUID from', + }); + } + + // 8. get_recording — fetch the recording bundle for the first one + if (firstRecordingMeetingUuid) { + const getRecording = await new ZoomBubble({ + operation: 'get_recording', + meeting_id: encodeURIComponent(firstRecordingMeetingUuid), + }).action(); + + results.push({ + operation: 'get_recording', + success: getRecording.success, + details: getRecording.success + ? `Got ${getRecording.recording_files?.length ?? 0} recording files` + : getRecording.error, + }); + + // 9. get_meeting_transcript — try to extract transcript VTT + const transcript = await new ZoomBubble({ + operation: 'get_meeting_transcript', + meeting_id: encodeURIComponent(firstRecordingMeetingUuid), + download: true, + }).action(); + + results.push({ + operation: 'get_meeting_transcript', + success: transcript.success, + details: transcript.success + ? `Transcript ${transcript.transcript_vtt ? `(${transcript.transcript_vtt.length} chars)` : '(metadata only)'}` + : transcript.error, + }); + } else { + results.push({ + operation: 'get_recording', + success: false, + details: 'Skipped — no recordings to fetch', + }); + results.push({ + operation: 'get_meeting_transcript', + success: false, + details: 'Skipped — no recordings to fetch transcript from', + }); + } + + return { testResults: results }; + } +} diff --git a/packages/bubble-core/src/bubbles/service-bubble/zoom/zoom.schema.ts b/packages/bubble-core/src/bubbles/service-bubble/zoom/zoom.schema.ts new file mode 100644 index 00000000..6089099d --- /dev/null +++ b/packages/bubble-core/src/bubbles/service-bubble/zoom/zoom.schema.ts @@ -0,0 +1,391 @@ +import { z } from 'zod'; +import { CredentialType } from '@bubblelab/shared-schemas'; + +const credentialsField = z + .record(z.nativeEnum(CredentialType), z.string()) + .optional() + .describe('Object mapping credential types to values (injected at runtime)'); + +const userIdField = z + .string() + .min(1, 'User ID is required') + .optional() + .default('me') + .describe( + 'Zoom user ID or email. Use "me" for the authenticated user (default).' + ); + +const meetingIdField = z + .string() + .min(1, 'Meeting ID is required') + .describe('Numeric Zoom meeting ID (e.g. "123456789")'); + +const meetingUuidOrIdField = z + .string() + .min(1, 'Meeting UUID or ID is required') + .describe( + 'Zoom meeting UUID (preferred for past meetings) or numeric meeting ID. ' + + 'IMPORTANT: if the UUID contains "/" or "+", you must pre-encode it ' + + 'with encodeURIComponent yourself before passing — Zoom requires ' + + 'double-encoding for these characters and the bubble only single-encodes.' + ); + +export const ZoomParamsSchema = z.discriminatedUnion('operation', [ + z.object({ + operation: z + .literal('create_meeting') + .describe('Create a scheduled or instant meeting for a user'), + user_id: userIdField, + topic: z + .string() + .min(1, 'Topic is required') + .describe('Meeting topic / title'), + type: z + .union([z.literal(1), z.literal(2), z.literal(3), z.literal(8)]) + .optional() + .default(2) + .describe( + 'Meeting type: 1=instant, 2=scheduled (default), 3=recurring no fixed time, 8=recurring with fixed time' + ), + start_time: z + .string() + .optional() + .describe( + 'ISO 8601 start time in UTC (e.g. "2026-05-01T15:00:00Z"). Required for type=2 or 8.' + ), + duration: z + .number() + .int() + .min(1) + .optional() + .default(30) + .describe('Scheduled meeting duration in minutes (default 30)'), + timezone: z + .string() + .optional() + .describe('IANA timezone for start_time (e.g. "America/Los_Angeles")'), + agenda: z.string().optional().describe('Meeting agenda / description'), + password: z + .string() + .optional() + .describe('Meeting passcode (max 10 chars, alphanumeric)'), + settings: z + .record(z.string(), z.unknown()) + .optional() + .describe( + 'Zoom meeting settings object (host_video, participant_video, mute_upon_entry, auto_recording, etc.). ' + + 'NOTE: recurrence does NOT go here — use the top-level `recurrence` field instead.' + ), + recurrence: z + .object({ + type: z + .union([z.literal(1), z.literal(2), z.literal(3)]) + .describe('Recurrence type: 1=daily, 2=weekly, 3=monthly'), + repeat_interval: z + .number() + .int() + .min(1) + .optional() + .describe( + 'Interval between recurrences (e.g. 2 = every 2 weeks). Defaults to 1.' + ), + weekly_days: z + .string() + .optional() + .describe( + 'Comma-separated days of week (1=Sun..7=Sat). Required when type=2.' + ), + monthly_day: z + .number() + .int() + .min(1) + .max(31) + .optional() + .describe( + 'Day of month (1-31). Use with type=3 for "monthly on day N" recurrence.' + ), + monthly_week: z + .union([ + z.literal(-1), + z.literal(1), + z.literal(2), + z.literal(3), + z.literal(4), + ]) + .optional() + .describe( + 'Week of month: -1=last, 1=first, 2=second, 3=third, 4=fourth. Use with monthly_week_day for type=3.' + ), + monthly_week_day: z + .number() + .int() + .min(1) + .max(7) + .optional() + .describe( + 'Day of week for monthly_week (1=Sun..7=Sat). Use with monthly_week.' + ), + end_times: z + .number() + .int() + .min(1) + .max(60) + .optional() + .describe( + '[ONEOF:end] Number of occurrences before stopping (max 60).' + ), + end_date_time: z + .string() + .optional() + .describe( + '[ONEOF:end] ISO 8601 datetime to stop recurring (e.g. "2026-12-31T00:00:00Z").' + ), + }) + .optional() + .describe( + 'Recurrence settings — required when type=8 (recurring with fixed time). ' + + 'Goes at the top of the request body, not inside `settings`. ' + + 'Shape: { type: 1=daily | 2=weekly | 3=monthly, repeat_interval?: number (default 1), ' + + 'weekly_days?: comma-separated "1=Sun..7=Sat" (required for type=2), ' + + 'monthly_day?: 1-31 (for type=3 nth-day), ' + + 'monthly_week?: -1=last|1|2|3|4 + monthly_week_day?: 1=Sun..7=Sat (for type=3 nth-weekday), ' + + 'end_times?: 1-60 occurrences OR end_date_time?: ISO 8601 — provide one of the two }.' + ), + credentials: credentialsField, + }), + + z.object({ + operation: z + .literal('get_meeting') + .describe('Retrieve details of a single scheduled meeting by ID'), + meeting_id: meetingIdField, + occurrence_id: z + .string() + .optional() + .describe('Specific occurrence ID for recurring meetings'), + credentials: credentialsField, + }), + + z.object({ + operation: z + .literal('list_meetings') + .describe( + "List a user's meetings. Returns flat: { meetings: [...], total_records, next_page_token }" + ), + user_id: userIdField, + type: z + .enum([ + 'scheduled', + 'live', + 'upcoming', + 'upcoming_meetings', + 'previous_meetings', + ]) + .optional() + .default('scheduled') + .describe( + 'Filter: scheduled (default — all upcoming + recurring meetings the user is host of), ' + + 'live (currently in progress), upcoming (next instance of every meeting, including recurring), ' + + 'upcoming_meetings (newer alias for upcoming), previous_meetings (already-ended meetings). ' + + 'Most flows want "scheduled" or "previous_meetings".' + ), + page_size: z + .number() + .int() + .min(1) + .max(300) + .optional() + .default(30) + .describe('Results per page (1-300, default 30)'), + next_page_token: z + .string() + .optional() + .describe('Pagination token from a previous response'), + credentials: credentialsField, + }), + + z.object({ + operation: z + .literal('get_past_meeting') + .describe('Retrieve details of a past meeting by UUID or ID'), + meeting_id: meetingUuidOrIdField, + credentials: credentialsField, + }), + + z.object({ + operation: z + .literal('list_past_instances') + .describe( + 'List ended instances of a recurring meeting. Use the returned UUIDs ' + + 'to fetch recordings or summaries. Returns an empty array for ' + + 'non-recurring meetings (type 1 or 2) — that is not an error.' + ), + meeting_id: meetingIdField, + credentials: credentialsField, + }), + + z.object({ + operation: z + .literal('list_user_recordings') + .describe( + "List a user's cloud recordings within an optional date range. " + + 'IMPORTANT: Zoom silently clamps the (from, to) window to a maximum ' + + 'of ~30 days — wider ranges are accepted but Zoom will only return ' + + 'recordings from the last 30 days of the requested window. To cover ' + + 'a longer period, paginate by issuing multiple back-to-back 30-day calls.' + ), + user_id: userIdField, + from: z + .string() + .optional() + .describe( + 'Start date (YYYY-MM-DD). Defaults to one month ago. Window is capped at ~30 days by Zoom.' + ), + to: z + .string() + .optional() + .describe('End date (YYYY-MM-DD). Defaults to today.'), + page_size: z + .number() + .int() + .min(1) + .max(300) + .optional() + .default(30) + .describe('Results per page (1-300, default 30)'), + next_page_token: z + .string() + .optional() + .describe('Pagination token from a previous response'), + credentials: credentialsField, + }), + + z.object({ + operation: z + .literal('get_recording') + .describe( + "Get a meeting's cloud recording bundle (all files, including audio, video, chat, transcript)" + ), + meeting_id: meetingUuidOrIdField, + include_fields: z + .string() + .optional() + .describe( + 'Comma-separated extra fields (e.g. "download_access_token") to include in the response' + ), + credentials: credentialsField, + }), + + z.object({ + operation: z + .literal('get_meeting_transcript') + .describe( + "Fetch a meeting's transcript. Locates the TRANSCRIPT file in the recording and optionally downloads its VTT content." + ), + meeting_id: meetingUuidOrIdField, + download: z + .boolean() + .optional() + .default(true) + .describe( + 'When true (default), download the VTT transcript content using the access token' + ), + credentials: credentialsField, + }), + + z.object({ + operation: z + .literal('get_user') + .describe('Get a Zoom user profile by ID, email, or "me"'), + user_id: userIdField, + credentials: credentialsField, + }), +]); + +const ZoomMeetingSchema = z + .record(z.string(), z.unknown()) + .describe('A Zoom meeting record (fields vary by API endpoint)'); + +const ZoomRecordingFileSchema = z + .record(z.string(), z.unknown()) + .describe( + 'A single recording file (audio, video, chat, transcript). Includes id, file_type, download_url, recording_start, recording_end, status.' + ); + +const ZoomUserSchema = z + .record(z.string(), z.unknown()) + .describe('A Zoom user profile record'); + +export const ZoomResultSchema = z.discriminatedUnion('operation', [ + z.object({ + operation: z.literal('create_meeting'), + success: z.boolean(), + meeting: ZoomMeetingSchema.optional(), + error: z.string(), + }), + z.object({ + operation: z.literal('get_meeting'), + success: z.boolean(), + meeting: ZoomMeetingSchema.optional(), + error: z.string(), + }), + z.object({ + operation: z.literal('list_meetings'), + success: z.boolean(), + meetings: z.array(ZoomMeetingSchema).optional(), + page_size: z.number().optional(), + total_records: z.number().optional(), + next_page_token: z.string().optional(), + error: z.string(), + }), + z.object({ + operation: z.literal('get_past_meeting'), + success: z.boolean(), + meeting: ZoomMeetingSchema.optional(), + error: z.string(), + }), + z.object({ + operation: z.literal('list_past_instances'), + success: z.boolean(), + meetings: z.array(ZoomMeetingSchema).optional(), + error: z.string(), + }), + z.object({ + operation: z.literal('list_user_recordings'), + success: z.boolean(), + meetings: z.array(z.record(z.string(), z.unknown())).optional(), + page_size: z.number().optional(), + total_records: z.number().optional(), + next_page_token: z.string().optional(), + from: z.string().optional(), + to: z.string().optional(), + error: z.string(), + }), + z.object({ + operation: z.literal('get_recording'), + success: z.boolean(), + recording: z.record(z.string(), z.unknown()).optional(), + recording_files: z.array(ZoomRecordingFileSchema).optional(), + error: z.string(), + }), + z.object({ + operation: z.literal('get_meeting_transcript'), + success: z.boolean(), + transcript_file: ZoomRecordingFileSchema.optional(), + transcript_vtt: z + .string() + .optional() + .describe('Raw WebVTT transcript content (when download=true)'), + error: z.string(), + }), + z.object({ + operation: z.literal('get_user'), + success: z.boolean(), + user: ZoomUserSchema.optional(), + error: z.string(), + }), +]); + +export type ZoomParams = z.output; +export type ZoomParamsInput = z.input; +export type ZoomResult = z.output; diff --git a/packages/bubble-core/src/bubbles/service-bubble/zoom/zoom.ts b/packages/bubble-core/src/bubbles/service-bubble/zoom/zoom.ts new file mode 100644 index 00000000..e3fc694f --- /dev/null +++ b/packages/bubble-core/src/bubbles/service-bubble/zoom/zoom.ts @@ -0,0 +1,447 @@ +import { ServiceBubble } from '../../../types/service-bubble-class.js'; +import type { BubbleContext } from '../../../types/bubble.js'; +import { + CredentialType, + decodeCredentialPayload, +} from '@bubblelab/shared-schemas'; +import { + ZoomParamsSchema, + ZoomResultSchema, + type ZoomParams, + type ZoomParamsInput, + type ZoomResult, +} from './zoom.schema.js'; + +const ZOOM_API_BASE = 'https://api.zoom.us/v2'; + +/** + * Zoom Service Bubble + * + * OAuth-based Zoom integration for managing meetings, retrieving cloud + * recordings (including AI-Companion summaries and VTT transcripts), and + * reading user profiles via the Zoom REST API v2. + */ +export class ZoomBubble< + T extends ZoomParamsInput = ZoomParamsInput, +> extends ServiceBubble> { + static readonly type = 'service' as const; + static readonly service = 'zoom'; + static readonly authType = 'oauth' as const; + static readonly bubbleName = 'zoom'; + static readonly schema = ZoomParamsSchema; + static readonly resultSchema = ZoomResultSchema; + static readonly shortDescription = + 'Zoom integration for meetings, cloud recordings, transcripts, and users'; + static readonly longDescription = ` + Zoom REST API v2 integration covering the most common meeting and + recording workflows. + + Features: + - Create, list, and read scheduled and past meetings + - List past instances of recurring meetings (needed to fetch their recordings) + - List a user's cloud recordings within a date range + - Fetch a meeting's recording bundle (audio, video, chat, transcript files) + - Fetch a meeting's transcript and optionally download the VTT content + - Read a user profile (including the authenticated user via "me") + + Security Features: + - OAuth 2.0 with automatic refresh-token rotation + - User-scoped access (each connection only sees the connected user's data) + `; + static readonly alias = ''; + + constructor( + params: T = { + operation: 'get_user', + user_id: 'me', + } as T, + context?: BubbleContext + ) { + super(params, context); + } + + public async testCredential(): Promise { + const accessToken = this.parseAccessToken(); + if (!accessToken) { + throw new Error('Zoom credentials are required'); + } + + const response = await fetch(`${ZOOM_API_BASE}/users/me`, { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/json', + }, + }); + if (!response.ok) { + const text = await response.text(); + throw new Error(`Zoom API error (${response.status}): ${text}`); + } + return true; + } + + private parseAccessToken(): string | null { + const { credentials } = this.params as { + credentials?: Record; + }; + + if (!credentials || typeof credentials !== 'object') { + return null; + } + + const raw = credentials[CredentialType.ZOOM_CRED]; + if (!raw) { + return null; + } + + try { + const parsed = decodeCredentialPayload<{ accessToken?: string }>(raw); + if (parsed.accessToken) { + return parsed.accessToken; + } + } catch { + // Fall through — treat raw as access token + } + + return raw; + } + + protected chooseCredential(): string | undefined { + return this.parseAccessToken() ?? undefined; + } + + private async zoomRequest( + path: string, + method: 'GET' | 'POST' | 'PATCH' | 'DELETE' = 'GET', + body?: unknown, + query?: Record + ): Promise { + const accessToken = this.parseAccessToken(); + if (!accessToken) { + throw new Error('Zoom credentials are required'); + } + + let url = `${ZOOM_API_BASE}${path}`; + if (query) { + const params = new URLSearchParams(); + for (const [k, v] of Object.entries(query)) { + if (v !== undefined && v !== null && v !== '') { + params.set(k, String(v)); + } + } + const qs = params.toString(); + if (qs) url += `?${qs}`; + } + + const init: RequestInit = { + method, + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + }; + if (body !== undefined && method !== 'GET') { + init.body = JSON.stringify(body); + } + + const response = await fetch(url, init); + if (!response.ok) { + const text = await response.text(); + throw new Error(`Zoom API error (${response.status}): ${text}`); + } + if (response.status === 204) { + return undefined as R; + } + return (await response.json()) as R; + } + + protected async performAction( + context?: BubbleContext + ): Promise> { + void context; + + const { operation } = this.params; + + try { + const result = await (async (): Promise => { + const p = this.params as ZoomParams; + switch (p.operation) { + case 'create_meeting': + return await this.createMeeting(p); + case 'get_meeting': + return await this.getMeeting(p); + case 'list_meetings': + return await this.listMeetings(p); + case 'get_past_meeting': + return await this.getPastMeeting(p); + case 'list_past_instances': + return await this.listPastInstances(p); + case 'list_user_recordings': + return await this.listUserRecordings(p); + case 'get_recording': + return await this.getRecording(p); + case 'get_meeting_transcript': + return await this.getMeetingTranscript(p); + case 'get_user': + return await this.getUser(p); + default: + throw new Error( + `Unsupported operation: ${(p as { operation: string }).operation}` + ); + } + })(); + + return result as Extract; + } catch (error) { + return { + operation, + success: false, + error: + error instanceof Error ? error.message : 'Unknown error occurred', + } as Extract; + } + } + + // ─── Operation Implementations ────────────────────────────────────── + + private async createMeeting( + params: Extract + ): Promise> { + const { + user_id, + topic, + type, + start_time, + duration, + timezone, + agenda, + password, + settings, + recurrence, + } = params; + + const body: Record = { topic, type, duration }; + if (start_time) body.start_time = start_time; + if (timezone) body.timezone = timezone; + if (agenda) body.agenda = agenda; + if (password) body.password = password; + if (settings) body.settings = settings; + if (recurrence) body.recurrence = recurrence; + + const meeting = await this.zoomRequest>( + `/users/${encodeURIComponent(user_id)}/meetings`, + 'POST', + body + ); + + return { + operation: 'create_meeting', + success: true, + meeting, + error: '', + }; + } + + private async getMeeting( + params: Extract + ): Promise> { + const { meeting_id, occurrence_id } = params; + const meeting = await this.zoomRequest>( + `/meetings/${encodeURIComponent(meeting_id)}`, + 'GET', + undefined, + occurrence_id ? { occurrence_id } : undefined + ); + return { + operation: 'get_meeting', + success: true, + meeting, + error: '', + }; + } + + private async listMeetings( + params: Extract + ): Promise> { + const { user_id, type, page_size, next_page_token } = params; + const data = await this.zoomRequest<{ + meetings?: Record[]; + page_size?: number; + total_records?: number; + next_page_token?: string; + }>(`/users/${encodeURIComponent(user_id)}/meetings`, 'GET', undefined, { + type, + page_size, + next_page_token, + }); + + return { + operation: 'list_meetings', + success: true, + meetings: data.meetings ?? [], + page_size: data.page_size, + total_records: data.total_records, + next_page_token: data.next_page_token, + error: '', + }; + } + + private async getPastMeeting( + params: Extract + ): Promise> { + const { meeting_id } = params; + const meeting = await this.zoomRequest>( + `/past_meetings/${encodeURIComponent(meeting_id)}` + ); + return { + operation: 'get_past_meeting', + success: true, + meeting, + error: '', + }; + } + + private async listPastInstances( + params: Extract + ): Promise> { + const { meeting_id } = params; + const data = await this.zoomRequest<{ + meetings?: Record[]; + }>(`/past_meetings/${encodeURIComponent(meeting_id)}/instances`); + return { + operation: 'list_past_instances', + success: true, + meetings: data.meetings ?? [], + error: '', + }; + } + + private async listUserRecordings( + params: Extract + ): Promise> { + const { user_id, from, to, page_size, next_page_token } = params; + const data = await this.zoomRequest<{ + meetings?: Record[]; + page_size?: number; + total_records?: number; + next_page_token?: string; + from?: string; + to?: string; + }>(`/users/${encodeURIComponent(user_id)}/recordings`, 'GET', undefined, { + from, + to, + page_size, + next_page_token, + }); + return { + operation: 'list_user_recordings', + success: true, + meetings: data.meetings ?? [], + page_size: data.page_size, + total_records: data.total_records, + next_page_token: data.next_page_token, + from: data.from, + to: data.to, + error: '', + }; + } + + private async getRecording( + params: Extract + ): Promise> { + const { meeting_id, include_fields } = params; + const recording = await this.zoomRequest>( + `/meetings/${encodeURIComponent(meeting_id)}/recordings`, + 'GET', + undefined, + include_fields ? { include_fields } : undefined + ); + const recordingFiles = Array.isArray( + (recording as { recording_files?: unknown }).recording_files + ) + ? (recording as { recording_files: Record[] }) + .recording_files + : []; + return { + operation: 'get_recording', + success: true, + recording, + recording_files: recordingFiles, + error: '', + }; + } + + private async getMeetingTranscript( + params: Extract + ): Promise> { + const { meeting_id, download } = params; + + const recording = await this.zoomRequest<{ + recording_files?: Record[]; + }>(`/meetings/${encodeURIComponent(meeting_id)}/recordings`); + + const files = recording.recording_files ?? []; + const transcriptFile = files.find( + (f) => (f as { file_type?: string }).file_type === 'TRANSCRIPT' + ); + + if (!transcriptFile) { + return { + operation: 'get_meeting_transcript', + success: false, + error: 'No TRANSCRIPT file found for this meeting recording', + }; + } + + let transcriptVtt: string | undefined; + if (download) { + const downloadUrl = (transcriptFile as { download_url?: string }) + .download_url; + if (!downloadUrl) { + return { + operation: 'get_meeting_transcript', + success: false, + transcript_file: transcriptFile, + error: 'Transcript file is missing download_url', + }; + } + const accessToken = this.parseAccessToken(); + const dlResp = await fetch(downloadUrl, { + headers: { Authorization: `Bearer ${accessToken}` }, + }); + if (!dlResp.ok) { + const text = await dlResp.text(); + return { + operation: 'get_meeting_transcript', + success: false, + transcript_file: transcriptFile, + error: `Transcript download failed (${dlResp.status}): ${text}`, + }; + } + transcriptVtt = await dlResp.text(); + } + + return { + operation: 'get_meeting_transcript', + success: true, + transcript_file: transcriptFile, + transcript_vtt: transcriptVtt, + error: '', + }; + } + + private async getUser( + params: Extract + ): Promise> { + const { user_id } = params; + const user = await this.zoomRequest>( + `/users/${encodeURIComponent(user_id)}` + ); + return { + operation: 'get_user', + success: true, + user, + error: '', + }; + } +} diff --git a/packages/bubble-core/src/index.ts b/packages/bubble-core/src/index.ts index 24245b8d..873e4df0 100644 --- a/packages/bubble-core/src/index.ts +++ b/packages/bubble-core/src/index.ts @@ -147,6 +147,14 @@ export { type MemberfulParamsInput, type MemberfulResult, } from './bubbles/service-bubble/memberful/index.js'; +export { + ZoomBubble, + ZoomParamsSchema, + ZoomResultSchema, + type ZoomParams, + type ZoomParamsInput, + type ZoomResult, +} from './bubbles/service-bubble/zoom/index.js'; export { ConfluenceBubble } from './bubbles/service-bubble/confluence/index.js'; export type { ConfluenceParamsInput } from './bubbles/service-bubble/confluence/index.js'; export { AshbyBubble } from './bubbles/service-bubble/ashby/index.js'; diff --git a/packages/bubble-runtime/package.json b/packages/bubble-runtime/package.json index f0ee9e17..4dfc3b98 100644 --- a/packages/bubble-runtime/package.json +++ b/packages/bubble-runtime/package.json @@ -1,6 +1,6 @@ { "name": "@bubblelab/bubble-runtime", - "version": "0.1.318", + "version": "0.1.319", "type": "module", "license": "Apache-2.0", "main": "./dist/index.js", diff --git a/packages/bubble-scope-manager/package.json b/packages/bubble-scope-manager/package.json index 11920259..8ed88688 100644 --- a/packages/bubble-scope-manager/package.json +++ b/packages/bubble-scope-manager/package.json @@ -1,6 +1,6 @@ { "name": "@bubblelab/ts-scope-manager", - "version": "0.1.318", + "version": "0.1.319", "private": false, "license": "MIT", "type": "commonjs", diff --git a/packages/bubble-shared-schemas/package.json b/packages/bubble-shared-schemas/package.json index cac77a28..743e3993 100644 --- a/packages/bubble-shared-schemas/package.json +++ b/packages/bubble-shared-schemas/package.json @@ -1,6 +1,6 @@ { "name": "@bubblelab/shared-schemas", - "version": "0.1.318", + "version": "0.1.319", "type": "module", "license": "Apache-2.0", "main": "./dist/index.js", diff --git a/packages/bubble-shared-schemas/src/bubble-definition-schema.ts b/packages/bubble-shared-schemas/src/bubble-definition-schema.ts index 64c27c6c..04fee0b4 100644 --- a/packages/bubble-shared-schemas/src/bubble-definition-schema.ts +++ b/packages/bubble-shared-schemas/src/bubble-definition-schema.ts @@ -90,6 +90,7 @@ export const CREDENTIAL_CONFIGURATION_MAP: Record< [CredentialType.CLERK_API_KEY]: {}, [CredentialType.GRANOLA_API_KEY]: {}, [CredentialType.MEMBERFUL_CRED]: {}, + [CredentialType.ZOOM_CRED]: {}, [CredentialType.CREDENTIAL_WILDCARD]: {}, // Wildcard marker, not a real credential }; diff --git a/packages/bubble-shared-schemas/src/capability-schema.ts b/packages/bubble-shared-schemas/src/capability-schema.ts index 3ba2987c..4c0a4357 100644 --- a/packages/bubble-shared-schemas/src/capability-schema.ts +++ b/packages/bubble-shared-schemas/src/capability-schema.ts @@ -52,7 +52,8 @@ export type CapabilityId = | 'clerk-assistant' | 'granola-assistant' | 'memberful-assistant' - | 'luma-assistant'; + | 'luma-assistant' + | 'zoom-recording-insights'; /** * Schema for a provider entry in a capability's metadata. diff --git a/packages/bubble-shared-schemas/src/credential-schema.ts b/packages/bubble-shared-schemas/src/credential-schema.ts index 114e4e87..ae9f6dad 100644 --- a/packages/bubble-shared-schemas/src/credential-schema.ts +++ b/packages/bubble-shared-schemas/src/credential-schema.ts @@ -726,6 +726,14 @@ export const CREDENTIAL_TYPE_CONFIG: Record = namePlaceholder: 'My Granola API Key', credentialConfigurations: {}, }, + [CredentialType.ZOOM_CRED]: { + label: 'Zoom', + description: + 'OAuth connection to Zoom for meetings, cloud recordings, transcripts, and users', + placeholder: '', // Not used for OAuth + namePlaceholder: 'My Zoom Connection', + credentialConfigurations: {}, + }, [CredentialType.CREDENTIAL_WILDCARD]: { label: 'Any Credential', description: @@ -814,6 +822,7 @@ export const CREDENTIAL_ENV_MAP: Record = { [CredentialType.CLERK_API_KEY]: '', // User-provided Secret Key, no env var [CredentialType.GRANOLA_API_KEY]: 'GRANOLA_API_KEY', [CredentialType.MEMBERFUL_CRED]: '', // Multi-field credential (subdomain + apiKey), no single env var + [CredentialType.ZOOM_CRED]: '', // OAuth credential, no env var [CredentialType.CREDENTIAL_WILDCARD]: '', // Wildcard marker, not a real credential }; @@ -866,7 +875,8 @@ export type OAuthProvider = | 'salesforce' | 'asana' | 'discord' - | 'docusign'; + | 'docusign' + | 'zoom'; /** * Scope description mapping - maps OAuth scope URLs to human-readable descriptions @@ -2593,6 +2603,107 @@ export const OAUTH_PROVIDERS: Record = { prompt: 'login', }, }, + zoom: { + name: 'zoom', + displayName: 'Zoom', + credentialTypes: { + [CredentialType.ZOOM_CRED]: { + displayName: 'Zoom', + defaultScopes: [ + 'meeting:write:meeting', + 'meeting:read:meeting', + 'meeting:read:summary', + 'meeting:read:past_meeting', + 'meeting:read:list_past_instances', + 'meeting:read:list_meetings', + 'cloud_recording:read:list_user_recordings', + 'cloud_recording:read:content', + 'cloud_recording:read:list_recording_files', + 'cloud_recording:read:recording', + 'cloud_recording:read:meeting_transcript', + 'user:read:email', + 'user:read:user', + 'zoomapp:inmeeting', + ], + description: + 'Access Zoom for managing meetings, cloud recordings, transcripts, and users', + scopeDescriptions: [ + { + scope: 'meeting:write:meeting', + description: 'Create, update, and delete meetings', + defaultEnabled: true, + }, + { + scope: 'meeting:read:meeting', + description: 'Read details of a single meeting', + defaultEnabled: true, + }, + { + scope: 'meeting:read:summary', + description: "Read a meeting's AI Companion summary", + defaultEnabled: true, + }, + { + scope: 'meeting:read:past_meeting', + description: 'Read details of a past meeting', + defaultEnabled: true, + }, + { + scope: 'meeting:read:list_past_instances', + description: 'List past instances of a recurring meeting', + defaultEnabled: true, + }, + { + scope: 'meeting:read:list_meetings', + description: "List a user's meetings", + defaultEnabled: true, + }, + { + scope: 'cloud_recording:read:list_user_recordings', + description: "List a user's cloud recordings", + defaultEnabled: true, + }, + { + scope: 'cloud_recording:read:content', + description: 'Read cloud recording file content (download)', + defaultEnabled: true, + }, + { + scope: 'cloud_recording:read:list_recording_files', + description: "Return all of a meeting's recording files", + defaultEnabled: true, + }, + { + scope: 'cloud_recording:read:recording', + description: 'Read details of a single cloud recording', + defaultEnabled: true, + }, + { + scope: 'cloud_recording:read:meeting_transcript', + description: "Read a meeting's transcript", + defaultEnabled: true, + }, + { + scope: 'user:read:email', + description: "Verify a user's email", + defaultEnabled: true, + }, + { + scope: 'user:read:user', + description: 'Read the authenticated user profile', + defaultEnabled: true, + }, + { + scope: 'zoomapp:inmeeting', + description: + 'Required dependency from other scopes (no API access used)', + defaultEnabled: true, + }, + ], + }, + }, + authorizationParams: {}, + }, }; /** @@ -2968,6 +3079,7 @@ export const BUBBLE_CREDENTIAL_OPTIONS: Record< granola: [CredentialType.GRANOLA_API_KEY], memberful: [CredentialType.MEMBERFUL_CRED], luma: [], + zoom: [CredentialType.ZOOM_CRED], }; export interface CredentialSiblingEntry { diff --git a/packages/bubble-shared-schemas/src/types.ts b/packages/bubble-shared-schemas/src/types.ts index b601c34f..923ea41f 100644 --- a/packages/bubble-shared-schemas/src/types.ts +++ b/packages/bubble-shared-schemas/src/types.ts @@ -140,6 +140,9 @@ export enum CredentialType { // Memberful Credentials MEMBERFUL_CRED = 'MEMBERFUL_CRED', + + // Zoom Credentials + ZOOM_CRED = 'ZOOM_CRED', } // Define all bubble names as a union type for type safety @@ -232,4 +235,5 @@ export type BubbleName = | 'clerk' | 'granola' | 'memberful' - | 'luma'; + | 'luma' + | 'zoom'; diff --git a/packages/create-bubblelab-app/package.json b/packages/create-bubblelab-app/package.json index a9977736..f9e370db 100644 --- a/packages/create-bubblelab-app/package.json +++ b/packages/create-bubblelab-app/package.json @@ -1,6 +1,6 @@ { "name": "create-bubblelab-app", - "version": "0.1.318", + "version": "0.1.319", "type": "module", "license": "Apache-2.0", "description": "Create BubbleLab AI agent applications with one command", diff --git a/packages/create-bubblelab-app/templates/basic/package.json b/packages/create-bubblelab-app/templates/basic/package.json index 7b4bdd1a..96210ee0 100644 --- a/packages/create-bubblelab-app/templates/basic/package.json +++ b/packages/create-bubblelab-app/templates/basic/package.json @@ -11,9 +11,9 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "@bubblelab/bubble-core": "^0.1.318", - "@bubblelab/bubble-runtime": "^0.1.318", - "@bubblelab/shared-schemas": "^0.1.318", + "@bubblelab/bubble-core": "^0.1.319", + "@bubblelab/bubble-runtime": "^0.1.319", + "@bubblelab/shared-schemas": "^0.1.319", "dotenv": "^16.4.5" }, "devDependencies": { diff --git a/packages/create-bubblelab-app/templates/reddit-scraper/package.json b/packages/create-bubblelab-app/templates/reddit-scraper/package.json index 00088f99..eaea3d15 100644 --- a/packages/create-bubblelab-app/templates/reddit-scraper/package.json +++ b/packages/create-bubblelab-app/templates/reddit-scraper/package.json @@ -11,8 +11,8 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "@bubblelab/bubble-core": "^0.1.318", - "@bubblelab/bubble-runtime": "^0.1.318", + "@bubblelab/bubble-core": "^0.1.319", + "@bubblelab/bubble-runtime": "^0.1.319", "dotenv": "^16.4.5" }, "devDependencies": {