diff --git a/components/grain/actions/download-recording/download-recording.mjs b/components/grain/actions/download-recording/download-recording.mjs new file mode 100644 index 0000000000000..7aa53046f7810 --- /dev/null +++ b/components/grain/actions/download-recording/download-recording.mjs @@ -0,0 +1,64 @@ +import fs from "fs"; +import grain from "../../grain.app.mjs"; + +const EXTENSION_BY_CONTENT_TYPE = { + "video/mp4": "mp4", + "video/quicktime": "mov", + "audio/mpeg": "mp3", + "audio/mp4": "m4a", +}; +const DEFAULT_EXTENSION = "mp4"; + +export default { + key: "grain-download-recording", + name: "Download Recording", + description: "Downloads a Grain recording's media file (video or audio) and writes it to File Stash, returning `filePath`, `filename`, and `contentType`." + + " Only recordings with processed media have a downloadable file — check `media_type` (`video` or `audio`, not `transcript`) from **List Recordings** or **Get Recording** first." + + " Use **Get Transcript** instead if you only need the spoken content, not the media file itself." + + " Example: `recordingId: \"8a089fcb-0961-4393-8da2-f0db5f8cfd79\"` downloads the file and returns `{\"filePath\": \"/tmp/grain-recording-8a089fcb-....mp4\", \"filename\": \"grain-recording-8a089fcb-....mp4\", \"contentType\": \"video/mp4\"}`." + + " [See the documentation](https://developers.grain.com/#download-recording)", + version: "0.0.1", + ai: "optimized", + annotations: { + destructiveHint: false, + openWorldHint: true, + readOnlyHint: true, + }, + type: "action", + props: { + grain, + recordingId: { + propDefinition: [ + grain, + "recordingId", + ], + }, + syncDir: { + type: "dir", + accessMode: "write", + sync: true, + }, + }, + async run({ $ }) { + const response = await this.grain.downloadRecording({ + $, + recordingId: this.recordingId, + responseType: "arraybuffer", + returnFullResponse: true, + }); + + const contentType = response.headers["content-type"]; + const extension = EXTENSION_BY_CONTENT_TYPE[contentType] ?? DEFAULT_EXTENSION; + const filename = `grain-recording-${this.recordingId}.${extension}`; + const filePath = `${process.env.STASH_DIR || "/tmp"}/${filename}`; + + fs.writeFileSync(filePath, Buffer.from(response.data)); + + $.export("$summary", `Downloaded recording ${this.recordingId} (${filename})`); + return { + filePath, + filename, + contentType, + }; + }, +}; diff --git a/components/grain/actions/get-recording/get-recording.mjs b/components/grain/actions/get-recording/get-recording.mjs index 5bf1022311a1c..a910c31e16108 100644 --- a/components/grain/actions/get-recording/get-recording.mjs +++ b/components/grain/actions/get-recording/get-recording.mjs @@ -6,8 +6,10 @@ export default { 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." + + " Example: `recordingId: \"pppp6666-qq77-rr88-ss99-tttt00000000\"` with `aiSummary: true` returns the recording's metadata plus `{\"ai_summary\": {\"text\": \"...\"}}`." + " [See the documentation](https://developers.grain.com/#get-recording)", - version: "1.0.0", + version: "1.0.1", + ai: "optimized", annotations: { destructiveHint: false, openWorldHint: true, diff --git a/components/grain/actions/get-transcript/get-transcript.mjs b/components/grain/actions/get-transcript/get-transcript.mjs index a210fcba29e28..9bd0723bc2713 100644 --- a/components/grain/actions/get-transcript/get-transcript.mjs +++ b/components/grain/actions/get-transcript/get-transcript.mjs @@ -8,8 +8,10 @@ export default { + " 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." + + " Example: `recordingId: \"pppp6666-qq77-rr88-ss99-tttt00000000\"` with `format: \"txt\"` returns plain text like `\"Speaker 1: Thanks for joining today...\"`." + " [See the documentation](https://developers.grain.com/#get-recording-transcript-json)", - version: "0.0.1", + version: "0.0.2", + ai: "optimized", annotations: { destructiveHint: false, openWorldHint: true, diff --git a/components/grain/actions/list-meeting-types/list-meeting-types.mjs b/components/grain/actions/list-meeting-types/list-meeting-types.mjs new file mode 100644 index 0000000000000..0dc74e78714bf --- /dev/null +++ b/components/grain/actions/list-meeting-types/list-meeting-types.mjs @@ -0,0 +1,31 @@ +import grain from "../../grain.app.mjs"; + +export default { + key: "grain-list-meeting-types", + name: "List Meeting Types", + description: "Lists the meeting types configured in your Grain workspace (id, name, scope), where scope is `internal` or `external`." + + " Use this to understand how recordings are categorized, or to resolve a meeting type's ID for filtering." + + " Example: returns `[{\"id\": \"e8b894a9-7ecf-4330-a282-527c085618fd\", \"name\": \"Sales\", \"scope\": \"external\"}, {\"id\": \"815747d1-9e25-40f6-a3b8-e1908e25151e\", \"name\": \"1:1s\", \"scope\": \"internal\"}]`." + + " [See the documentation](https://developers.grain.com/#list-meeting-types)", + version: "0.0.1", + ai: "optimized", + annotations: { + destructiveHint: false, + openWorldHint: true, + readOnlyHint: true, + }, + type: "action", + props: { + grain, + }, + async run({ $ }) { + const { meeting_types: meetingTypes } = await this.grain.listMeetingTypes({ + $, + }); + + $.export("$summary", `Found ${meetingTypes.length} meeting type${meetingTypes.length === 1 + ? "" + : "s"}`); + return meetingTypes; + }, +}; diff --git a/components/grain/actions/list-recordings/list-recordings.mjs b/components/grain/actions/list-recordings/list-recordings.mjs index b09e5570ec314..5ae7e5d700876 100644 --- a/components/grain/actions/list-recordings/list-recordings.mjs +++ b/components/grain/actions/list-recordings/list-recordings.mjs @@ -1,13 +1,18 @@ +import { ConfigurationError } from "@pipedream/platform"; 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." + description: "Lists Grain recordings, optionally filtered by start datetime range (ISO8601), title search, participant scope, team, or meeting type." + " Automatically paginates and returns up to Max Results recordings." + " Use this to find recording IDs for **Get Recording** and **Get Transcript**." + + " Example: `titleSearch: \"Acme\"` returns recordings like" + + " `[{\"id\": \"pppp6666-qq77-rr88-ss99-tttt00000000\", \"title\": \"Acme Renewal Call\", \"start_datetime\": \"2026-01-05T15:00:00Z\", \"media_type\": \"video\", ...}]`." + + " Pass `fields` to return only the fields you need instead of the full object." + " [See the documentation](https://developers.grain.com/#list-recordings)", - version: "0.0.1", + version: "0.1.0", + ai: "optimized", annotations: { destructiveHint: false, openWorldHint: true, @@ -19,13 +24,15 @@ export default { beforeDatetime: { type: "string", label: "Before Datetime", - description: "Only return recordings that started before this ISO8601 datetime. E.g. `2025-01-01T00:00:00Z`", + description: "Only return recordings that started before this ISO8601 datetime. E.g. `2025-01-01T00:00:00Z`." + + " Verified against the live API to filter at day granularity — a cutoff earlier or later than a recording's calendar day works reliably, but a same-day cutoff may not exclude recordings from later that same day.", 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`", + description: "Only return recordings that started after this ISO8601 datetime. E.g. `2025-01-01T00:00:00Z`." + + " Verified against the live API to filter at day granularity — a cutoff earlier or later than a recording's calendar day works reliably, but a same-day cutoff may not exclude recordings from earlier that same day.", optional: true, }, titleSearch: { @@ -44,6 +51,18 @@ export default { ], optional: true, }, + team: { + type: "string", + label: "Team ID", + description: "Only return recordings belonging to this team. Use **List Teams** to find team IDs. E.g. `a414c333-c9fe-4fdc-9131-fb31796699b2`.", + optional: true, + }, + meetingType: { + type: "string", + label: "Meeting Type ID", + description: "Only return recordings with this meeting type. Use **List Meeting Types** to find meeting type IDs. E.g. `a97a9e83-c45e-4a46-9b1e-216ce1e69252`.", + optional: true, + }, maxResults: { type: "integer", label: "Max Results", @@ -51,14 +70,42 @@ export default { optional: true, default: 100, min: 1, + max: 500, + }, + fields: { + type: "string[]", + label: "Fields", + description: "Only include these fields in each returned recording (e.g. `[\"id\", \"title\", \"start_datetime\"]`)." + + " Omit fields to return the full recording object for each result.", + optional: true, }, }, async run({ $ }) { + let fields = this.fields; + if (typeof fields === "string") { + try { + fields = JSON.parse(fields); + } catch { + throw new ConfigurationError("`fields` must be a JSON array of field names."); + } + } + if ( + fields !== undefined + && ( + !Array.isArray(fields) + || fields.some((field) => typeof field !== "string" || !field) + ) + ) { + throw new ConfigurationError("`fields` must be an array of non-empty field names."); + } + const filter = { before_datetime: this.beforeDatetime, after_datetime: this.afterDatetime, title_search: this.titleSearch, participant_scope: this.participantScope, + team: this.team, + meeting_type: this.meetingType, }; const recordings = []; @@ -84,6 +131,15 @@ export default { $.export("$summary", `Successfully fetched ${recordings.length} recording${recordings.length === 1 ? "" : "s"}`); - return recordings; + + if (!fields?.length) { + return recordings; + } + return recordings.map((recording) => Object.fromEntries( + fields.map((field) => [ + field, + recording[field], + ]), + )); }, }; diff --git a/components/grain/actions/list-teams/list-teams.mjs b/components/grain/actions/list-teams/list-teams.mjs new file mode 100644 index 0000000000000..b09e4e8e143d8 --- /dev/null +++ b/components/grain/actions/list-teams/list-teams.mjs @@ -0,0 +1,31 @@ +import grain from "../../grain.app.mjs"; + +export default { + key: "grain-list-teams", + name: "List Teams", + description: "Lists the teams in your Grain workspace (id, name)." + + " Use this to resolve a team's ID before sharing a recording with it via **Manage Recording Sharing**." + + " Example: returns `[{\"id\": \"a414c333-c9fe-4fdc-9131-fb31796699b2\", \"name\": \"Pipedream\"}]`." + + " [See the documentation](https://developers.grain.com/#list-teams)", + version: "0.0.1", + ai: "optimized", + annotations: { + destructiveHint: false, + openWorldHint: true, + readOnlyHint: true, + }, + type: "action", + props: { + grain, + }, + async run({ $ }) { + const { teams } = await this.grain.listTeams({ + $, + }); + + $.export("$summary", `Found ${teams.length} team${teams.length === 1 + ? "" + : "s"}`); + return teams; + }, +}; diff --git a/components/grain/actions/list-users/list-users.mjs b/components/grain/actions/list-users/list-users.mjs new file mode 100644 index 0000000000000..332377f92aa6c --- /dev/null +++ b/components/grain/actions/list-users/list-users.mjs @@ -0,0 +1,31 @@ +import grain from "../../grain.app.mjs"; + +export default { + key: "grain-list-users", + name: "List Users", + description: "Lists the users in your Grain workspace (id, name, email)." + + " Use this to resolve a user's ID before sharing a recording with them via **Manage Recording Sharing**, or to identify who's who when reviewing recording participants." + + " Example: returns `[{\"id\": \"d91b7ed0-a149-425c-9623-0664148e4fc1\", \"name\": \"Danny Archer\", \"email\": \"darcher@pipedream.com\"}]`." + + " [See the documentation](https://developers.grain.com/#list-users)", + version: "0.0.1", + ai: "optimized", + annotations: { + destructiveHint: false, + openWorldHint: true, + readOnlyHint: true, + }, + type: "action", + props: { + grain, + }, + async run({ $ }) { + const { users } = await this.grain.listUsers({ + $, + }); + + $.export("$summary", `Found ${users.length} user${users.length === 1 + ? "" + : "s"}`); + return users; + }, +}; diff --git a/components/grain/actions/manage-recording-sharing/manage-recording-sharing.mjs b/components/grain/actions/manage-recording-sharing/manage-recording-sharing.mjs new file mode 100644 index 0000000000000..a199abe295db4 --- /dev/null +++ b/components/grain/actions/manage-recording-sharing/manage-recording-sharing.mjs @@ -0,0 +1,83 @@ +import grain from "../../grain.app.mjs"; + +export default { + key: "grain-manage-recording-sharing", + name: "Manage Recording Sharing", + description: "Shares or unshares a recording with a specific user or team." + + " Use **List Recordings** to find the recording's ID, and **List Users** or **List Teams** to resolve the target's ID." + + " Set `operation` to `share` to grant access or `unshare` to revoke it, and `targetType` to `user` or `team`." + + " Example: `recordingId: \"pppp6666-qq77-rr88-ss99-tttt00000000\", operation: \"share\", targetType: \"user\", targetId: \"d91b7ed0-a149-425c-9623-0664148e4fc1\"` shares the recording with that user and returns `{\"success\": true}`." + + " [See the documentation](https://developers.grain.com/#share-recording-to-a-team)", + version: "0.0.1", + ai: "optimized", + annotations: { + destructiveHint: false, + openWorldHint: true, + readOnlyHint: false, + }, + type: "action", + props: { + grain, + recordingId: { + propDefinition: [ + grain, + "recordingId", + ], + }, + operation: { + type: "string", + label: "Operation", + description: "Whether to share or unshare the recording.", + options: [ + "share", + "unshare", + ], + }, + targetType: { + type: "string", + label: "Target Type", + description: "Whether the target is a user or a team.", + options: [ + "user", + "team", + ], + }, + targetId: { + type: "string", + label: "Target ID", + description: "The ID of the user (from **List Users**) or team (from **List Teams**) to share or unshare the recording with. E.g. `d91b7ed0-a149-425c-9623-0664148e4fc1`.", + }, + }, + async run({ $ }) { + if (this.operation === "share") { + await this.grain.shareRecording({ + $, + recordingId: this.recordingId, + targetType: this.targetType, + data: { + [`${this.targetType}_id`]: this.targetId, + }, + }); + } else { + await this.grain.unshareRecording({ + $, + recordingId: this.recordingId, + targetType: this.targetType, + targetId: this.targetId, + }); + } + + const summary = `${this.operation === "share" + ? "Shared" + : "Unshared"} recording ${this.recordingId} ${this.operation === "share" + ? "with" + : "from"} ${this.targetType} ${this.targetId}`; + $.export("$summary", summary); + return { + recordingId: this.recordingId, + operation: this.operation, + targetType: this.targetType, + targetId: this.targetId, + }; + }, +}; diff --git a/components/grain/actions/update-recording/update-recording.mjs b/components/grain/actions/update-recording/update-recording.mjs new file mode 100644 index 0000000000000..60e186ad5f657 --- /dev/null +++ b/components/grain/actions/update-recording/update-recording.mjs @@ -0,0 +1,117 @@ +import { ConfigurationError } from "@pipedream/platform"; +import grain from "../../grain.app.mjs"; + +export default { + key: "grain-update-recording", + name: "Update Recording", + description: "Renames a recording and/or adds and removes tags on it. All fields are optional — pass only what you want to change." + + " Use **List Recordings** first to find the recording's ID." + + " Example: to retitle a recording and tag it, pass `title` and `addTags: [\"qa-verified\"]` together." + + " [See the documentation](https://developers.grain.com/#update-recording)", + version: "0.0.1", + ai: "optimized", + annotations: { + destructiveHint: false, + openWorldHint: true, + readOnlyHint: false, + }, + type: "action", + props: { + grain, + recordingId: { + propDefinition: [ + grain, + "recordingId", + ], + }, + title: { + type: "string", + label: "Title", + description: "New title for the recording. Leave blank to keep the current title.", + optional: true, + }, + addTags: { + type: "string[]", + label: "Add Tags", + description: "Tags to add to the recording. Example: `[\"qa-verified\", \"customer-call\"]`.", + optional: true, + }, + removeTags: { + type: "string[]", + label: "Remove Tags", + description: "Tags to remove from the recording.", + optional: true, + }, + }, + async run({ $ }) { + const parseTags = (value, propName) => { + let tags = value; + if (typeof tags === "string") { + try { + tags = JSON.parse(tags); + } catch { + throw new ConfigurationError(`\`${propName}\` must be a JSON array of tag names.`); + } + } + if ( + tags !== undefined + && ( + !Array.isArray(tags) + || tags.some((tag) => typeof tag !== "string" || !tag) + ) + ) { + throw new ConfigurationError(`\`${propName}\` must be an array of non-empty tag names.`); + } + return tags; + }; + + const addTags = parseTags(this.addTags, "addTags"); + const removeTags = parseTags(this.removeTags, "removeTags"); + + const actions = []; + + if (this.title) { + await this.grain.updateRecordingTitle({ + $, + recordingId: this.recordingId, + data: { + title: this.title, + }, + }); + actions.push(`renamed to "${this.title}"`); + } + + for (const tag of addTags ?? []) { + await this.grain.addRecordingTag({ + $, + recordingId: this.recordingId, + data: { + tag, + }, + }); + } + if (addTags?.length) { + actions.push(`added tag(s) [${addTags.join(", ")}]`); + } + + for (const tag of removeTags ?? []) { + await this.grain.removeRecordingTag({ + $, + recordingId: this.recordingId, + tag, + }); + } + if (removeTags?.length) { + actions.push(`removed tag(s) [${removeTags.join(", ")}]`); + } + + const summary = actions.length + ? `Updated recording ${this.recordingId}: ${actions.join("; ")}` + : `No changes requested for recording ${this.recordingId}`; + $.export("$summary", summary); + return { + recordingId: this.recordingId, + actions, + }; + }, +}; diff --git a/components/grain/grain.app.mjs b/components/grain/grain.app.mjs index 0385f56a6ed85..b854f76f9f0d9 100644 --- a/components/grain/grain.app.mjs +++ b/components/grain/grain.app.mjs @@ -8,26 +8,6 @@ export default { type: "string", 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({ - data: { - cursor: prevContext?.nextPage, - }, - }); - return { - options: recordings.map(({ - id: value, title: label, - }) => ({ - value, - label, - })), - context: { - nextPage: cursor, - }, - }; - }, }, highlights: { type: "boolean", @@ -138,6 +118,128 @@ export default { ...opts, }); }, + /** + * List workspace users. + * @param {object} [opts={}] Request context. + * @returns {Promise} The workspace's users. + */ + listUsers(opts = {}) { + return this._makeRequest({ + method: "POST", + path: "/users", + ...opts, + }); + }, + /** + * List workspace teams. + * @param {object} [opts={}] Request context. + * @returns {Promise} The workspace's teams. + */ + listTeams(opts = {}) { + return this._makeRequest({ + method: "POST", + path: "/teams", + ...opts, + }); + }, + /** + * List configured meeting types. + * @param {object} [opts={}] Request context. + * @returns {Promise} The workspace's meeting types. + */ + listMeetingTypes(opts = {}) { + return this._makeRequest({ + method: "POST", + path: "/meeting_types", + ...opts, + }); + }, + /** + * Rename a recording. + * @param {object} opts Request context, recordingId, and data containing the new title. + * @returns {Promise} The updated recording. + */ + updateRecordingTitle({ + recordingId, ...opts + }) { + return this._makeRequest({ + method: "PATCH", + path: `/recordings/${recordingId}`, + ...opts, + }); + }, + /** + * Add a tag to a recording. + * @param {object} opts Request context, recordingId, and data containing the tag. + * @returns {Promise} The API's success response. + */ + addRecordingTag({ + recordingId, ...opts + }) { + return this._makeRequest({ + method: "PUT", + path: `/recordings/${recordingId}/tags`, + ...opts, + }); + }, + /** + * Remove a tag from a recording. + * @param {object} opts Request context, recordingId, and tag. + * @returns {Promise} The API's success response. + */ + removeRecordingTag({ + recordingId, tag, ...opts + }) { + return this._makeRequest({ + method: "DELETE", + path: `/recordings/${recordingId}/tags/${encodeURIComponent(tag)}`, + ...opts, + }); + }, + /** + * Share a recording with a user or team. + * @param {object} opts Request context, recordingId, targetType (user or team), and data + * containing the target's ID under `user_id` or `team_id`. + * @returns {Promise} The API's success response. + */ + shareRecording({ + recordingId, targetType, ...opts + }) { + return this._makeRequest({ + method: "PUT", + path: `/recordings/${recordingId}/${targetType}s`, + ...opts, + }); + }, + /** + * Unshare a recording from a user or team. + * @param {object} opts Request context, recordingId, targetType (user or team), and targetId. + * @returns {Promise} The API's success response. + */ + unshareRecording({ + recordingId, targetType, targetId, ...opts + }) { + return this._makeRequest({ + method: "DELETE", + path: `/recordings/${recordingId}/${targetType}s/${targetId}`, + ...opts, + }); + }, + /** + * Download a recording's media file. + * @param {object} opts Request context and recordingId. Pass `responseType: "arraybuffer"` + * and `returnFullResponse: true` to receive the raw binary and headers. + * @returns {Promise} The recording's media file. + */ + downloadRecording({ + recordingId, ...opts + }) { + return this._makeRequest({ + method: "GET", + path: `/recordings/${recordingId}/download`, + ...opts, + }); + }, /** * Register a webhook for a Grain event type. * @param {object} [opts={}] Request options with hook_url, hook_type, and include in data. diff --git a/components/grain/package.json b/components/grain/package.json index 2dcaeccf01505..43afbaa5bd2f4 100644 --- a/components/grain/package.json +++ b/components/grain/package.json @@ -1,6 +1,6 @@ { "name": "@pipedream/grain", - "version": "1.0.0", + "version": "1.1.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 4c72096c12e1a..97c0ce9dbcc0d 100644 --- a/components/grain/sources/common/base.mjs +++ b/components/grain/sources/common/base.mjs @@ -49,16 +49,9 @@ export default { if (!body?.data?.id || body.type !== this.getHookType()) return; const ts = this.getTimestamp(body); - // Grain doesn't document a delivery ID. Added/deleted events dedupe on the - // resource ID alone (there's only ever one). Updated events concatenate the - // resource ID with the payload-derived timestamp so retries of the same - // update share an ID while a later, distinct update gets a new one. - const id = body.type.endsWith("_updated") - ? `${body.data.id}:${ts}` - : body.data.id; this.$emit(body, { - id, + id: body.data.id, summary: this.getSummary(body), ts, }); 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 a9d4e4bb7e1b4..f684181287dcb 100644 --- a/components/grain/sources/new-highlight-instant/new-highlight-instant.mjs +++ b/components/grain/sources/new-highlight-instant/new-highlight-instant.mjs @@ -6,7 +6,7 @@ export default { key: "grain-new-highlight-instant", name: "New Highlight (Instant)", description: "Emit new event when a highlight is added. [See the documentation](https://developers.grain.com/#create-hook)", - version: "1.0.0", + version: "1.0.1", type: "source", dedupe: "unique", methods: { 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 94d31122908f1..d694e643c5c35 100644 --- a/components/grain/sources/new-recording-instant/new-recording-instant.mjs +++ b/components/grain/sources/new-recording-instant/new-recording-instant.mjs @@ -6,7 +6,7 @@ export default { key: "grain-new-recording-instant", name: "New Recording (Instant)", description: "Emit new event when a recording is added. [See the documentation](https://developers.grain.com/#create-hook)", - version: "1.0.0", + version: "1.0.1", type: "source", dedupe: "unique", methods: { 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 034ad329ada3f..d030e8d5631f9 100644 --- a/components/grain/sources/new-story-instant/new-story-instant.mjs +++ b/components/grain/sources/new-story-instant/new-story-instant.mjs @@ -6,7 +6,7 @@ export default { key: "grain-new-story-instant", name: "New Story (Instant)", description: "Emit new event when a story is added. [See the documentation](https://developers.grain.com/#create-hook)", - version: "1.0.0", + version: "1.0.1", type: "source", dedupe: "unique", methods: { 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 e7d6227f0e679..cd73cf95c4903 100644 --- a/components/grain/sources/removed-highlight-instant/removed-highlight-instant.mjs +++ b/components/grain/sources/removed-highlight-instant/removed-highlight-instant.mjs @@ -6,7 +6,7 @@ export default { key: "grain-removed-highlight-instant", name: "New Highlight Removed (Instant)", description: "Emit new event when a highlight is removed. [See the documentation](https://developers.grain.com/#create-hook)", - version: "1.0.0", + version: "1.0.1", type: "source", dedupe: "unique", methods: { 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 16a05df98c2a1..00a6a12546749 100644 --- a/components/grain/sources/removed-recording-instant/removed-recording-instant.mjs +++ b/components/grain/sources/removed-recording-instant/removed-recording-instant.mjs @@ -6,7 +6,7 @@ export default { key: "grain-removed-recording-instant", name: "New Recording Removed (Instant)", description: "Emit new event when a recording is removed. [See the documentation](https://developers.grain.com/#create-hook)", - version: "1.0.0", + version: "1.0.1", type: "source", dedupe: "unique", methods: { 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 78913a0b39bed..7cb874c8b5cd8 100644 --- a/components/grain/sources/removed-story-instant/removed-story-instant.mjs +++ b/components/grain/sources/removed-story-instant/removed-story-instant.mjs @@ -6,7 +6,7 @@ export default { key: "grain-removed-story-instant", name: "New Story Removed (Instant)", description: "Emit new event when a story is removed. [See the documentation](https://developers.grain.com/#create-hook)", - version: "1.0.0", + version: "1.0.1", type: "source", dedupe: "unique", methods: { 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 d81399abfef06..d2009f6189f0d 100644 --- a/components/grain/sources/updated-highlight-instant/updated-highlight-instant.mjs +++ b/components/grain/sources/updated-highlight-instant/updated-highlight-instant.mjs @@ -5,10 +5,9 @@ export default { ...common, key: "grain-updated-highlight-instant", name: "New Highlight Updated (Instant)", - 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", + description: "Emit new event when a highlight is updated. Deduplicates retried webhook deliveries of the same update; each distinct update still emits. [See the documentation](https://developers.grain.com/#create-hook)", + version: "1.1.0", type: "source", - // Grain does not document a delivery ID; deduping by resource ID would discard later updates. methods: { ...common.methods, getHookType() { 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 0a75d448a3482..ee8b4cce8167c 100644 --- a/components/grain/sources/updated-recording-instant/updated-recording-instant.mjs +++ b/components/grain/sources/updated-recording-instant/updated-recording-instant.mjs @@ -5,10 +5,9 @@ export default { ...common, key: "grain-updated-recording-instant", name: "New Recording Updated (Instant)", - 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", + description: "Emit new event when a recording is updated. Deduplicates retried webhook deliveries of the same update; each distinct update still emits. [See the documentation](https://developers.grain.com/#create-hook)", + version: "1.1.0", type: "source", - // Grain does not document a delivery ID; deduping by resource ID would discard later updates. methods: { ...common.methods, getHookType() { 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 1a73bc8bd9b5d..57af1950bb5e8 100644 --- a/components/grain/sources/updated-story-instant/updated-story-instant.mjs +++ b/components/grain/sources/updated-story-instant/updated-story-instant.mjs @@ -5,10 +5,9 @@ export default { ...common, key: "grain-updated-story-instant", name: "New Story Updated (Instant)", - 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", + description: "Emit new event when a story is updated. Deduplicates retried webhook deliveries of the same update; each distinct update still emits. [See the documentation](https://developers.grain.com/#create-hook)", + version: "1.1.0", type: "source", - // Grain does not document a delivery ID; deduping by resource ID would discard later updates. methods: { ...common.methods, getHookType() {