diff --git a/components/grain/actions/get-recording/get-recording.mjs b/components/grain/actions/get-recording/get-recording.mjs index 776d0168cd8e5..5bf1022311a1c 100644 --- a/components/grain/actions/get-recording/get-recording.mjs +++ b/components/grain/actions/get-recording/get-recording.mjs @@ -1,15 +1,13 @@ -import { - INTELLIGENCE_NOTES_FORMAT_OPTIONS, - TRANSCRIPT_FORMAT_OPTIONS, -} from "../../common/constants.mjs"; -import { parseObject } from "../../common/utils.mjs"; import grain from "../../grain.app.mjs"; export default { key: "grain-get-recording", name: "Get Recording", - description: "Fetches a specific recording by its ID from Grain, optionally including the transcript and intelligence notes. [See the documentation](https://grainhq.notion.site/grain-public-api-877184aa82b54c77a875083c1b560de9)", - version: "0.0.2", + description: "Fetches a specific recording by its ID from Grain, returning its metadata (title, times, URL, tags, teams, meeting type)." + + " Enable the optional include props to add highlights, participants, AI action items, AI summary, calendar event, HubSpot data, or screenshares to the response." + + " Use **List Recordings** to find recording IDs, and **Get Transcript** to fetch the full transcript." + + " [See the documentation](https://developers.grain.com/#get-recording)", + version: "1.0.0", annotations: { destructiveHint: false, openWorldHint: true, @@ -18,45 +16,77 @@ export default { type: "action", props: { grain, - recordId: { + recordingId: { propDefinition: [ grain, - "recordId", + "recordingId", ], }, - transcriptFormat: { - type: "string", - label: "Transcript Format", - description: "Format for the transcript", - options: TRANSCRIPT_FORMAT_OPTIONS, - optional: true, + highlights: { + propDefinition: [ + grain, + "highlights", + ], }, - intelligenceNotesFormat: { - type: "string", - label: "Intelligence Notes Format", - description: "Format for the intelligence notes", - options: INTELLIGENCE_NOTES_FORMAT_OPTIONS, - optional: true, + participants: { + propDefinition: [ + grain, + "participants", + ], + }, + aiActionItems: { + propDefinition: [ + grain, + "aiActionItems", + ], }, - allowedIntelligenceNotes: { - type: "string[]", - label: "Allowed Intelligence Notes", - description: "Whitelist of intelligence notes section titles", + aiSummary: { + propDefinition: [ + grain, + "aiSummary", + ], + }, + calendarEvent: { + propDefinition: [ + grain, + "calendarEvent", + ], + }, + hubspot: { + propDefinition: [ + grain, + "hubspot", + ], + }, + screenshares: { + type: "boolean", + label: "Include Screenshares", + description: "Include the recording's screenshare ranges in the response", optional: true, }, }, async run({ $ }) { + const include = { + highlights: this.highlights, + participants: this.participants, + ai_action_items: this.aiActionItems, + ai_summary: this.aiSummary, + calendar_event: this.calendarEvent, + hubspot: this.hubspot, + screenshares: this.screenshares, + }; + const response = await this.grain.fetchRecording({ $, - recordId: this.recordId, - params: { - transcript_format: this.transcriptFormat, - intelligence_notes_format: this.intelligenceNotesFormat, - allowed_intelligence_notes: parseObject(this.allowedIntelligenceNotes), + recordingId: this.recordingId, + data: { + include: Object.fromEntries(Object.entries(include).filter(([ + , value, + ]) => value)), }, }); - $.export("$summary", `Successfully fetched recording with ID ${this.recordId}`); + $.export("$summary", `Successfully fetched recording with ID ${this.recordingId}`); return response; }, }; diff --git a/components/grain/actions/get-transcript/get-transcript.mjs b/components/grain/actions/get-transcript/get-transcript.mjs new file mode 100644 index 0000000000000..a210fcba29e28 --- /dev/null +++ b/components/grain/actions/get-transcript/get-transcript.mjs @@ -0,0 +1,45 @@ +import { TRANSCRIPT_FORMAT_OPTIONS } from "../../common/constants.mjs"; +import grain from "../../grain.app.mjs"; + +export default { + key: "grain-get-transcript", + name: "Get Transcript", + description: "Fetches the full transcript of a Grain recording." + + " The `json` format returns structured segments with speaker, participant ID, start/end times in milliseconds, and text;" + + " `txt`, `vtt`, and `srt` return plain text or subtitle formats." + + " Use **List Recordings** to find recording IDs; use **Get Recording** for the recording's metadata instead of its transcript." + + " [See the documentation](https://developers.grain.com/#get-recording-transcript-json)", + version: "0.0.1", + annotations: { + destructiveHint: false, + openWorldHint: true, + readOnlyHint: true, + }, + type: "action", + props: { + grain, + recordingId: { + propDefinition: [ + grain, + "recordingId", + ], + }, + format: { + type: "string", + label: "Format", + description: "Format for the transcript", + options: TRANSCRIPT_FORMAT_OPTIONS, + default: "json", + }, + }, + async run({ $ }) { + const response = await this.grain.fetchTranscript({ + $, + recordingId: this.recordingId, + format: this.format, + }); + + $.export("$summary", `Successfully fetched transcript for recording ${this.recordingId}`); + return response; + }, +}; diff --git a/components/grain/actions/list-recordings/list-recordings.mjs b/components/grain/actions/list-recordings/list-recordings.mjs new file mode 100644 index 0000000000000..b09e5570ec314 --- /dev/null +++ b/components/grain/actions/list-recordings/list-recordings.mjs @@ -0,0 +1,89 @@ +import grain from "../../grain.app.mjs"; + +export default { + key: "grain-list-recordings", + name: "List Recordings", + description: "Lists Grain recordings, optionally filtered by start datetime range (ISO8601), title search, or participant scope." + + " Automatically paginates and returns up to Max Results recordings." + + " Use this to find recording IDs for **Get Recording** and **Get Transcript**." + + " [See the documentation](https://developers.grain.com/#list-recordings)", + version: "0.0.1", + annotations: { + destructiveHint: false, + openWorldHint: true, + readOnlyHint: true, + }, + type: "action", + props: { + grain, + beforeDatetime: { + type: "string", + label: "Before Datetime", + description: "Only return recordings that started before this ISO8601 datetime. E.g. `2025-01-01T00:00:00Z`", + optional: true, + }, + afterDatetime: { + type: "string", + label: "After Datetime", + description: "Only return recordings that started after this ISO8601 datetime. E.g. `2025-01-01T00:00:00Z`", + optional: true, + }, + titleSearch: { + type: "string", + label: "Title Search", + description: "Only return recordings whose title matches this search string", + optional: true, + }, + participantScope: { + type: "string", + label: "Participant Scope", + description: "Only return recordings whose participants are all internal, or that include external participants", + options: [ + "internal", + "external", + ], + optional: true, + }, + maxResults: { + type: "integer", + label: "Max Results", + description: "Maximum number of recordings to return. Must be a positive integer.", + optional: true, + default: 100, + min: 1, + }, + }, + async run({ $ }) { + const filter = { + before_datetime: this.beforeDatetime, + after_datetime: this.afterDatetime, + title_search: this.titleSearch, + participant_scope: this.participantScope, + }; + + const recordings = []; + let cursor; + do { + const { + recordings: page, cursor: nextCursor, + } = await this.grain.listRecordings({ + $, + data: { + cursor, + filter, + }, + }); + recordings.push(...page); + cursor = nextCursor; + } while (cursor && recordings.length < this.maxResults); + + if (recordings.length > this.maxResults) { + recordings.length = this.maxResults; + } + + $.export("$summary", `Successfully fetched ${recordings.length} recording${recordings.length === 1 + ? "" + : "s"}`); + return recordings; + }, +}; diff --git a/components/grain/common/constants.mjs b/components/grain/common/constants.mjs index d7deeadb1afb4..abc754f8cb734 100644 --- a/components/grain/common/constants.mjs +++ b/components/grain/common/constants.mjs @@ -4,22 +4,15 @@ export const TRANSCRIPT_FORMAT_OPTIONS = [ value: "json", }, { - label: "VTT", - value: "vtt", - }, -]; - -export const INTELLIGENCE_NOTES_FORMAT_OPTIONS = [ - { - label: "JSON", - value: "json", + label: "Text", + value: "txt", }, { - label: "Markdown", - value: "md", + label: "VTT", + value: "vtt", }, { - label: "Text", - value: "text", + label: "SRT", + value: "srt", }, ]; diff --git a/components/grain/common/utils.mjs b/components/grain/common/utils.mjs deleted file mode 100644 index dcc9cc61f6f41..0000000000000 --- a/components/grain/common/utils.mjs +++ /dev/null @@ -1,24 +0,0 @@ -export const parseObject = (obj) => { - if (!obj) return undefined; - - if (Array.isArray(obj)) { - return obj.map((item) => { - if (typeof item === "string") { - try { - return JSON.parse(item); - } catch (e) { - return item; - } - } - return item; - }); - } - if (typeof obj === "string") { - try { - return JSON.parse(obj); - } catch (e) { - return obj; - } - } - return obj; -}; diff --git a/components/grain/grain.app.mjs b/components/grain/grain.app.mjs index 3f2e01a992f9e..0385f56a6ed85 100644 --- a/components/grain/grain.app.mjs +++ b/components/grain/grain.app.mjs @@ -4,16 +4,16 @@ export default { type: "app", app: "grain", propDefinitions: { - recordId: { + recordingId: { type: "string", - label: "Record ID", - description: "The ID of the recording to fetch", - async options({ prevContext: { nextPage } }) { + label: "Recording ID", + description: "The ID of the recording to fetch. Use **List Recordings** to find recording IDs.", + async options({ prevContext }) { const { recordings, cursor, } = await this.listRecordings({ - params: { - cursor: nextPage, + data: { + cursor: prevContext?.nextPage, }, }); return { @@ -29,42 +29,63 @@ export default { }; }, }, - viewId: { - type: "string", - label: "View ID", - description: "The ID of the view to fetch", - async options({ - type, prevContext: { nextPage }, - }) { - const { - views, cursor, - } = await this.listViews({ - params: { - type_filter: type, - cursor: nextPage, - }, - }); - return { - options: views.map(({ - id: value, name: label, - }) => ({ - value, - label, - })), - context: { - nextPage: cursor, - }, - }; - }, + highlights: { + type: "boolean", + label: "Include Highlights", + description: "Whether to include the recording's highlights", + optional: true, + }, + participants: { + type: "boolean", + label: "Include Participants", + description: "Whether to include the recording's participants", + optional: true, + }, + calendarEvent: { + type: "boolean", + label: "Include Calendar Event", + description: "Whether to include the recording's calendar event data", + optional: true, + }, + hubspot: { + type: "boolean", + label: "Include HubSpot Data", + description: "Whether to include associated HubSpot data", + optional: true, + }, + aiActionItems: { + type: "boolean", + label: "Include AI Action Items", + description: "Whether to include the recording's AI action items", + optional: true, + }, + aiSummary: { + type: "boolean", + label: "Include AI Summary", + description: "Whether to include the recording's AI summary", + optional: true, + }, + transcript: { + type: "boolean", + label: "Include Transcript", + description: "Whether to include the highlight's transcript", + optional: true, + }, + speakers: { + type: "boolean", + label: "Include Speakers", + description: "Whether to include the highlight's speakers", + optional: true, }, }, methods: { _baseUrl() { - return "https://grain.com/_/public-api"; + return "https://api.grain.com/_/public-api/v2"; }, _headers() { return { - Authorization: `Bearer ${this.$auth.oauth_access_token}`, + "Authorization": `Bearer ${this.$auth.oauth_access_token}`, + "Public-Api-Version": "2025-10-31", }; }, _makeRequest({ @@ -76,33 +97,64 @@ export default { ...opts, }); }, + /** + * Fetch a page of recordings matching the supplied filters. + * @param {object} [opts={}] Request context and data containing filter, include, and cursor. + * @returns {Promise} Recordings and the cursor for the next page. + */ listRecordings(opts = {}) { return this._makeRequest({ + method: "POST", path: "/recordings", ...opts, }); }, - listViews(opts = {}) { + /** + * Fetch recording metadata and optional related data. + * @param {object} opts Request context, recordingId, and data containing include options. + * @returns {Promise} The recording. + */ + fetchRecording({ + recordingId, ...opts + }) { return this._makeRequest({ - path: "/views", + method: "POST", + path: `/recordings/${recordingId}`, ...opts, }); }, - fetchRecording({ - recordId, ...opts + /** + * Fetch a recording's transcript in the requested format. + * @param {object} opts Request context, recordingId, and format (json, txt, vtt, or srt). + * @returns {Promise} Transcript segments for JSON, or transcript text. + */ + fetchTranscript({ + recordingId, format, ...opts }) { return this._makeRequest({ - path: `/recordings/${recordId}`, + path: `/recordings/${recordingId}/transcript${format === "json" + ? "" + : `.${format}`}`, ...opts, }); }, + /** + * Register a webhook for a Grain event type. + * @param {object} [opts={}] Request options with hook_url, hook_type, and include in data. + * @returns {Promise} The registered hook, including its ID. + */ createWebhook(opts = {}) { return this._makeRequest({ method: "POST", - path: "/hooks", + path: "/hooks/create", ...opts, }); }, + /** + * Remove a webhook registration. + * @param {string} hookId The ID returned when the hook was created. + * @returns {Promise} The API's success response. + */ deleteWebhook(hookId) { return this._makeRequest({ method: "DELETE", diff --git a/components/grain/package.json b/components/grain/package.json index b3f932031b10f..2dcaeccf01505 100644 --- a/components/grain/package.json +++ b/components/grain/package.json @@ -1,6 +1,6 @@ { "name": "@pipedream/grain", - "version": "0.1.0", + "version": "1.0.0", "description": "Pipedream Grain Components", "main": "grain.app.mjs", "keywords": [ diff --git a/components/grain/sources/common/base.mjs b/components/grain/sources/common/base.mjs index ae3f5a16dc562..5cbbb519a6ac3 100644 --- a/components/grain/sources/common/base.mjs +++ b/components/grain/sources/common/base.mjs @@ -13,32 +13,38 @@ export default { _setHookId(hookId) { this.db.set("hookId", hookId); }, + getInclude() { + return undefined; + }, + getTimestamp() { + return Date.now(); + }, }, hooks: { async activate() { const response = await this.grain.createWebhook({ data: { - version: 2, hook_url: this.http.endpoint, - view_id: this.viewId, - actions: this.getAction(), + hook_type: this.getHookType(), + include: this.getInclude(), }, }); this._setHookId(response.id); }, async deactivate() { const webhookId = this._getHookId(); - await this.grain.deleteWebhook(webhookId); + if (webhookId) { + await this.grain.deleteWebhook(webhookId); + } }, }, async run({ body }) { - if (!body.data) return; + if (!body?.data?.id || body.type !== this.getHookType()) return; - const ts = Date.parse(new Date()); this.$emit(body, { - id: `${body.data.id}-${ts}`, + id: body.data.id, summary: this.getSummary(body), - ts, + ts: this.getTimestamp(body), }); }, }; diff --git a/components/grain/sources/common/highlight.mjs b/components/grain/sources/common/highlight.mjs new file mode 100644 index 0000000000000..1262d449374bb --- /dev/null +++ b/components/grain/sources/common/highlight.mjs @@ -0,0 +1,32 @@ +import common from "./base.mjs"; + +export default { + ...common, + props: { + ...common.props, + transcript: { + propDefinition: [ + common.props.grain, + "transcript", + ], + }, + speakers: { + propDefinition: [ + common.props.grain, + "speakers", + ], + }, + }, + methods: { + ...common.methods, + getInclude() { + const include = { + transcript: this.transcript, + speakers: this.speakers, + }; + return Object.fromEntries(Object.entries(include).filter(([ + , value, + ]) => value)); + }, + }, +}; diff --git a/components/grain/sources/common/recording.mjs b/components/grain/sources/common/recording.mjs new file mode 100644 index 0000000000000..7b39c37677e98 --- /dev/null +++ b/components/grain/sources/common/recording.mjs @@ -0,0 +1,60 @@ +import common from "./base.mjs"; + +export default { + ...common, + props: { + ...common.props, + highlights: { + propDefinition: [ + common.props.grain, + "highlights", + ], + }, + participants: { + propDefinition: [ + common.props.grain, + "participants", + ], + }, + calendarEvent: { + propDefinition: [ + common.props.grain, + "calendarEvent", + ], + }, + hubspot: { + propDefinition: [ + common.props.grain, + "hubspot", + ], + }, + aiActionItems: { + propDefinition: [ + common.props.grain, + "aiActionItems", + ], + }, + aiSummary: { + propDefinition: [ + common.props.grain, + "aiSummary", + ], + }, + }, + methods: { + ...common.methods, + getInclude() { + const include = { + highlights: this.highlights, + participants: this.participants, + calendar_event: this.calendarEvent, + hubspot: this.hubspot, + ai_action_items: this.aiActionItems, + ai_summary: this.aiSummary, + }; + return Object.fromEntries(Object.entries(include).filter(([ + , value, + ]) => value)); + }, + }, +}; diff --git a/components/grain/sources/new-highlight-instant/new-highlight-instant.mjs b/components/grain/sources/new-highlight-instant/new-highlight-instant.mjs index 9f6eb8ca6d585..0c920a89d268e 100644 --- a/components/grain/sources/new-highlight-instant/new-highlight-instant.mjs +++ b/components/grain/sources/new-highlight-instant/new-highlight-instant.mjs @@ -1,32 +1,24 @@ -import common from "../common/base.mjs"; +import common from "../common/highlight.mjs"; import sampleEmit from "./test-event.mjs"; export default { ...common, key: "grain-new-highlight-instant", name: "New Highlight (Instant)", - description: "Emit new event when a highlight that matches the filter is added.", - version: "0.0.1", + description: "Emit new event when a highlight is added. [See the documentation](https://developers.grain.com/#create-hook)", + version: "1.0.0", type: "source", dedupe: "unique", - props: { - ...common.props, - viewId: { - propDefinition: [ - common.props.grain, - "viewId", - () => ({ - type: "highlights", - }), - ], - }, - }, methods: { ...common.methods, - getAction() { - return [ - "added", - ]; + getHookType() { + return "highlight_added"; + }, + getTimestamp({ data }) { + const ts = Date.parse(data.created_datetime); + return Number.isNaN(ts) + ? Date.now() + : ts; }, getSummary({ data }) { return `New highlight added: ${data.id}`; diff --git a/components/grain/sources/new-highlight-instant/test-event.mjs b/components/grain/sources/new-highlight-instant/test-event.mjs index 2fec46429c6b9..4ae0bf3049331 100644 --- a/components/grain/sources/new-highlight-instant/test-event.mjs +++ b/components/grain/sources/new-highlight-instant/test-event.mjs @@ -2,16 +2,20 @@ export default { "type": "highlight_added", "user_id": "aea95745-99e9-4609-8623-c9efa2926b82", "data": { - "id": "vjQRUKsWw0aFpCT3531eGbr8V0HJrMjKMEIcAUmP", - "recording_id": "b5185ccb-9a08-458c-9be1-db17a03fb14c", - "text": "testing 123 #test", + "id": "a14e5af9-d28e-43e9-902b-bc07419082eb", + "recording_id": "b5185ccb-9a08-458c-9be1-db17a03fb14c", + "text": "testing 123 #test", "transcript": "expected, that there was a mews in a lane which runs down by one wall of the garden. I lent the ostlers a hand in rubbing down their horses, and received in exchange twopence, a glass of half-and-half, two fills of shag tobacco, and as much information as I could desire about Miss Adler, to say nothing of half a dozen other people in", - "speakers": ["Andy Arbol"], + "speakers": [ + "Andy Arbol" + ], "timestamp": 3080, "duration": 15000, - "created_datetime": "2021-07-29T23:16:34Z", - "url": "https://grain.com/highlight/vjQRUKsWw0aFpCT3531eGbr8V0HJrMjKMEIcAUmP", + "created_datetime": "2021-07-29T23:16:34Z", + "url": "https://grain.com/highlight/a14e5af9-d28e-43e9-902b-bc07419082eb", "thumbnail_url": "https://media.grain.com/clips/v1/a14e5af9-d28e-43e9-902b-bc07419082eb/57zB8z52l7BKPoOvkS9KNyUi7LDSsNEh.jpeg", - "tags": ["test"] + "tags": [ + "test" + ] } -} \ No newline at end of file +}; diff --git a/components/grain/sources/new-recording-instant/new-recording-instant.mjs b/components/grain/sources/new-recording-instant/new-recording-instant.mjs index 9584835dceaeb..1bcdadff8fe44 100644 --- a/components/grain/sources/new-recording-instant/new-recording-instant.mjs +++ b/components/grain/sources/new-recording-instant/new-recording-instant.mjs @@ -1,32 +1,24 @@ -import common from "../common/base.mjs"; +import common from "../common/recording.mjs"; import sampleEmit from "./test-event.mjs"; export default { ...common, key: "grain-new-recording-instant", name: "New Recording (Instant)", - description: "Emit new event when a recording that matches the filter is added.", - version: "0.0.1", + description: "Emit new event when a recording is added. [See the documentation](https://developers.grain.com/#create-hook)", + version: "1.0.0", type: "source", dedupe: "unique", - props: { - ...common.props, - viewId: { - propDefinition: [ - common.props.grain, - "viewId", - () => ({ - type: "recordings", - }), - ], - }, - }, methods: { ...common.methods, - getAction() { - return [ - "added", - ]; + getHookType() { + return "recording_added"; + }, + getTimestamp({ data }) { + const ts = Date.parse(data.end_datetime); + return Number.isNaN(ts) + ? Date.now() + : ts; }, getSummary({ data }) { return `New recording added: ${data.id}`; diff --git a/components/grain/sources/new-recording-instant/test-event.mjs b/components/grain/sources/new-recording-instant/test-event.mjs index bfac368e987bb..6b09e0126be50 100644 --- a/components/grain/sources/new-recording-instant/test-event.mjs +++ b/components/grain/sources/new-recording-instant/test-event.mjs @@ -2,11 +2,26 @@ export default { "type": "recording_added", "user_id": "aea95745-99e9-4609-8623-c9efa2926b82", "data": { - "id": "b5185ccb-9a08-458c-9be1-db17a03fb14c", - "title": "Sample Recording", - "url": "https://grain.com/recordings/b5185ccb-9a08-458c-9be1-db17a03fb14c/Kz5t1kAyPtt78hcxbSOJHJzFiPpZmUIeDVFXWzP0", - "start_datetime": "2021-07-29T23:13:17Z", - "end_datetime": "2021-07-29T23:16:18Z", - "public_thumbnail_url": null // Only non-null if recording share state is public - } -} \ No newline at end of file + "id": "pppp6666-qq77-rr88-ss99-tttt00000000", + "title": "All Hands", + "start_datetime": "2025-01-01T09:30:00Z", + "end_datetime": "2025-01-01T10:00:00Z", + "duration_ms": 1800000, + "media_type": "video", + "source": "zoom", + "url": "https://grain.com/share/recording/pppp6666-qq77-rr88-ss99-tttt00000000", + "thumbnail_url": "https://media.grain.com/public_thumbnails/recordings/pppp6666", + "tags": [], + "teams": [ + { + "id": "aaaa1111-bb22-cc33-dd44-eeee55555555", + "name": "My Team", + }, + ], + "meeting_type": { + "id": "ffff6666-gg77-hh88-ii99-jjjj00000000", + "name": "Project & Team Coordination", + "scope": "internal", + }, + }, +}; diff --git a/components/grain/sources/new-story-instant/new-story-instant.mjs b/components/grain/sources/new-story-instant/new-story-instant.mjs index 68fe04f1b1bba..034ad329ada3f 100644 --- a/components/grain/sources/new-story-instant/new-story-instant.mjs +++ b/components/grain/sources/new-story-instant/new-story-instant.mjs @@ -5,28 +5,20 @@ export default { ...common, key: "grain-new-story-instant", name: "New Story (Instant)", - description: "Emit new event when a story that matches the filter is added.", - version: "0.0.1", + description: "Emit new event when a story is added. [See the documentation](https://developers.grain.com/#create-hook)", + version: "1.0.0", type: "source", dedupe: "unique", - props: { - ...common.props, - viewId: { - propDefinition: [ - common.props.grain, - "viewId", - () => ({ - type: "stories", - }), - ], - }, - }, methods: { ...common.methods, - getAction() { - return [ - "added", - ]; + getHookType() { + return "story_added"; + }, + getTimestamp({ data }) { + const ts = Date.parse(data.created_datetime); + return Number.isNaN(ts) + ? Date.now() + : ts; }, getSummary({ data }) { return `New story added: ${data.id}`; diff --git a/components/grain/sources/new-story-instant/test-event.mjs b/components/grain/sources/new-story-instant/test-event.mjs index e163749cbc950..5f2d3e57f46b7 100644 --- a/components/grain/sources/new-story-instant/test-event.mjs +++ b/components/grain/sources/new-story-instant/test-event.mjs @@ -8,8 +8,10 @@ export default { "url": "https://grain.com/app/stories/89bd4a02-25f5-42c0-bd40-aa4c94be13ce", "public_url": "https://grain.com/share/story/89bd4a02-25f5-42c0-bd40-aa4c94be13ce/2hAEpxLsIN8hDQ48aQ1Yi1MIirv1qCPSJNhxXEoj", "banner_image_url": "https://media.grain.com/public/story_thumbnails/07.png", - "created_datetime": "2021-07-29T23:16:34Z", - "last_edited_datetime": "2021-08-29T23:16:34Z", - "tags": ["customer"] + "created_datetime": "2021-07-29T23:16:34Z", + "last_edited_datetime": "2021-08-29T23:16:34Z", + "tags": [ + "customer" + ] } -} \ No newline at end of file +}; diff --git a/components/grain/sources/removed-highlight-instant/removed-highlight-instant.mjs b/components/grain/sources/removed-highlight-instant/removed-highlight-instant.mjs index e284d0f258548..e7d6227f0e679 100644 --- a/components/grain/sources/removed-highlight-instant/removed-highlight-instant.mjs +++ b/components/grain/sources/removed-highlight-instant/removed-highlight-instant.mjs @@ -5,31 +5,17 @@ export default { ...common, key: "grain-removed-highlight-instant", name: "New Highlight Removed (Instant)", - description: "Emit new event when a highlight is removed.", - version: "0.0.1", + description: "Emit new event when a highlight is removed. [See the documentation](https://developers.grain.com/#create-hook)", + version: "1.0.0", type: "source", dedupe: "unique", - props: { - ...common.props, - viewId: { - propDefinition: [ - common.props.grain, - "viewId", - () => ({ - type: "highlights", - }), - ], - }, - }, methods: { ...common.methods, - getAction() { - return [ - "removed", - ]; + getHookType() { + return "highlight_deleted"; }, getSummary({ data }) { - return `Highlight removed from recording ${data.recording_id}`; + return `Highlight removed: ${data.id}`; }, }, sampleEmit, diff --git a/components/grain/sources/removed-highlight-instant/test-event.mjs b/components/grain/sources/removed-highlight-instant/test-event.mjs index 20fe6132f063a..57c0d7dfd9f32 100644 --- a/components/grain/sources/removed-highlight-instant/test-event.mjs +++ b/components/grain/sources/removed-highlight-instant/test-event.mjs @@ -1,8 +1,8 @@ export default { - "type": "highlight_removed", + "type": "highlight_deleted", "user_id": "aea95745-99e9-4609-8623-c9efa2926b82", "data": { - "id": "vjQRUKsWw0aFpCT3531eGbr8V0HJrMjKMEIcAUmP", + "id": "a14e5af9-d28e-43e9-902b-bc07419082eb", "recording_id": "b5185ccb-9a08-458c-9be1-db17a03fb14c" } -} \ No newline at end of file +}; diff --git a/components/grain/sources/removed-recording-instant/removed-recording-instant.mjs b/components/grain/sources/removed-recording-instant/removed-recording-instant.mjs index f62a24fab8513..16a05df98c2a1 100644 --- a/components/grain/sources/removed-recording-instant/removed-recording-instant.mjs +++ b/components/grain/sources/removed-recording-instant/removed-recording-instant.mjs @@ -5,28 +5,14 @@ export default { ...common, key: "grain-removed-recording-instant", name: "New Recording Removed (Instant)", - description: "Emit new event when a recording is removed.", - version: "0.0.1", + description: "Emit new event when a recording is removed. [See the documentation](https://developers.grain.com/#create-hook)", + version: "1.0.0", type: "source", dedupe: "unique", - props: { - ...common.props, - viewId: { - propDefinition: [ - common.props.grain, - "viewId", - () => ({ - type: "recordings", - }), - ], - }, - }, methods: { ...common.methods, - getAction() { - return [ - "removed", - ]; + getHookType() { + return "recording_deleted"; }, getSummary({ data }) { return `Recording removed: ${data.id}`; diff --git a/components/grain/sources/removed-recording-instant/test-event.mjs b/components/grain/sources/removed-recording-instant/test-event.mjs index e1f711fd981e2..6c0d0daf3e96f 100644 --- a/components/grain/sources/removed-recording-instant/test-event.mjs +++ b/components/grain/sources/removed-recording-instant/test-event.mjs @@ -1,7 +1,7 @@ export default { - "type": "recording_removed", + "type": "recording_deleted", "user_id": "aea95745-99e9-4609-8623-c9efa2926b82", "data": { "id": "b5185ccb-9a08-458c-9be1-db17a03fb14c" } -} \ No newline at end of file +}; diff --git a/components/grain/sources/removed-story-instant/removed-story-instant.mjs b/components/grain/sources/removed-story-instant/removed-story-instant.mjs index 15f37cbfaa605..78913a0b39bed 100644 --- a/components/grain/sources/removed-story-instant/removed-story-instant.mjs +++ b/components/grain/sources/removed-story-instant/removed-story-instant.mjs @@ -5,28 +5,14 @@ export default { ...common, key: "grain-removed-story-instant", name: "New Story Removed (Instant)", - description: "Emit new event when a story is removed.", - version: "0.0.1", + description: "Emit new event when a story is removed. [See the documentation](https://developers.grain.com/#create-hook)", + version: "1.0.0", type: "source", dedupe: "unique", - props: { - ...common.props, - viewId: { - propDefinition: [ - common.props.grain, - "viewId", - () => ({ - type: "stories", - }), - ], - }, - }, methods: { ...common.methods, - getAction() { - return [ - "removed", - ]; + getHookType() { + return "story_deleted"; }, getSummary({ data }) { return `New story removed: ${data.id}`; diff --git a/components/grain/sources/removed-story-instant/test-event.mjs b/components/grain/sources/removed-story-instant/test-event.mjs index 79c9215c51ba7..d7b0dd6d7da55 100644 --- a/components/grain/sources/removed-story-instant/test-event.mjs +++ b/components/grain/sources/removed-story-instant/test-event.mjs @@ -1,7 +1,7 @@ export default { - "type": "story_removed", + "type": "story_deleted", "user_id": "aea95745-99e9-4609-8623-c9efa2926b82", "data": { "id": "1aff0fe4-6575-4d5f-a462-aaf09f5f17a6" } -} \ No newline at end of file +}; diff --git a/components/grain/sources/updated-highlight-instant/test-event.mjs b/components/grain/sources/updated-highlight-instant/test-event.mjs index b892cad27d725..901270f4f5d86 100644 --- a/components/grain/sources/updated-highlight-instant/test-event.mjs +++ b/components/grain/sources/updated-highlight-instant/test-event.mjs @@ -2,16 +2,20 @@ export default { "type": "highlight_updated", "user_id": "aea95745-99e9-4609-8623-c9efa2926b82", "data": { - "id": "vjQRUKsWw0aFpCT3531eGbr8V0HJrMjKMEIcAUmP", - "recording_id": "b5185ccb-9a08-458c-9be1-db17a03fb14c", - "text": "testing 123 #test", + "id": "a14e5af9-d28e-43e9-902b-bc07419082eb", + "recording_id": "b5185ccb-9a08-458c-9be1-db17a03fb14c", + "text": "testing 123 #test", "transcript": "expected, that there was a mews in a lane which runs down by one wall of the garden. I lent the ostlers a hand in rubbing down their horses, and received in exchange twopence, a glass of half-and-half, two fills of shag tobacco, and as much information as I could desire about Miss Adler, to say nothing of half a dozen other people in", - "speakers": ["Andy Arbol"], + "speakers": [ + "Andy Arbol" + ], "timestamp": 3080, "duration": 15000, - "created_datetime": "2021-07-29T23:16:34Z", - "url": "https://grain.com/highlight/vjQRUKsWw0aFpCT3531eGbr8V0HJrMjKMEIcAUmP", + "created_datetime": "2021-07-29T23:16:34Z", + "url": "https://grain.com/highlight/a14e5af9-d28e-43e9-902b-bc07419082eb", "thumbnail_url": "https://media.grain.com/clips/v1/a14e5af9-d28e-43e9-902b-bc07419082eb/57zB8z52l7BKPoOvkS9KNyUi7LDSsNEh.jpeg", - "tags": ["test"] + "tags": [ + "test" + ] } -} \ No newline at end of file +}; diff --git a/components/grain/sources/updated-highlight-instant/updated-highlight-instant.mjs b/components/grain/sources/updated-highlight-instant/updated-highlight-instant.mjs index f5147e298b111..d81399abfef06 100644 --- a/components/grain/sources/updated-highlight-instant/updated-highlight-instant.mjs +++ b/components/grain/sources/updated-highlight-instant/updated-highlight-instant.mjs @@ -1,32 +1,18 @@ -import common from "../common/base.mjs"; +import common from "../common/highlight.mjs"; import sampleEmit from "./test-event.mjs"; export default { ...common, key: "grain-updated-highlight-instant", name: "New Highlight Updated (Instant)", - description: "Emit new event when a highlight is updated.", - version: "0.0.1", + description: "Emit new event when a highlight is updated. Each webhook delivery emits an event, including retries. [See the documentation](https://developers.grain.com/#create-hook)", + version: "1.0.0", type: "source", - dedupe: "unique", - props: { - ...common.props, - viewId: { - propDefinition: [ - common.props.grain, - "viewId", - () => ({ - type: "highlights", - }), - ], - }, - }, + // Grain does not document a delivery ID; deduping by resource ID would discard later updates. methods: { ...common.methods, - getAction() { - return [ - "updated", - ]; + getHookType() { + return "highlight_updated"; }, getSummary({ data }) { return `New highlight updated: ${data.id}`; diff --git a/components/grain/sources/updated-recording-instant/test-event.mjs b/components/grain/sources/updated-recording-instant/test-event.mjs index 5a440d2062bd2..fb2ce1797a5b5 100644 --- a/components/grain/sources/updated-recording-instant/test-event.mjs +++ b/components/grain/sources/updated-recording-instant/test-event.mjs @@ -2,11 +2,26 @@ export default { "type": "recording_updated", "user_id": "aea95745-99e9-4609-8623-c9efa2926b82", "data": { - "id": "b5185ccb-9a08-458c-9be1-db17a03fb14c", - "title": "Sample Recording", - "url": "https://grain.com/recordings/b5185ccb-9a08-458c-9be1-db17a03fb14c/Kz5t1kAyPtt78hcxbSOJHJzFiPpZmUIeDVFXWzP0", - "start_datetime": "2021-07-29T23:13:17Z", - "end_datetime": "2021-07-29T23:16:18Z", - "public_thumbnail_url": null // Only non-null if recording share state is public - } -} \ No newline at end of file + "id": "pppp6666-qq77-rr88-ss99-tttt00000000", + "title": "All Hands — Updated", + "start_datetime": "2025-01-01T09:30:00Z", + "end_datetime": "2025-01-01T10:00:00Z", + "duration_ms": 1800000, + "media_type": "video", + "source": "zoom", + "url": "https://grain.com/share/recording/pppp6666-qq77-rr88-ss99-tttt00000000", + "thumbnail_url": "https://media.grain.com/public_thumbnails/recordings/pppp6666", + "tags": [], + "teams": [ + { + "id": "aaaa1111-bb22-cc33-dd44-eeee55555555", + "name": "My Team", + }, + ], + "meeting_type": { + "id": "ffff6666-gg77-hh88-ii99-jjjj00000000", + "name": "Project & Team Coordination", + "scope": "internal", + }, + }, +}; diff --git a/components/grain/sources/updated-recording-instant/updated-recording-instant.mjs b/components/grain/sources/updated-recording-instant/updated-recording-instant.mjs index 5615b8e41430b..0a75d448a3482 100644 --- a/components/grain/sources/updated-recording-instant/updated-recording-instant.mjs +++ b/components/grain/sources/updated-recording-instant/updated-recording-instant.mjs @@ -1,32 +1,18 @@ -import common from "../common/base.mjs"; +import common from "../common/recording.mjs"; import sampleEmit from "./test-event.mjs"; export default { ...common, key: "grain-updated-recording-instant", name: "New Recording Updated (Instant)", - description: "Emit new event when a recording is updated.", - version: "0.0.1", + description: "Emit new event when a recording is updated. Each webhook delivery emits an event, including retries. [See the documentation](https://developers.grain.com/#create-hook)", + version: "1.0.0", type: "source", - dedupe: "unique", - props: { - ...common.props, - viewId: { - propDefinition: [ - common.props.grain, - "viewId", - () => ({ - type: "recordings", - }), - ], - }, - }, + // Grain does not document a delivery ID; deduping by resource ID would discard later updates. methods: { ...common.methods, - getAction() { - return [ - "updated", - ]; + getHookType() { + return "recording_updated"; }, getSummary({ data }) { return `New recording updated: ${data.id}`; diff --git a/components/grain/sources/updated-story-instant/test-event.mjs b/components/grain/sources/updated-story-instant/test-event.mjs index 00ce668c0c07c..578183cd0d890 100644 --- a/components/grain/sources/updated-story-instant/test-event.mjs +++ b/components/grain/sources/updated-story-instant/test-event.mjs @@ -8,8 +8,10 @@ export default { "url": "https://grain.com/app/stories/89bd4a02-25f5-42c0-bd40-aa4c94be13ce", "public_url": "https://grain.com/share/story/89bd4a02-25f5-42c0-bd40-aa4c94be13ce/2hAEpxLsIN8hDQ48aQ1Yi1MIirv1qCPSJNhxXEoj", "banner_image_url": "https://media.grain.com/public/story_thumbnails/07.png", - "created_datetime": "2021-07-29T23:16:34Z", - "last_edited_datetime": "2021-08-29T23:16:34Z", - "tags": ["customer"] + "created_datetime": "2021-07-29T23:16:34Z", + "last_edited_datetime": "2021-08-29T23:16:34Z", + "tags": [ + "customer" + ] } -} \ No newline at end of file +}; diff --git a/components/grain/sources/updated-story-instant/updated-story-instant.mjs b/components/grain/sources/updated-story-instant/updated-story-instant.mjs index 20c794c9eeca0..1a73bc8bd9b5d 100644 --- a/components/grain/sources/updated-story-instant/updated-story-instant.mjs +++ b/components/grain/sources/updated-story-instant/updated-story-instant.mjs @@ -5,28 +5,20 @@ export default { ...common, key: "grain-updated-story-instant", name: "New Story Updated (Instant)", - description: "Emit new event when a story is updated.", - version: "0.0.1", + description: "Emit new event when a story is updated. Each webhook delivery emits an event, including retries. [See the documentation](https://developers.grain.com/#create-hook)", + version: "1.0.0", type: "source", - dedupe: "unique", - props: { - ...common.props, - viewId: { - propDefinition: [ - common.props.grain, - "viewId", - () => ({ - type: "stories", - }), - ], - }, - }, + // Grain does not document a delivery ID; deduping by resource ID would discard later updates. methods: { ...common.methods, - getAction() { - return [ - "updated", - ]; + getHookType() { + return "story_updated"; + }, + getTimestamp({ data }) { + const ts = Date.parse(data.last_edited_datetime); + return Number.isNaN(ts) + ? Date.now() + : ts; }, getSummary({ data }) { return `New story updated: ${data.id}`; diff --git a/scripts/tests/grain.test.mjs b/scripts/tests/grain.test.mjs new file mode 100644 index 0000000000000..a99e358e34431 --- /dev/null +++ b/scripts/tests/grain.test.mjs @@ -0,0 +1,401 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import grain from "../../components/grain/grain.app.mjs"; +import newRecording from "../../components/grain/sources/new-recording-instant/new-recording-instant.mjs"; +import updatedRecording from "../../components/grain/sources/updated-recording-instant/updated-recording-instant.mjs"; +import removedRecording from "../../components/grain/sources/removed-recording-instant/removed-recording-instant.mjs"; +import newHighlight from "../../components/grain/sources/new-highlight-instant/new-highlight-instant.mjs"; +import updatedHighlight from "../../components/grain/sources/updated-highlight-instant/updated-highlight-instant.mjs"; +import removedHighlight from "../../components/grain/sources/removed-highlight-instant/removed-highlight-instant.mjs"; +import newStory from "../../components/grain/sources/new-story-instant/new-story-instant.mjs"; +import updatedStory from "../../components/grain/sources/updated-story-instant/updated-story-instant.mjs"; +import removedStory from "../../components/grain/sources/removed-story-instant/removed-story-instant.mjs"; +import listRecordings from "../../components/grain/actions/list-recordings/list-recordings.mjs"; +import getRecording from "../../components/grain/actions/get-recording/get-recording.mjs"; + +// Run from the repository root: node --test scripts/tests/grain.test.mjs + +const SOURCES = [ + { + source: newRecording, + event: "recording_added", + }, + { + source: updatedRecording, + event: "recording_updated", + }, + { + source: removedRecording, + event: "recording_deleted", + }, + { + source: newHighlight, + event: "highlight_added", + }, + { + source: updatedHighlight, + event: "highlight_updated", + }, + { + source: removedHighlight, + event: "highlight_deleted", + }, + { + source: newStory, + event: "story_added", + }, + { + source: updatedStory, + event: "story_updated", + }, + { + source: removedStory, + event: "story_deleted", + }, +]; + +function instantiate(source, props = {}) { + const emitted = []; + const db = new Map(); + return { + ...source.methods, + db, + emitted, + http: { + endpoint: "https://example.com/hook", + }, + $emit(body, metadata) { + emitted.push({ + body, + metadata, + }); + }, + ...props, + }; +} + +for (const { + source, event, +} of SOURCES) { + test(`${event}: subscribes to v2, emits its fixture, and removes its hook`, async () => { + const requests = []; + const deleted = []; + const instance = instantiate(source, { + grain: { + async createWebhook(request) { + requests.push(request.data); + return { + id: "hook-123", + }; + }, + async deleteWebhook(id) { + deleted.push(id); + }, + }, + }); + assert.equal(source.version, "1.0.0"); + assert.equal(source.props.viewId, undefined); + await source.hooks.deactivate.call(instance); + assert.equal(deleted.length, 0); + await source.hooks.activate.call(instance); + assert.equal(requests[0].hook_type, event); + assert.equal(requests[0].hook_url, instance.http.endpoint); + assert.equal(requests[0].view_id, undefined); + assert.equal(requests[0].actions, undefined); + assert.equal(instance.db.get("hookId"), "hook-123"); + if (event.startsWith("story_") || event.endsWith("_deleted")) { + assert.equal(requests[0].include, undefined); + } + + const before = Date.now(); + await source.run.call(instance, { + body: source.sampleEmit, + }); + const after = Date.now(); + assert.equal(source.sampleEmit.type, event); + assert.equal(instance.emitted.length, 1); + assert.equal(instance.emitted[0].body, source.sampleEmit); + const { metadata } = instance.emitted[0]; + assert.equal(metadata.id, source.sampleEmit.data.id); + assert.ok(metadata.summary.includes(source.sampleEmit.data.id)); + if (event.endsWith("_added")) { + const data = source.sampleEmit.data; + assert.equal(metadata.ts, Date.parse(data.end_datetime ?? data.created_datetime)); + } else if (event === "story_updated") { + assert.equal(metadata.ts, Date.parse(source.sampleEmit.data.last_edited_datetime)); + } else { + assert.ok(metadata.ts >= before && metadata.ts <= after); + } + + if (event.endsWith("_updated")) { + // No resource-ID deduplication: successive edits and retries must reach the workflow. + assert.equal(source.dedupe, undefined); + for (const body of [ + source.sampleEmit, + { + ...source.sampleEmit, + data: { + ...source.sampleEmit.data, + title: "Another edit", + }, + }, + ]) { + await source.run.call(instance, { + body, + }); + } + assert.equal(instance.emitted.length, 3); + } else { + assert.equal(source.dedupe, "unique"); + } + await source.hooks.deactivate.call(instance); + assert.deepEqual(deleted, [ + "hook-123", + ]); + }); +} + +test("sources ignore reachability probes, malformed payloads, and other hook types", async () => { + for (const { source } of SOURCES) { + const instance = instantiate(source); + for (const body of [ + undefined, + null, + {}, + { + data: {}, + }, + { + ...source.sampleEmit, + type: "upload_status", + }, + ]) { + await source.run.call(instance, { + body, + }); + } + assert.equal(instance.emitted.length, 0); + } +}); + +test("events with missing or invalid timestamps fall back to receipt time", () => { + for (const { + source, event, + } of SOURCES.filter(({ event }) => event.endsWith("_added") || event === "story_updated")) { + const instance = instantiate(source); + for (const value of [ + undefined, + "invalid", + ]) { + const before = Date.now(); + const ts = instance.getTimestamp({ + data: { + [event === "recording_added" + ? "end_datetime" + : event === "story_updated" + ? "last_edited_datetime" + : "created_datetime"]: value, + }, + }); + assert.ok(ts >= before && ts <= Date.now()); + } + } +}); + +test("recording and highlight hooks send only enabled include options", () => { + for (const { + source, event, + } of SOURCES.filter(({ event }) => !event.endsWith("_deleted"))) { + const instance = instantiate(source, { + highlights: true, + participants: false, + aiSummary: true, + calendarEvent: true, + transcript: true, + speakers: false, + }); + if (event.startsWith("recording_")) { + assert.deepEqual(instance.getInclude(), { + highlights: true, + calendar_event: true, + ai_summary: true, + }); + } else if (event.startsWith("highlight_")) { + assert.deepEqual(instance.getInclude(), { + transcript: true, + }); + } + } +}); + +test("failed hook creation does not store a hook ID", async () => { + const { source } = SOURCES[0]; + const instance = instantiate(source, { + grain: { + async createWebhook() { + throw new Error("Hook creation failed"); + }, + }, + }); + await assert.rejects(source.hooks.activate.call(instance), /Hook creation failed/); + assert.equal(instance.db.get("hookId"), undefined); +}); + +test("transcript formats use the documented routes and preserve request context", () => { + const $ = {}; + const instance = { + _makeRequest: (request) => request, + }; + for (const format of [ + "json", + "txt", + "vtt", + "srt", + ]) { + const request = grain.methods.fetchTranscript.call(instance, { + $, + recordingId: "recording-123", + format, + }); + assert.equal(request.path, format === "json" + ? "/recordings/recording-123/transcript" + : `/recordings/recording-123/transcript.${format}`); + assert.equal(request.$, $); + } +}); + +test("list recordings follows cursors, forwards filters, and respects the result limit", async () => { + const requests = []; + const summaries = []; + const pages = [ + { + recordings: [ + { + id: "1", + }, + { + id: "2", + }, + ], + cursor: "page-2", + }, + { + recordings: [ + { + id: "3", + }, + { + id: "4", + }, + ], + cursor: "page-3", + }, + ]; + const $ = { + export: (key, value) => summaries.push([ + key, + value, + ]), + }; + const result = await listRecordings.run.call({ + titleSearch: "All Hands", + maxResults: 3, + grain: { + async listRecordings(request) { + assert.equal(request.$, $); + requests.push(request.data); + return pages.shift(); + }, + }, + }, { + $, + }); + assert.deepEqual(result.map(({ id }) => id), [ + "1", + "2", + "3", + ]); + assert.equal(requests.length, 2); + assert.equal(requests[0].cursor, undefined); + assert.equal(requests[1].cursor, "page-2"); + assert.deepEqual(JSON.parse(JSON.stringify(requests[1].filter)), { + title_search: "All Hands", + }); + assert.deepEqual(summaries, [ + [ + "$summary", + "Successfully fetched 3 recordings", + ], + ]); +}); + +test("list recordings returns an empty array and summary when no recordings match", async () => { + const summaries = []; + const result = await listRecordings.run.call({ + maxResults: 100, + grain: { + async listRecordings() { + return { + recordings: [], + cursor: null, + }; + }, + }, + }, { + $: { + export: (key, value) => summaries.push([ + key, + value, + ]), + }, + }); + assert.deepEqual(result, []); + assert.deepEqual(summaries, [ + [ + "$summary", + "Successfully fetched 0 recordings", + ], + ]); +}); + +test("get recording forwards enabled include flags, returns the response, and exports a summary", async () => { + const response = { + id: "recording-123", + title: "All Hands", + }; + const summaries = []; + const $ = { + export: (key, value) => summaries.push([ + key, + value, + ]), + }; + const result = await getRecording.run.call({ + recordingId: response.id, + aiSummary: true, + participants: false, + screenshares: true, + grain: { + async fetchRecording(request) { + assert.equal(request.$, $); + assert.equal(request.recordingId, response.id); + assert.deepEqual(request.data, { + include: { + ai_summary: true, + screenshares: true, + }, + }); + return response; + }, + }, + }, { + $, + }); + assert.equal(result, response); + assert.deepEqual(summaries, [ + [ + "$summary", + "Successfully fetched recording with ID recording-123", + ], + ]); +});