diff --git a/packages/twenty-apps/public/twenty-fireflies/README.md b/packages/twenty-apps/public/twenty-fireflies/README.md index 3834409da1..1ad4fb021b 100644 --- a/packages/twenty-apps/public/twenty-fireflies/README.md +++ b/packages/twenty-apps/public/twenty-fireflies/README.md @@ -1,9 +1,10 @@ # Fireflies for Twenty -Sync [Fireflies](https://fireflies.ai) call transcripts and AI summaries onto -the matching `CalendarEvent` in your Twenty CRM — searchable, in context, and -ready for AI agents and workflows to act on. Plus on-demand workflow tools to -sync, list, and search Fireflies calls from the AI chat or workflow builder. +Sync [Fireflies](https://fireflies.ai) call transcripts and AI summaries into +`CallRecording` records linked to the matching `CalendarEvent` in your Twenty +CRM — searchable, in context, and ready for AI agents and workflows to act +on. Plus on-demand workflow tools to sync, list, and search Fireflies calls +from the AI chat or workflow builder. ## What this app does @@ -13,8 +14,9 @@ sync, list, and search Fireflies calls from the AI chat or workflow builder. `meeting.summarized` webhook. 3. For each event, this app fetches the relevant data via the Fireflies GraphQL API. -4. It finds the matching `CalendarEvent` in Twenty and writes the content - into either the **Transcript** or **Summary** field on that event. +4. It upserts a `CallRecording` record (one per Fireflies call) with the + diarized transcript and the AI summary, linked to the matching + `CalendarEvent` when one is found. Alongside the webhook, three [Workflow tools](#workflow-tools) let you trigger Fireflies actions from the AI chat or as steps inside a workflow, @@ -38,18 +40,22 @@ Both identifiers are populated by Twenty's calendar drivers on every synced CalendarEvent, so any meeting that's been pulled in via Google / Outlook / CalDAV calendar sync will match exactly. The matcher does **not** fall back to fuzzy URL matching — if the transcript can't be tied to a synced calendar -event, the call is treated as an orphan and skipped (see -[Limitations](#limitations) below). This avoids silently writing transcripts -to the wrong event. +event, the CallRecording is still created, just without a linked calendar +event. This avoids silently linking transcripts to the wrong event. ## What gets added to your Twenty workspace -Two new fields on the standard **CalendarEvent** object: +The app writes to the standard **CallRecording** object (no new schema): -- **Transcript** — rich-text field, speaker-attributed (e.g. *"**Sarah:** - Hi there"*, then *"**John:** Doing well, thanks."*). -- **Summary** — rich-text field with the Fireflies AI summary: a bullet-list - overview, action items grouped by speaker, topics discussed, and keywords. +- One CallRecording per Fireflies call, with a deterministic id — the + `meeting.transcribed` webhook, the `meeting.summarized` webhook, and manual + syncs all converge on the same record. +- **Transcript** — the diarized transcript (speaker names and sentence-level + timestamps) stored in the CallRecording `transcript` field. +- **Summary** — the Fireflies AI summary (overview, action items, topics, + keywords) stored as rich text in the CallRecording `summary` field. +- **Title, start/end time, external recording id, and the CalendarEvent + link** filled from the Fireflies call metadata. Plus three workflow tools — see [Workflow tools](#workflow-tools) below. @@ -58,16 +64,16 @@ Plus three workflow tools — see [Workflow tools](#workflow-tools) below. Once the API key is configured, three tools become available in the workflow builder and the AI chat — covering the cases the webhook can't: -- **Sync Fireflies Call** — *"sync the Fireflies call `01HXYZ...` onto its - CalendarEvent now"*. As a workflow step: provide `transcriptId`. Runs the +- **Sync Fireflies Call** — *"sync the Fireflies call `01HXYZ...` into a + CallRecording now"*. As a workflow step: provide `transcriptId`. Runs the same pipeline as the webhook (fetch transcript + AI summary, find matching - CalendarEvent, write Transcript + Summary fields) on demand. Use cases: + CalendarEvent, upsert the CallRecording) on demand. Use cases: **backfilling** historical calls that happened before the app was installed; **recovering** from a missed webhook (e.g. the calendar event hadn't synced yet when Fireflies pushed); or triggering a sync from a workflow instead of waiting for Fireflies. Output includes - `calendarEventId`, `updatedFields`, and a per-field outcome breakdown so - partial successes are visible. + `callRecordingId`, `calendarEventId`, `updatedFields`, and a per-field + outcome breakdown so partial successes are visible. - **List Fireflies Calls By Participant** — *"show me my last 5 calls with john@acme.com"*. As a workflow step: provide `participantEmail` (and optional `limit`, max 50). Returns recent Fireflies calls — newest first — @@ -82,8 +88,8 @@ builder and the AI chat — covering the cases the webhook can't: The list-by-participant and search tools return the same compact call shape: `id`, `title`, `date`, `durationMinutes`, `participants`, `hostEmail`, -`transcriptUrl`, `meetingLink`. To then sync any of those calls onto its -CalendarEvent, pass the `id` from a list result into **Sync Fireflies Call**. +`transcriptUrl`, `meetingLink`. To then sync any of those calls into a +CallRecording, pass the `id` from a list result into **Sync Fireflies Call**. ## Installing @@ -96,24 +102,38 @@ CalendarEvent, pass the `id` from a list result into **Sync Fireflies Call**. > webhook, your Twenty admin needs to follow the > [Self-hosting setup](#self-hosting-setup-admin-only) section. +## Upgrading from 0.1.x + +Version 0.1.x stored transcripts and summaries as two rich-text fields on +CalendarEvent. Upgrading removes those fields **and their stored content** — +recordings now live on the standard CallRecording object instead. + +The removed content is a cache of Fireflies data: any call still available +in Fireflies can be re-ingested as a CallRecording by passing its id to +**Sync Fireflies Call** (find ids with the list / search tools). An +automatic history backfill on install and upgrade is planned as a follow-up. + ## Limitations What this connector intentionally does **not** support in v1: -- **Calls without a matching CalendarEvent (orphan calls).** Ad-hoc calls - that were never on anyone's synced calendar are skipped. The webhook logs - the skip reason; the transcript still lives in Fireflies. Synthetic event - creation for orphans is planned for v2. +- **Audio / video media ingestion.** Fireflies' `audio_url` / `video_url` + are not downloaded yet; the CallRecording holds the transcript and summary + only. Media ingestion is planned for a follow-up. - **Fireflies sentiment, speaker analytics, transcript chapters.** Only the raw transcript and the AI summary (overview, action items, topics, keywords) are synced today. +- **Calls without a matching CalendarEvent (orphan calls).** These are no + longer skipped — the CallRecording is created — but it stays unlinked + until the calendar event syncs; re-running **Sync Fireflies Call** after + the calendar sync fills the link. - **Per-user Fireflies accounts.** All transcripts come through one workspace-shared API key (set by the admin). Per-user OAuth-style connections require extending Twenty's connection provider system and are planned once we have evidence that workspace-shared is too coarse. -- **Editing transcripts or summaries in Twenty.** The fields are writable - but the next Fireflies sync overwrites any manual edits — treat them as - read-only. +- **Editing transcripts or summaries in Twenty.** The CallRecording fields + are writable but the next Fireflies sync overwrites any manual edits — + treat them as read-only. ## Troubleshooting @@ -121,12 +141,12 @@ What this connector intentionally does **not** support in v1: |---|---|---| | Webhook returns `Fireflies is not configured` | `FIREFLIES_API_KEY` not set | Admin: paste the API key in **Settings → Applications → Fireflies → Settings** | | Webhook returns `Invalid webhook signature` | `FIREFLIES_WEBHOOK_SECRET` mismatch between Fireflies and Twenty | Re-copy the signing secret from the Fireflies webhook configuration and paste it into the Twenty app settings | -| Webhook returns `skipped: No CalendarEvent matched the transcript by external ID or iCalUid` | The meeting was never on a synced calendar in Twenty, or the workspace has no Google/Outlook/CalDAV calendar connection set up | Connect the relevant calendar provider in **Settings → Accounts** so the calendar event lands in Twenty with `eventExternalId` and `iCalUid` populated. Manually-created CalendarEvents are intentionally not matched in v1 | +| CallRecording is created but has no linked CalendarEvent | The meeting was never on a synced calendar in Twenty, or the workspace has no Google/Outlook/CalDAV calendar connection set up | Connect the relevant calendar provider in **Settings → Accounts** so the calendar event lands in Twenty with `eventExternalId` and `iCalUid` populated, then re-run **Sync Fireflies Call** for that transcript. Manually-created CalendarEvents are intentionally not matched in v1 | | Transcript appears empty | Fireflies returned no sentences (call too short, audio failed) | Check the call in the Fireflies dashboard; nothing this app can do | | Summary appears empty | Fireflies hasn't summarized the call yet, or the call was too short to summarize | Fireflies sends `meeting.summarized` separately from `meeting.transcribed` (typically a minute or two later); ensure that event is subscribed to in your Webhooks V2 config | | Summary is populated but Transcript isn't (or vice versa) | Only one of the two Fireflies events is subscribed to | Subscribe to both `meeting.transcribed` and `meeting.summarized` in your Fireflies Webhooks V2 configuration | | Fireflies API errors with `401` | API key wrong, rotated, or revoked | Generate a new key in Fireflies → Integrations → Fireflies API → Regenerate, then update `FIREFLIES_API_KEY` | -| **Sync Fireflies Call** reports `No fields were updated` | The Fireflies call's `calendar_id` / `cal_id` doesn't match any CalendarEvent's `iCalUid` or `eventExternalId` (orphan call), or the per-field outcomes show transient Fireflies API failures | Check the `fieldOutcomes` array in the result — `skipped` means orphan call (same limitation as the webhook); `error` means Fireflies-side failure (retry, or inspect the error message) | +| **Sync Fireflies Call** reports `No CallRecording was written` | Fireflies returned no transcript sentences and no summary for the call, or the per-field outcomes show transient Fireflies API failures | Check the `fieldOutcomes` array in the result — `skipped` means Fireflies had no content for that field; `error` means Fireflies-side failure (retry, or inspect the error message) | | **List / Search** tools return `count: 0` for a contact you've definitely talked to | Email mismatch — Fireflies stores the address as the participant joined the meeting with, which may differ from the contact's primary address in Twenty (aliases, plus-addressing, work vs. personal) | Try the contact's other known email addresses; cross-check the `participants` list on a known matching call | --- @@ -162,9 +182,9 @@ supported. `openssl rand -hex 32`). Save it; you'll paste it into Twenty next. 4. Under **Events**, subscribe to **both**: - **`meeting.transcribed`** — fires when the transcript is ready and - writes it to the **Transcript** field. + writes it to the CallRecording's **Transcript** field. - **`meeting.summarized`** — fires once Fireflies finishes its AI summary - and writes it to the **Summary** field. + and writes it to the CallRecording's **Summary** field. Subscribing to only one is fine if you don't want the other field populated; the app dispatches per event. 5. **Save** the configuration. @@ -175,7 +195,8 @@ supported. 2. Paste the Fireflies API key into the `FIREFLIES_API_KEY` row. 3. Paste the signing secret into the `FIREFLIES_WEBHOOK_SECRET` row. -After saving, the next time Fireflies finishes processing a recording, the -transcript will land on the matching CalendarEvent within a few seconds; -the summary follows once Fireflies finishes the AI summarization step -(typically a minute or two later — Fireflies sends two separate webhooks). +After saving, the next time Fireflies finishes processing a recording, a +CallRecording with the transcript will appear (linked to the matching +CalendarEvent) within a few seconds; the summary follows once Fireflies +finishes the AI summarization step (typically a minute or two later — +Fireflies sends two separate webhooks). diff --git a/packages/twenty-apps/public/twenty-fireflies/package.json b/packages/twenty-apps/public/twenty-fireflies/package.json index cd0a93f31f..b7dcff7a29 100644 --- a/packages/twenty-apps/public/twenty-fireflies/package.json +++ b/packages/twenty-apps/public/twenty-fireflies/package.json @@ -1,6 +1,6 @@ { "name": "@twentyhq/twenty-fireflies", - "version": "0.1.0", + "version": "0.2.0", "description": "Fireflies call-transcript connector for Twenty", "license": "MIT", "engines": { @@ -28,8 +28,8 @@ "oxlint": "^0.16.0", "react": "^18.2.0", "react-dom": "^18.2.0", - "twenty-client-sdk": "^2.16.0", - "twenty-sdk": "^2.16.0", + "twenty-client-sdk": "^2.18.0", + "twenty-sdk": "^2.18.0", "typescript": "^5.9.3", "vite-tsconfig-paths": "^4.2.1", "vitest": "^4.0.0" diff --git a/packages/twenty-apps/public/twenty-fireflies/public/gallery/summary-on-calendar-event.png b/packages/twenty-apps/public/twenty-fireflies/public/gallery/summary-on-calendar-event.png deleted file mode 100644 index 3a97276e1e..0000000000 Binary files a/packages/twenty-apps/public/twenty-fireflies/public/gallery/summary-on-calendar-event.png and /dev/null differ diff --git a/packages/twenty-apps/public/twenty-fireflies/public/gallery/transcript-on-calendar-event.png b/packages/twenty-apps/public/twenty-fireflies/public/gallery/transcript-on-calendar-event.png deleted file mode 100644 index d671ed5548..0000000000 Binary files a/packages/twenty-apps/public/twenty-fireflies/public/gallery/transcript-on-calendar-event.png and /dev/null differ diff --git a/packages/twenty-apps/public/twenty-fireflies/src/__tests__/twenty-fireflies.integration-test.ts b/packages/twenty-apps/public/twenty-fireflies/src/__tests__/twenty-fireflies.integration-test.ts index d81520194f..4d0af6b506 100644 --- a/packages/twenty-apps/public/twenty-fireflies/src/__tests__/twenty-fireflies.integration-test.ts +++ b/packages/twenty-apps/public/twenty-fireflies/src/__tests__/twenty-fireflies.integration-test.ts @@ -1,7 +1,12 @@ +import { CoreApiClient } from 'twenty-client-sdk/core'; import { MetadataApiClient } from 'twenty-client-sdk/metadata'; import { describe, expect, it } from 'vitest'; import { APPLICATION_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers'; +import { + CALL_RECORDING_REQUEST_STATUS, + CALL_RECORDING_STATUS, +} from 'src/logic-functions/constants/call-recording-status'; describe('App installation', () => { it('should find the installed Fireflies app in the applications list', async () => { @@ -23,3 +28,86 @@ describe('App installation', () => { expect(matchingApplication).toBeDefined(); }); }); + +// The published twenty-client-sdk types only export CoreApiClient/CoreSchema; +// the enum exports appear at runtime after dev:generate-client regenerates the +// client against the server, hence the cast and the runtime guard. +type GeneratedCoreSchemaRuntime = { + enumCallRecordingStatusEnum?: Record; +}; + +const getServerCallRecordingStatuses = async (): Promise => { + const generatedCoreSchema = (await import( + 'twenty-client-sdk/core' + )) as unknown as GeneratedCoreSchemaRuntime; + const statusEnum = generatedCoreSchema.enumCallRecordingStatusEnum; + + if (statusEnum === undefined) { + throw new Error( + 'enumCallRecordingStatusEnum is missing from twenty-client-sdk/core; regenerate the client against a running server (yarn twenty dev) before the integration suite', + ); + } + + return Object.values(statusEnum); +}; + +describe('CallRecording status contract', () => { + it('should accept every status and request status value the app mirrors', async () => { + const client = new CoreApiClient(); + const serverCallRecordingStatuses = await getServerCallRecordingStatuses(); + + expect(serverCallRecordingStatuses).toEqual( + expect.arrayContaining(Object.values(CALL_RECORDING_STATUS)), + ); + + const created = await client.mutation({ + createCallRecording: { + __args: { + data: { + title: 'Fireflies integration test recording', + status: CALL_RECORDING_STATUS.PROCESSING, + recordingRequestStatus: CALL_RECORDING_REQUEST_STATUS.REQUESTED, + }, + }, + id: true, + status: true, + recordingRequestStatus: true, + }, + }); + + const callRecordingId = created.createCallRecording?.id; + + expect(callRecordingId).toBeDefined(); + expect(created.createCallRecording?.status).toBe( + CALL_RECORDING_STATUS.PROCESSING, + ); + expect(created.createCallRecording?.recordingRequestStatus).toBe( + CALL_RECORDING_REQUEST_STATUS.REQUESTED, + ); + + if (callRecordingId === undefined) { + throw new Error('Expected call recording creation to return an id'); + } + + const updated = await client.mutation({ + updateCallRecording: { + __args: { + id: callRecordingId, + data: { status: CALL_RECORDING_STATUS.COMPLETED }, + }, + status: true, + }, + }); + + expect(updated.updateCallRecording?.status).toBe( + CALL_RECORDING_STATUS.COMPLETED, + ); + + await client.mutation({ + destroyCallRecording: { + __args: { id: callRecordingId }, + id: true, + }, + }); + }); +}); diff --git a/packages/twenty-apps/public/twenty-fireflies/src/application.config.ts b/packages/twenty-apps/public/twenty-fireflies/src/application.config.ts index 06429787b6..af0342f345 100644 --- a/packages/twenty-apps/public/twenty-fireflies/src/application.config.ts +++ b/packages/twenty-apps/public/twenty-fireflies/src/application.config.ts @@ -10,13 +10,11 @@ export default defineApplication({ universalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER, displayName: 'Twenty Fireflies', description: - 'Sync Fireflies call transcripts and AI summaries onto matching CalendarEvent records in Twenty, and trigger sync / list / search of Fireflies calls from workflows and the AI chat.', + 'Sync Fireflies call transcripts and AI summaries into CallRecording records linked to matching CalendarEvents in Twenty, and trigger sync / list / search of Fireflies calls from workflows and the AI chat.', logoUrl: 'public/twenty-fireflies.svg', author: 'Twenty', category: 'Productivity', screenshots: [ - 'public/gallery/transcript-on-calendar-event.png', - 'public/gallery/summary-on-calendar-event.png', 'public/gallery/workflow-builder-actions.png', 'public/gallery/app-settings.png', ], diff --git a/packages/twenty-apps/public/twenty-fireflies/src/constants/universal-identifiers.ts b/packages/twenty-apps/public/twenty-fireflies/src/constants/universal-identifiers.ts index e6d6808a1a..a8adca1945 100644 --- a/packages/twenty-apps/public/twenty-fireflies/src/constants/universal-identifiers.ts +++ b/packages/twenty-apps/public/twenty-fireflies/src/constants/universal-identifiers.ts @@ -10,12 +10,6 @@ export const FIREFLIES_API_KEY_VARIABLE_UNIVERSAL_IDENTIFIER = export const FIREFLIES_WEBHOOK_SECRET_VARIABLE_UNIVERSAL_IDENTIFIER = 'a1ef34d9-f0e7-483e-9909-fce2757bdd23'; -export const TRANSCRIPT_ON_CALENDAR_EVENT_FIELD_UNIVERSAL_IDENTIFIER = - 'e444c83b-0d28-41e2-8c3f-4ede9eb88a75'; - -export const SUMMARY_ON_CALENDAR_EVENT_FIELD_UNIVERSAL_IDENTIFIER = - 'c5f8a3e2-7b91-4d2e-b6a4-9f3e1d5c8a02'; - export const FIREFLIES_WEBHOOK_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER = 'e0c2d033-0ef4-4b92-9e64-2d8f2e4c497e'; diff --git a/packages/twenty-apps/public/twenty-fireflies/src/logic-functions/constants/call-recording-status.ts b/packages/twenty-apps/public/twenty-fireflies/src/logic-functions/constants/call-recording-status.ts new file mode 100644 index 0000000000..3f242047a9 --- /dev/null +++ b/packages/twenty-apps/public/twenty-fireflies/src/logic-functions/constants/call-recording-status.ts @@ -0,0 +1,9 @@ +// TODO: duplicated from core CallRecording select options — import once a shared package exists. +export const CALL_RECORDING_STATUS = { + PROCESSING: 'PROCESSING', + COMPLETED: 'COMPLETED', +} as const; + +export const CALL_RECORDING_REQUEST_STATUS = { + REQUESTED: 'REQUESTED', +} as const; diff --git a/packages/twenty-apps/public/twenty-fireflies/src/logic-functions/fireflies-sync-call.ts b/packages/twenty-apps/public/twenty-fireflies/src/logic-functions/fireflies-sync-call.ts index 92c7d21f88..a9e781355f 100644 --- a/packages/twenty-apps/public/twenty-fireflies/src/logic-functions/fireflies-sync-call.ts +++ b/packages/twenty-apps/public/twenty-fireflies/src/logic-functions/fireflies-sync-call.ts @@ -9,7 +9,7 @@ export default defineLogicFunction({ universalIdentifier: FIREFLIES_SYNC_CALL_UNIVERSAL_IDENTIFIER, name: 'fireflies-sync-call', description: - 'Sync a single Fireflies call onto its matching CalendarEvent on demand: fetches both transcript and AI summary from Fireflies and writes them to the Transcript and Summary fields. Same matching rules as the webhook (Fireflies calendar_id / cal_id ↔ Twenty eventExternalId or iCalUid). Useful for backfilling history, recovering from a missed webhook, or syncing on a workflow trigger instead of waiting for Fireflies to push.', + 'Sync a single Fireflies call into a CallRecording record on demand: fetches both transcript and AI summary from Fireflies and upserts them onto one CallRecording linked to the matching CalendarEvent. Same matching rules as the webhook (Fireflies calendar_id / cal_id ↔ Twenty eventExternalId or iCalUid). Useful for backfilling history, recovering from a missed webhook, or syncing on a workflow trigger instead of waiting for Fireflies to push.', timeoutSeconds: 60, toolTriggerSettings: { inputSchema: firefliesSyncCallInputSchema, @@ -25,6 +25,7 @@ export default defineLogicFunction({ message: { type: 'string' }, error: { type: 'string' }, transcriptId: { type: 'string' }, + callRecordingId: { type: 'string' }, calendarEventId: { type: 'string' }, updatedFields: { type: 'array', items: { type: 'string' } }, fieldOutcomes: { diff --git a/packages/twenty-apps/public/twenty-fireflies/src/logic-functions/fireflies-webhook.ts b/packages/twenty-apps/public/twenty-fireflies/src/logic-functions/fireflies-webhook.ts index 48d8fa986d..657ab4bac9 100644 --- a/packages/twenty-apps/public/twenty-fireflies/src/logic-functions/fireflies-webhook.ts +++ b/packages/twenty-apps/public/twenty-fireflies/src/logic-functions/fireflies-webhook.ts @@ -56,7 +56,7 @@ export default defineLogicFunction({ universalIdentifier: FIREFLIES_WEBHOOK_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER, name: 'fireflies-webhook', description: - 'Receives Fireflies webhook events when a transcript is ready, then writes the transcript onto the matching CalendarEvent.', + 'Receives Fireflies webhook events when a transcript or summary is ready, then upserts a CallRecording record linked to the matching CalendarEvent.', timeoutSeconds: 60, handler: firefliesWebhookRouteHandler, httpRouteTriggerSettings: { diff --git a/packages/twenty-apps/public/twenty-fireflies/src/logic-functions/handlers/fireflies-sync-call-handler.ts b/packages/twenty-apps/public/twenty-fireflies/src/logic-functions/handlers/fireflies-sync-call-handler.ts index 33407baebf..d9eeb566dd 100644 --- a/packages/twenty-apps/public/twenty-fireflies/src/logic-functions/handlers/fireflies-sync-call-handler.ts +++ b/packages/twenty-apps/public/twenty-fireflies/src/logic-functions/handlers/fireflies-sync-call-handler.ts @@ -9,8 +9,9 @@ import { import { getFirefliesApiKey } from 'src/logic-functions/utils/get-fireflies-api-key'; import { type FirefliesSyncableField, - syncFirefliesFieldToCalendarEvent, -} from 'src/logic-functions/utils/sync-fireflies-field-to-calendar-event'; + type SyncFirefliesCallResult, + syncFirefliesCallToCallRecording, +} from 'src/logic-functions/utils/sync-fireflies-call-to-call-recording'; const ALL_FIELDS: FirefliesSyncableField[] = ['transcript', 'summary']; @@ -49,16 +50,19 @@ export const firefliesSyncCallHandler = async ( const client = new CoreApiClient(); - const results = await Promise.all( - ALL_FIELDS.map((field) => - syncFirefliesFieldToCalendarEvent({ + // Sequential: both fields upsert the same row, concurrent runs would race on the create. + const results: SyncFirefliesCallResult[] = []; + + for (const field of ALL_FIELDS) { + results.push( + await syncFirefliesCallToCallRecording({ apiKey: apiKeyResult.apiKey, client, transcriptId, field, }), - ), - ); + ); + } const fieldOutcomes: FirefliesSyncCallFieldOutcome[] = results.map( (result) => { @@ -80,9 +84,10 @@ export const firefliesSyncCallHandler = async ( .filter((outcome) => outcome.status === 'updated') .map((outcome) => outcome.field); - const calendarEventId = results.find( - (result) => result.status === 'updated', - )?.calendarEventId; + const updatedResult = results.find( + (result): result is Extract => + result.status === 'updated', + ); if (updatedFields.length === 0) { const skipReasons = fieldOutcomes @@ -94,7 +99,7 @@ export const firefliesSyncCallHandler = async ( return { success: false, - message: `No fields were updated on the matching CalendarEvent for Fireflies transcript ${transcriptId}.`, + message: `No CallRecording was written for Fireflies transcript ${transcriptId}.`, error: [...errors, ...skipReasons].join(' | ') || 'No fields updated.', transcriptId, fieldOutcomes, @@ -111,14 +116,15 @@ export const firefliesSyncCallHandler = async ( partialFailures.length > 0 ? `Synced ${updatedFields.join( ' + ', - )} for Fireflies transcript ${transcriptId} (with errors on ${partialFailures + )} onto the CallRecording for Fireflies transcript ${transcriptId} (with errors on ${partialFailures .map((outcome) => outcome.field) .join(', ')}).` : `Synced ${updatedFields.join( ' + ', - )} for Fireflies transcript ${transcriptId}.`, + )} onto the CallRecording for Fireflies transcript ${transcriptId}.`, transcriptId, - calendarEventId, + callRecordingId: updatedResult?.callRecordingId, + calendarEventId: updatedResult?.calendarEventId, updatedFields, fieldOutcomes, }; diff --git a/packages/twenty-apps/public/twenty-fireflies/src/logic-functions/handlers/fireflies-webhook-handler.ts b/packages/twenty-apps/public/twenty-fireflies/src/logic-functions/handlers/fireflies-webhook-handler.ts index 0c28504f09..ca86fcbb87 100644 --- a/packages/twenty-apps/public/twenty-fireflies/src/logic-functions/handlers/fireflies-webhook-handler.ts +++ b/packages/twenty-apps/public/twenty-fireflies/src/logic-functions/handlers/fireflies-webhook-handler.ts @@ -5,8 +5,8 @@ import { type FirefliesWebhookResult } from 'src/logic-functions/types/fireflies import { getFirefliesApiKey } from 'src/logic-functions/utils/get-fireflies-api-key'; import { type FirefliesSyncableField, - syncFirefliesFieldToCalendarEvent, -} from 'src/logic-functions/utils/sync-fireflies-field-to-calendar-event'; + syncFirefliesCallToCallRecording, +} from 'src/logic-functions/utils/sync-fireflies-call-to-call-recording'; const TRANSCRIPT_READY_EVENT = 'meeting.transcribed'; const SUMMARY_READY_EVENT = 'meeting.summarized'; @@ -47,7 +47,7 @@ export const firefliesWebhookHandler = async ({ return { error: apiKeyResult.error, meetingId }; } - const syncResult = await syncFirefliesFieldToCalendarEvent({ + const syncResult = await syncFirefliesCallToCallRecording({ apiKey: apiKeyResult.apiKey, client: new CoreApiClient(), transcriptId: meetingId, @@ -65,6 +65,7 @@ export const firefliesWebhookHandler = async ({ return { action: 'updated', field: syncResult.field, + callRecordingId: syncResult.callRecordingId, calendarEventId: syncResult.calendarEventId, meetingId, }; diff --git a/packages/twenty-apps/public/twenty-fireflies/src/logic-functions/types/fireflies-sync-call-result.type.ts b/packages/twenty-apps/public/twenty-fireflies/src/logic-functions/types/fireflies-sync-call-result.type.ts index 070e0c0591..53edb99966 100644 --- a/packages/twenty-apps/public/twenty-fireflies/src/logic-functions/types/fireflies-sync-call-result.type.ts +++ b/packages/twenty-apps/public/twenty-fireflies/src/logic-functions/types/fireflies-sync-call-result.type.ts @@ -1,4 +1,4 @@ -import { type FirefliesSyncableField } from 'src/logic-functions/utils/sync-fireflies-field-to-calendar-event'; +import { type FirefliesSyncableField } from 'src/logic-functions/utils/sync-fireflies-call-to-call-recording'; export type FirefliesSyncCallFieldOutcome = | { field: FirefliesSyncableField; status: 'updated' } @@ -10,6 +10,7 @@ export type FirefliesSyncCallResult = { message: string; error?: string; transcriptId?: string; + callRecordingId?: string; calendarEventId?: string; updatedFields?: FirefliesSyncableField[]; fieldOutcomes?: FirefliesSyncCallFieldOutcome[]; diff --git a/packages/twenty-apps/public/twenty-fireflies/src/logic-functions/types/fireflies-transcript.type.ts b/packages/twenty-apps/public/twenty-fireflies/src/logic-functions/types/fireflies-transcript.type.ts index da82410363..819534b523 100644 --- a/packages/twenty-apps/public/twenty-fireflies/src/logic-functions/types/fireflies-transcript.type.ts +++ b/packages/twenty-apps/public/twenty-fireflies/src/logic-functions/types/fireflies-transcript.type.ts @@ -2,6 +2,7 @@ export type FirefliesTranscriptSentence = { speaker_name: string | null; text: string; start_time: number | null; + end_time?: number | null; }; export type FirefliesSummary = { diff --git a/packages/twenty-apps/public/twenty-fireflies/src/logic-functions/types/fireflies-webhook-payload.type.ts b/packages/twenty-apps/public/twenty-fireflies/src/logic-functions/types/fireflies-webhook-payload.type.ts index 1398484911..4d5dfd6ac1 100644 --- a/packages/twenty-apps/public/twenty-fireflies/src/logic-functions/types/fireflies-webhook-payload.type.ts +++ b/packages/twenty-apps/public/twenty-fireflies/src/logic-functions/types/fireflies-webhook-payload.type.ts @@ -11,7 +11,8 @@ export type FirefliesWebhookResult = | { action: 'updated'; field: FirefliesSyncedField; - calendarEventId: string; + callRecordingId: string; + calendarEventId?: string; meetingId: string; } | { skipped: true; reason: string; meetingId?: string } diff --git a/packages/twenty-apps/public/twenty-fireflies/src/logic-functions/utils/__tests__/compute-call-recording-id-for-fireflies-meeting.test.ts b/packages/twenty-apps/public/twenty-fireflies/src/logic-functions/utils/__tests__/compute-call-recording-id-for-fireflies-meeting.test.ts new file mode 100644 index 0000000000..791ade49e9 --- /dev/null +++ b/packages/twenty-apps/public/twenty-fireflies/src/logic-functions/utils/__tests__/compute-call-recording-id-for-fireflies-meeting.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest'; + +import { computeCallRecordingIdForFirefliesMeeting } from 'src/logic-functions/utils/compute-call-recording-id-for-fireflies-meeting'; + +const UUID_V4_PATTERN = + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; + +describe('computeCallRecordingIdForFirefliesMeeting', () => { + it('should produce a valid v4-shaped UUID', () => { + const id = computeCallRecordingIdForFirefliesMeeting('01K1234ABCD'); + + expect(id).toMatch(UUID_V4_PATTERN); + }); + + it('should be deterministic for the same meeting id', () => { + expect(computeCallRecordingIdForFirefliesMeeting('meeting-a')).toBe( + computeCallRecordingIdForFirefliesMeeting('meeting-a'), + ); + }); + + it('should differ for different meeting ids', () => { + expect(computeCallRecordingIdForFirefliesMeeting('meeting-a')).not.toBe( + computeCallRecordingIdForFirefliesMeeting('meeting-b'), + ); + }); +}); diff --git a/packages/twenty-apps/public/twenty-fireflies/src/logic-functions/utils/__tests__/format-transcript-as-markdown.test.ts b/packages/twenty-apps/public/twenty-fireflies/src/logic-functions/utils/__tests__/format-transcript-as-markdown.test.ts deleted file mode 100644 index 42b58154e0..0000000000 --- a/packages/twenty-apps/public/twenty-fireflies/src/logic-functions/utils/__tests__/format-transcript-as-markdown.test.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { type FirefliesTranscript } from 'src/logic-functions/types/fireflies-transcript.type'; -import { formatTranscriptAsMarkdown } from 'src/logic-functions/utils/format-transcript-as-markdown'; - -const buildTranscript = ( - sentences: FirefliesTranscript['sentences'], -): FirefliesTranscript => ({ - id: 'abc', - title: 'Test call', - date: 1700000000000, - duration: 30, - meeting_link: 'https://zoom.us/j/1234', - participants: ['a@example.com'], - organizer_email: 'a@example.com', - sentences, -}); - -describe('formatTranscriptAsMarkdown', () => { - it('returns a placeholder when there are no sentences', () => { - const result = formatTranscriptAsMarkdown(buildTranscript([])); - - expect(result).toContain('Fireflies returned no transcript content'); - }); - - it('returns a placeholder when sentences is null', () => { - const result = formatTranscriptAsMarkdown(buildTranscript(null)); - - expect(result).toContain('Fireflies returned no transcript content'); - }); - - it('groups consecutive sentences from the same speaker into one paragraph', () => { - const result = formatTranscriptAsMarkdown( - buildTranscript([ - { speaker_name: 'Sarah', text: 'Hi there', start_time: 0 }, - { speaker_name: 'Sarah', text: 'How are you?', start_time: 1 }, - { speaker_name: 'John', text: 'Doing well, thanks.', start_time: 2 }, - ]), - ); - - expect(result).toBe( - '**Sarah:** Hi there How are you?\n\n**John:** Doing well, thanks.', - ); - }); - - it('falls back to "Speaker" when the speaker name is missing', () => { - const result = formatTranscriptAsMarkdown( - buildTranscript([{ speaker_name: null, text: 'Hello', start_time: 0 }]), - ); - - expect(result).toBe('**Speaker:** Hello'); - }); - - it('falls back to "Speaker" when the speaker name is whitespace-only', () => { - const result = formatTranscriptAsMarkdown( - buildTranscript([ - { speaker_name: ' ', text: 'Hello', start_time: 0 }, - { speaker_name: '\n\t', text: 'World', start_time: 1 }, - ]), - ); - - expect(result).toBe('**Speaker:** Hello World'); - }); - - it('skips empty sentence text', () => { - const result = formatTranscriptAsMarkdown( - buildTranscript([ - { speaker_name: 'Sarah', text: ' ', start_time: 0 }, - { speaker_name: 'Sarah', text: 'Hello', start_time: 1 }, - ]), - ); - - expect(result).toBe('**Sarah:** Hello'); - }); - - it('returns the placeholder when every sentence is whitespace-only', () => { - const result = formatTranscriptAsMarkdown( - buildTranscript([ - { speaker_name: 'Sarah', text: ' ', start_time: 0 }, - { speaker_name: 'John', text: '\n\t', start_time: 1 }, - { speaker_name: null, text: '', start_time: 2 }, - ]), - ); - - expect(result).toContain('Fireflies returned no transcript content'); - }); -}); diff --git a/packages/twenty-apps/public/twenty-fireflies/src/logic-functions/utils/__tests__/map-fireflies-sentences-to-transcript-entries.test.ts b/packages/twenty-apps/public/twenty-fireflies/src/logic-functions/utils/__tests__/map-fireflies-sentences-to-transcript-entries.test.ts new file mode 100644 index 0000000000..782f8bdc9e --- /dev/null +++ b/packages/twenty-apps/public/twenty-fireflies/src/logic-functions/utils/__tests__/map-fireflies-sentences-to-transcript-entries.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from 'vitest'; + +import { mapFirefliesSentencesToTranscriptEntries } from 'src/logic-functions/utils/map-fireflies-sentences-to-transcript-entries'; + +describe('mapFirefliesSentencesToTranscriptEntries', () => { + it('should map sentences to diarized entries with relative timestamps', () => { + const entries = mapFirefliesSentencesToTranscriptEntries([ + { + speaker_name: 'Sarah', + text: 'Hi there, how are you?', + start_time: 1.5, + end_time: 3.2, + }, + { + speaker_name: 'John', + text: 'Doing well, thanks.', + start_time: 4, + end_time: 5.5, + }, + ]); + + expect(entries).toEqual([ + { + participant: { name: 'Sarah' }, + words: [ + { + text: 'Hi there, how are you?', + start_timestamp: { relative: 1.5 }, + end_timestamp: { relative: 3.2 }, + }, + ], + }, + { + participant: { name: 'John' }, + words: [ + { + text: 'Doing well, thanks.', + start_timestamp: { relative: 4 }, + end_timestamp: { relative: 5.5 }, + }, + ], + }, + ]); + }); + + it('should omit end_timestamp when end_time is missing so players span to the next entry', () => { + const entries = mapFirefliesSentencesToTranscriptEntries([ + { speaker_name: 'Sarah', text: 'Hello.', start_time: 2 }, + ]); + + expect(entries[0].words[0]).toEqual({ + text: 'Hello.', + start_timestamp: { relative: 2 }, + }); + }); + + it('should omit timestamps when start_time is null', () => { + const entries = mapFirefliesSentencesToTranscriptEntries([ + { speaker_name: 'Sarah', text: 'Hello.', start_time: null }, + ]); + + expect(entries[0].words[0]).toEqual({ text: 'Hello.' }); + }); + + it('should label missing speakers and skip empty sentences', () => { + const entries = mapFirefliesSentencesToTranscriptEntries([ + { speaker_name: null, text: 'Unattributed line.', start_time: 0 }, + { speaker_name: 'Sarah', text: ' ', start_time: 1 }, + ]); + + expect(entries).toHaveLength(1); + expect(entries[0].participant.name).toBe('Speaker'); + }); + + it('should return an empty array for null or undefined sentences', () => { + expect(mapFirefliesSentencesToTranscriptEntries(null)).toEqual([]); + expect(mapFirefliesSentencesToTranscriptEntries(undefined)).toEqual([]); + }); +}); diff --git a/packages/twenty-apps/public/twenty-fireflies/src/logic-functions/utils/compute-call-recording-id-for-fireflies-meeting.ts b/packages/twenty-apps/public/twenty-fireflies/src/logic-functions/utils/compute-call-recording-id-for-fireflies-meeting.ts new file mode 100644 index 0000000000..a411d8b10d --- /dev/null +++ b/packages/twenty-apps/public/twenty-fireflies/src/logic-functions/utils/compute-call-recording-id-for-fireflies-meeting.ts @@ -0,0 +1,18 @@ +import { createHash } from 'crypto'; + +// Same Fireflies meeting → same id, so all webhooks and syncs converge on one row. +export const computeCallRecordingIdForFirefliesMeeting = ( + firefliesMeetingId: string, +): string => { + const bytes = createHash('sha256') + .update(`fireflies:${firefliesMeetingId}`) + .digest(); + + // v4 version/variant bits so server-side UUID validation accepts the hash. + bytes[6] = (bytes[6] & 0x0f) | 0x40; + bytes[8] = (bytes[8] & 0x3f) | 0x80; + + const hex = bytes.subarray(0, 16).toString('hex'); + + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; +}; diff --git a/packages/twenty-apps/public/twenty-fireflies/src/logic-functions/utils/fetch-fireflies-summary.ts b/packages/twenty-apps/public/twenty-fireflies/src/logic-functions/utils/fetch-fireflies-summary.ts index a0fdc5644b..a651f6ff96 100644 --- a/packages/twenty-apps/public/twenty-fireflies/src/logic-functions/utils/fetch-fireflies-summary.ts +++ b/packages/twenty-apps/public/twenty-fireflies/src/logic-functions/utils/fetch-fireflies-summary.ts @@ -9,6 +9,7 @@ const SUMMARY_QUERY = ` transcript(id: $transcriptId) { id title + date duration meeting_link participants diff --git a/packages/twenty-apps/public/twenty-fireflies/src/logic-functions/utils/fetch-fireflies-transcript.ts b/packages/twenty-apps/public/twenty-fireflies/src/logic-functions/utils/fetch-fireflies-transcript.ts index f4feac5083..06dadb7851 100644 --- a/packages/twenty-apps/public/twenty-fireflies/src/logic-functions/utils/fetch-fireflies-transcript.ts +++ b/packages/twenty-apps/public/twenty-fireflies/src/logic-functions/utils/fetch-fireflies-transcript.ts @@ -9,6 +9,7 @@ const TRANSCRIPT_QUERY = ` transcript(id: $transcriptId) { id title + date duration meeting_link participants @@ -20,6 +21,7 @@ const TRANSCRIPT_QUERY = ` speaker_name text start_time + end_time } } } diff --git a/packages/twenty-apps/public/twenty-fireflies/src/logic-functions/utils/find-matching-calendar-event.ts b/packages/twenty-apps/public/twenty-fireflies/src/logic-functions/utils/find-matching-calendar-event.ts index 58ee50a3e0..b763178a06 100644 --- a/packages/twenty-apps/public/twenty-fireflies/src/logic-functions/utils/find-matching-calendar-event.ts +++ b/packages/twenty-apps/public/twenty-fireflies/src/logic-functions/utils/find-matching-calendar-event.ts @@ -112,6 +112,6 @@ export const findMatchingCalendarEvent = async ({ return { matched: false, reason: - 'No CalendarEvent matched the transcript by external ID or iCalUid. Either the meeting was never on a synced calendar, or its calendar sync (Google/Outlook/CalDAV) is not configured in Twenty. Orphan calls are skipped in v1.', + 'No CalendarEvent matched the transcript by external ID or iCalUid. Either the meeting was never on a synced calendar, or its calendar sync (Google/Outlook/CalDAV) is not configured in Twenty. The CallRecording is created without a linked calendar event.', }; }; diff --git a/packages/twenty-apps/public/twenty-fireflies/src/logic-functions/utils/format-transcript-as-markdown.ts b/packages/twenty-apps/public/twenty-fireflies/src/logic-functions/utils/format-transcript-as-markdown.ts deleted file mode 100644 index 18dfbbbd90..0000000000 --- a/packages/twenty-apps/public/twenty-fireflies/src/logic-functions/utils/format-transcript-as-markdown.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { isNonEmptyArray, isNonEmptyString } from '@sniptt/guards'; -import { isDefined } from 'src/utils/is-defined'; - -import { type FirefliesTranscript } from 'src/logic-functions/types/fireflies-transcript.type'; - -const UNKNOWN_SPEAKER_LABEL = 'Speaker'; -const EMPTY_TRANSCRIPT_MESSAGE = - '_Fireflies returned no transcript content for this meeting._'; - -export const formatTranscriptAsMarkdown = ( - transcript: FirefliesTranscript, -): string => { - const sentences = transcript.sentences ?? []; - - const lines: string[] = []; - let currentSpeaker: string | null = null; - let currentLines: string[] = []; - - const flush = () => { - if (!isNonEmptyArray(currentLines)) { - return; - } - - const speakerLabel = isDefined(currentSpeaker) - ? currentSpeaker - : UNKNOWN_SPEAKER_LABEL; - - lines.push(`**${speakerLabel}:** ${currentLines.join(' ')}`); - currentLines = []; - }; - - for (const sentence of sentences) { - const text = sentence.text.trim(); - - if (!isNonEmptyString(text)) { - continue; - } - - const trimmedSpeaker = sentence.speaker_name?.trim(); - const speaker = isNonEmptyString(trimmedSpeaker) ? trimmedSpeaker : null; - - if (speaker !== currentSpeaker) { - flush(); - currentSpeaker = speaker; - } - - currentLines.push(text); - } - - flush(); - - if (!isNonEmptyArray(lines)) { - return EMPTY_TRANSCRIPT_MESSAGE; - } - - return lines.join('\n\n'); -}; diff --git a/packages/twenty-apps/public/twenty-fireflies/src/logic-functions/utils/map-fireflies-sentences-to-transcript-entries.ts b/packages/twenty-apps/public/twenty-fireflies/src/logic-functions/utils/map-fireflies-sentences-to-transcript-entries.ts new file mode 100644 index 0000000000..0710694322 --- /dev/null +++ b/packages/twenty-apps/public/twenty-fireflies/src/logic-functions/utils/map-fireflies-sentences-to-transcript-entries.ts @@ -0,0 +1,63 @@ +import { isNonEmptyString } from '@sniptt/guards'; + +import { type FirefliesTranscriptSentence } from 'src/logic-functions/types/fireflies-transcript.type'; + +const UNKNOWN_SPEAKER_LABEL = 'Speaker'; + +export type TranscriptEntryWord = { + text: string; + start_timestamp?: { relative: number }; + end_timestamp?: { relative: number }; +}; + +export type TranscriptEntry = { + participant: { name: string }; + words: TranscriptEntryWord[]; +}; + +const isFiniteNumber = (value: number | null | undefined): value is number => + typeof value === 'number' && Number.isFinite(value); + +// Fireflies only exposes sentence-level timing, so each sentence becomes a single-word entry. +export const mapFirefliesSentencesToTranscriptEntries = ( + sentences: FirefliesTranscriptSentence[] | null | undefined, +): TranscriptEntry[] => { + if (!Array.isArray(sentences)) { + return []; + } + + return sentences.flatMap((sentence) => { + const text = sentence.text?.trim(); + + if (!isNonEmptyString(text)) { + return []; + } + + const speakerName = sentence.speaker_name?.trim(); + const startSeconds = sentence.start_time; + // No end_time → leave end_timestamp unset so players span the entry + // until the next timestamped sentence instead of a single instant. + const endSeconds = sentence.end_time; + + return [ + { + participant: { + name: isNonEmptyString(speakerName) + ? speakerName + : UNKNOWN_SPEAKER_LABEL, + }, + words: [ + { + text, + ...(isFiniteNumber(startSeconds) + ? { start_timestamp: { relative: startSeconds } } + : {}), + ...(isFiniteNumber(endSeconds) + ? { end_timestamp: { relative: endSeconds } } + : {}), + }, + ], + }, + ]; + }); +}; diff --git a/packages/twenty-apps/public/twenty-fireflies/src/logic-functions/utils/sync-fireflies-call-to-call-recording.ts b/packages/twenty-apps/public/twenty-fireflies/src/logic-functions/utils/sync-fireflies-call-to-call-recording.ts new file mode 100644 index 0000000000..735854cd36 --- /dev/null +++ b/packages/twenty-apps/public/twenty-fireflies/src/logic-functions/utils/sync-fireflies-call-to-call-recording.ts @@ -0,0 +1,186 @@ +import { isNonEmptyArray, isNonEmptyString } from '@sniptt/guards'; +import { type CoreApiClient } from 'twenty-client-sdk/core'; +import { isDefined } from 'src/utils/is-defined'; + +import { + CALL_RECORDING_REQUEST_STATUS, + CALL_RECORDING_STATUS, +} from 'src/logic-functions/constants/call-recording-status'; +import { type FirefliesTranscript } from 'src/logic-functions/types/fireflies-transcript.type'; +import { computeCallRecordingIdForFirefliesMeeting } from 'src/logic-functions/utils/compute-call-recording-id-for-fireflies-meeting'; +import { fetchFirefliesSummary } from 'src/logic-functions/utils/fetch-fireflies-summary'; +import { fetchFirefliesTranscript } from 'src/logic-functions/utils/fetch-fireflies-transcript'; +import { findMatchingCalendarEvent } from 'src/logic-functions/utils/find-matching-calendar-event'; +import { formatSummaryAsMarkdown } from 'src/logic-functions/utils/format-summary-as-markdown'; +import { mapFirefliesSentencesToTranscriptEntries } from 'src/logic-functions/utils/map-fireflies-sentences-to-transcript-entries'; +import { + type CallRecordingWriteFields, + upsertCallRecording, +} from 'src/logic-functions/utils/upsert-call-recording'; + +export type FirefliesSyncableField = 'transcript' | 'summary'; + +export type SyncFirefliesCallResult = + | { + status: 'updated'; + field: FirefliesSyncableField; + callRecordingId: string; + calendarEventId?: string; + created: boolean; + } + | { status: 'skipped'; field: FirefliesSyncableField; reason: string } + | { status: 'error'; field: FirefliesSyncableField; error: string }; + +const MINUTES_TO_MILLISECONDS = 60_000; + +const buildTimestampFields = ( + transcript: FirefliesTranscript, +): Pick => { + const dateMs = transcript.date; + + if (!isDefined(dateMs) || !Number.isFinite(dateMs)) { + return {}; + } + + const startedAt = new Date(dateMs).toISOString(); + + // Fireflies duration is in minutes. + const durationMinutes = transcript.duration; + + if (!isDefined(durationMinutes) || !Number.isFinite(durationMinutes)) { + return { startedAt }; + } + + return { + startedAt, + endedAt: new Date( + dateMs + durationMinutes * MINUTES_TO_MILLISECONDS, + ).toISOString(), + }; +}; + +export const syncFirefliesCallToCallRecording = async ({ + apiKey, + client, + transcriptId, + field, +}: { + apiKey: string; + client: CoreApiClient; + transcriptId: string; + field: FirefliesSyncableField; +}): Promise => { + const fetchResult = + field === 'transcript' + ? await fetchFirefliesTranscript({ apiKey, transcriptId }) + : await fetchFirefliesSummary({ apiKey, transcriptId }); + + if (!fetchResult.ok) { + return { status: 'error', field, error: fetchResult.errorMessage }; + } + + const firefliesTranscript = fetchResult.data; + + const fieldUpdate = buildFieldUpdate({ field, firefliesTranscript }); + + if (fieldUpdate.empty) { + return { status: 'skipped', field, reason: fieldUpdate.reason }; + } + + const match = await findMatchingCalendarEvent({ + client, + transcript: firefliesTranscript, + }); + const calendarEventId = match.matched ? match.calendarEventId : undefined; + + const callRecordingId = computeCallRecordingIdForFirefliesMeeting( + firefliesTranscript.id, + ); + + const title = firefliesTranscript.title?.trim(); + const sharedFields: CallRecordingWriteFields = { + ...(isNonEmptyString(title) ? { title } : {}), + externalRecordingId: firefliesTranscript.id, + ...buildTimestampFields(firefliesTranscript), + ...(isDefined(calendarEventId) ? { calendarEventId } : {}), + ...fieldUpdate.fields, + }; + + try { + const upsertResult = await upsertCallRecording(client, { + id: callRecordingId, + createFields: { + ...sharedFields, + recordingRequestStatus: CALL_RECORDING_REQUEST_STATUS.REQUESTED, + status: sharedFields.status ?? CALL_RECORDING_STATUS.PROCESSING, + }, + updateFields: sharedFields, + }); + + return { + status: 'updated', + field, + callRecordingId: upsertResult.callRecordingId, + calendarEventId, + created: upsertResult.created, + }; + } catch (error) { + return { + status: 'error', + field, + error: `Failed to upsert CallRecording ${callRecordingId} for Fireflies transcript ${transcriptId}: ${ + (error as Error).message + }`, + }; + } +}; + +const buildFieldUpdate = ({ + field, + firefliesTranscript, +}: { + field: FirefliesSyncableField; + firefliesTranscript: FirefliesTranscript; +}): + | { empty: false; fields: CallRecordingWriteFields } + | { empty: true; reason: string } => { + if (field === 'transcript') { + const entries = mapFirefliesSentencesToTranscriptEntries( + firefliesTranscript.sentences, + ); + + if (!isNonEmptyArray(entries)) { + return { + empty: true, + reason: + 'Fireflies returned no transcript sentences for this meeting; nothing to sync.', + }; + } + + return { + empty: false, + fields: { + transcript: entries, + status: CALL_RECORDING_STATUS.COMPLETED, + }, + }; + } + + if (!isDefined(firefliesTranscript.summary)) { + return { + empty: true, + reason: + 'Fireflies returned no summary content for this meeting; nothing to sync.', + }; + } + + return { + empty: false, + fields: { + summary: { + markdown: formatSummaryAsMarkdown(firefliesTranscript), + blocknote: null, + }, + }, + }; +}; diff --git a/packages/twenty-apps/public/twenty-fireflies/src/logic-functions/utils/sync-fireflies-field-to-calendar-event.ts b/packages/twenty-apps/public/twenty-fireflies/src/logic-functions/utils/sync-fireflies-field-to-calendar-event.ts deleted file mode 100644 index abfd4462a0..0000000000 --- a/packages/twenty-apps/public/twenty-fireflies/src/logic-functions/utils/sync-fireflies-field-to-calendar-event.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { type CoreApiClient } from 'twenty-client-sdk/core'; - -import { type FirefliesTranscript } from 'src/logic-functions/types/fireflies-transcript.type'; -import { fetchFirefliesSummary } from 'src/logic-functions/utils/fetch-fireflies-summary'; -import { fetchFirefliesTranscript } from 'src/logic-functions/utils/fetch-fireflies-transcript'; -import { type FirefliesApiResult } from 'src/logic-functions/utils/fireflies-api-request'; -import { findMatchingCalendarEvent } from 'src/logic-functions/utils/find-matching-calendar-event'; -import { formatSummaryAsMarkdown } from 'src/logic-functions/utils/format-summary-as-markdown'; -import { formatTranscriptAsMarkdown } from 'src/logic-functions/utils/format-transcript-as-markdown'; -import { updateCalendarEventSummary } from 'src/logic-functions/utils/update-calendar-event-summary'; -import { updateCalendarEventTranscript } from 'src/logic-functions/utils/update-calendar-event-transcript'; - -export type FirefliesSyncableField = 'transcript' | 'summary'; - -export type SyncFirefliesFieldResult = - | { - status: 'updated'; - field: FirefliesSyncableField; - calendarEventId: string; - } - | { status: 'skipped'; field: FirefliesSyncableField; reason: string } - | { status: 'error'; field: FirefliesSyncableField; error: string }; - -type FieldSyncStrategy = { - fetch: (args: { - apiKey: string; - transcriptId: string; - }) => Promise>; - format: (transcript: FirefliesTranscript) => string; - update: (args: { - client: CoreApiClient; - calendarEventId: string; - markdown: string; - }) => Promise; -}; - -const FIELD_SYNC_STRATEGIES: Record = - { - transcript: { - fetch: fetchFirefliesTranscript, - format: formatTranscriptAsMarkdown, - update: updateCalendarEventTranscript, - }, - summary: { - fetch: fetchFirefliesSummary, - format: formatSummaryAsMarkdown, - update: updateCalendarEventSummary, - }, - }; - -export const syncFirefliesFieldToCalendarEvent = async ({ - apiKey, - client, - transcriptId, - field, -}: { - apiKey: string; - client: CoreApiClient; - transcriptId: string; - field: FirefliesSyncableField; -}): Promise => { - const strategy = FIELD_SYNC_STRATEGIES[field]; - - const fetchResult = await strategy.fetch({ apiKey, transcriptId }); - - if (!fetchResult.ok) { - return { status: 'error', field, error: fetchResult.errorMessage }; - } - - const match = await findMatchingCalendarEvent({ - client, - transcript: fetchResult.data, - }); - - if (!match.matched) { - return { status: 'skipped', field, reason: match.reason }; - } - - const markdown = strategy.format(fetchResult.data); - - try { - await strategy.update({ - client, - calendarEventId: match.calendarEventId, - markdown, - }); - } catch (error) { - return { - status: 'error', - field, - error: `Failed to update CalendarEvent ${match.calendarEventId} ${field}: ${ - (error as Error).message - }`, - }; - } - - return { - status: 'updated', - field, - calendarEventId: match.calendarEventId, - }; -}; diff --git a/packages/twenty-apps/public/twenty-fireflies/src/logic-functions/utils/update-calendar-event-summary.ts b/packages/twenty-apps/public/twenty-fireflies/src/logic-functions/utils/update-calendar-event-summary.ts deleted file mode 100644 index 6d997b812e..0000000000 --- a/packages/twenty-apps/public/twenty-fireflies/src/logic-functions/utils/update-calendar-event-summary.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { type CoreApiClient } from 'twenty-client-sdk/core'; - -export const updateCalendarEventSummary = async ({ - client, - calendarEventId, - markdown, -}: { - client: CoreApiClient; - calendarEventId: string; - markdown: string; -}): Promise => { - await client.mutation({ - updateCalendarEvent: { - __args: { - id: calendarEventId, - data: { - summary: { - markdown, - blocknote: null, - }, - }, - }, - id: true, - }, - }); -}; diff --git a/packages/twenty-apps/public/twenty-fireflies/src/logic-functions/utils/update-calendar-event-transcript.ts b/packages/twenty-apps/public/twenty-fireflies/src/logic-functions/utils/update-calendar-event-transcript.ts deleted file mode 100644 index 117737c07d..0000000000 --- a/packages/twenty-apps/public/twenty-fireflies/src/logic-functions/utils/update-calendar-event-transcript.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { type CoreApiClient } from 'twenty-client-sdk/core'; - -export const updateCalendarEventTranscript = async ({ - client, - calendarEventId, - markdown, -}: { - client: CoreApiClient; - calendarEventId: string; - markdown: string; -}): Promise => { - await client.mutation({ - updateCalendarEvent: { - __args: { - id: calendarEventId, - data: { - transcript: { - markdown, - blocknote: null, - }, - }, - }, - id: true, - }, - }); -}; diff --git a/packages/twenty-apps/public/twenty-fireflies/src/logic-functions/utils/upsert-call-recording.ts b/packages/twenty-apps/public/twenty-fireflies/src/logic-functions/utils/upsert-call-recording.ts new file mode 100644 index 0000000000..5d5c9d51fe --- /dev/null +++ b/packages/twenty-apps/public/twenty-fireflies/src/logic-functions/utils/upsert-call-recording.ts @@ -0,0 +1,96 @@ +import { type CoreApiClient } from 'twenty-client-sdk/core'; +import { isDefined } from 'src/utils/is-defined'; + +export type CallRecordingWriteFields = { + title?: string; + status?: string; + recordingRequestStatus?: string; + externalRecordingId?: string; + startedAt?: string; + endedAt?: string; + transcript?: unknown; + summary?: { markdown: string; blocknote: null }; + calendarEventId?: string; +}; + +type UpsertCallRecordingResult = { + callRecordingId: string; + created: boolean; +}; + +const findCallRecordingId = async ( + client: CoreApiClient, + id: string, +): Promise => { + const queryResult = await client.query({ + callRecordings: { + __args: { + filter: { id: { eq: id } }, + first: 1, + }, + edges: { + node: { + id: true, + }, + }, + }, + }); + + return queryResult.callRecordings?.edges?.[0]?.node?.id ?? undefined; +}; + +const updateCallRecording = async ( + client: CoreApiClient, + id: string, + data: CallRecordingWriteFields, +): Promise => { + await client.mutation({ + updateCallRecording: { + __args: { id, data }, + id: true, + }, + }); +}; + +export const upsertCallRecording = async ( + client: CoreApiClient, + { + id, + createFields, + updateFields, + }: { + id: string; + createFields: CallRecordingWriteFields; + updateFields: CallRecordingWriteFields; + }, +): Promise => { + const existingId = await findCallRecordingId(client, id); + + if (isDefined(existingId)) { + await updateCallRecording(client, id, updateFields); + + return { callRecordingId: id, created: false }; + } + + try { + await client.mutation({ + createCallRecording: { + __args: { data: { id, ...createFields } }, + id: true, + }, + }); + + return { callRecordingId: id, created: true }; + } catch (error) { + // A concurrent create on the same deterministic id may have won the race. + const raceWinnerId = await findCallRecordingId(client, id); + + if (!isDefined(raceWinnerId)) { + throw error; + } + + await updateCallRecording(client, id, updateFields); + + return { callRecordingId: id, created: false }; + } +}; diff --git a/packages/twenty-apps/public/twenty-fireflies/src/roles/default-function.role.ts b/packages/twenty-apps/public/twenty-fireflies/src/roles/default-function.role.ts index 07caa9ab20..f8d07c136a 100644 --- a/packages/twenty-apps/public/twenty-fireflies/src/roles/default-function.role.ts +++ b/packages/twenty-apps/public/twenty-fireflies/src/roles/default-function.role.ts @@ -9,7 +9,7 @@ export default defineApplicationRole({ universalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER, label: 'Twenty Fireflies sync role', description: - 'Reads CalendarEvent and CalendarChannelEventAssociation to locate the meeting matching a Fireflies call (from an incoming webhook or from the on-demand Sync Fireflies Call workflow tool), and updates that CalendarEvent to write the synced transcript and summary fields. The list / search workflow tools only call the Fireflies API and do not require any Twenty object permissions.', + 'Reads CalendarEvent and CalendarChannelEventAssociation to locate the meeting matching a Fireflies call (from an incoming webhook or from the on-demand Sync Fireflies Call workflow tool), and writes the synced transcript and summary into a CallRecording record linked to that meeting. The list / search workflow tools only call the Fireflies API and do not require any Twenty object permissions.', canReadAllObjectRecords: false, canUpdateAllObjectRecords: false, canSoftDeleteAllObjectRecords: false, @@ -23,7 +23,7 @@ export default defineApplicationRole({ objectUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.calendarEvent.universalIdentifier, canReadObjectRecords: true, - canUpdateObjectRecords: true, + canUpdateObjectRecords: false, canSoftDeleteObjectRecords: false, canDestroyObjectRecords: false, }, @@ -36,6 +36,14 @@ export default defineApplicationRole({ canSoftDeleteObjectRecords: false, canDestroyObjectRecords: false, }, + { + objectUniversalIdentifier: + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.callRecording.universalIdentifier, + canReadObjectRecords: true, + canUpdateObjectRecords: true, + canSoftDeleteObjectRecords: false, + canDestroyObjectRecords: false, + }, ], fieldPermissions: [], permissionFlagUniversalIdentifiers: [], diff --git a/packages/twenty-apps/public/twenty-fireflies/src/schema/fields/summary-on-calendar-event.field.ts b/packages/twenty-apps/public/twenty-fireflies/src/schema/fields/summary-on-calendar-event.field.ts deleted file mode 100644 index 8fc8065750..0000000000 --- a/packages/twenty-apps/public/twenty-fireflies/src/schema/fields/summary-on-calendar-event.field.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { - defineField, - FieldType, - STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, -} from 'twenty-sdk/define'; - -import { SUMMARY_ON_CALENDAR_EVENT_FIELD_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers'; - -export default defineField({ - universalIdentifier: SUMMARY_ON_CALENDAR_EVENT_FIELD_UNIVERSAL_IDENTIFIER, - objectUniversalIdentifier: - STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.calendarEvent.universalIdentifier, - type: FieldType.RICH_TEXT, - name: 'summary', - label: 'Summary', - description: - 'AI-generated meeting summary synced from Fireflies. Includes overview, action items, and keywords. Populated automatically when Fireflies finishes summarizing a recording that matches this calendar event.', - icon: 'IconNotes', - isNullable: true, -}); diff --git a/packages/twenty-apps/public/twenty-fireflies/src/schema/fields/transcript-on-calendar-event.field.ts b/packages/twenty-apps/public/twenty-fireflies/src/schema/fields/transcript-on-calendar-event.field.ts deleted file mode 100644 index 7c09d0bf42..0000000000 --- a/packages/twenty-apps/public/twenty-fireflies/src/schema/fields/transcript-on-calendar-event.field.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { - defineField, - FieldType, - STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, -} from 'twenty-sdk/define'; - -import { TRANSCRIPT_ON_CALENDAR_EVENT_FIELD_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers'; - -export default defineField({ - universalIdentifier: TRANSCRIPT_ON_CALENDAR_EVENT_FIELD_UNIVERSAL_IDENTIFIER, - objectUniversalIdentifier: - STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.calendarEvent.universalIdentifier, - type: FieldType.RICH_TEXT, - name: 'transcript', - label: 'Transcript', - description: - 'Call transcript synced from Fireflies. Populated automatically when a Fireflies recording finishes processing and matches this calendar event.', - icon: 'IconMicrophone', - isNullable: true, -}); diff --git a/packages/twenty-apps/public/twenty-fireflies/yarn.lock b/packages/twenty-apps/public/twenty-fireflies/yarn.lock index 1389bfca72..379ce447ee 100644 --- a/packages/twenty-apps/public/twenty-fireflies/yarn.lock +++ b/packages/twenty-apps/public/twenty-fireflies/yarn.lock @@ -953,8 +953,8 @@ __metadata: oxlint: "npm:^0.16.0" react: "npm:^18.2.0" react-dom: "npm:^18.2.0" - twenty-client-sdk: "npm:^2.16.0" - twenty-sdk: "npm:^2.16.0" + twenty-client-sdk: "npm:^2.18.0" + twenty-sdk: "npm:^2.18.0" typescript: "npm:^5.9.3" vite-tsconfig-paths: "npm:^4.2.1" vitest: "npm:^4.0.0" @@ -2888,22 +2888,22 @@ __metadata: languageName: node linkType: hard -"twenty-client-sdk@npm:2.16.0, twenty-client-sdk@npm:^2.16.0": - version: 2.16.0 - resolution: "twenty-client-sdk@npm:2.16.0" +"twenty-client-sdk@npm:2.18.0, twenty-client-sdk@npm:^2.18.0": + version: 2.18.0 + resolution: "twenty-client-sdk@npm:2.18.0" dependencies: "@genql/runtime": "npm:^2.10.0" esbuild: "npm:^0.28.1" graphql: "npm:^16.8.1" lodash: "npm:^4.17.21" prettier: "npm:^3.8.3" - checksum: 10c0/f990220bec103a04ca50dc8bb38c295e94a4e51b27a3188f4a0ae515c204b76bf87f5591739eb705b97e7aa5a9aa6a215d0d4da05297e50a0392801ea64a2234 + checksum: 10c0/3b7d6ca4e7b59c04cd2348af0f2ba8de153e70d565b4a38c2744b44998dfcc6c2bdb84ba6315cec208953d8c6fa7573ab33c17355f971f279299d470c57be06e languageName: node linkType: hard -"twenty-sdk@npm:^2.16.0": - version: 2.16.0 - resolution: "twenty-sdk@npm:2.16.0" +"twenty-sdk@npm:^2.18.0": + version: 2.18.0 + resolution: "twenty-sdk@npm:2.18.0" dependencies: "@sniptt/guards": "npm:^0.2.0" axios: "npm:^1.16.0" @@ -2922,12 +2922,12 @@ __metadata: semver: "npm:7.6.3" sharp: "npm:^0.34.5" tinyglobby: "npm:^0.2.15" - twenty-client-sdk: "npm:2.16.0" + twenty-client-sdk: "npm:2.18.0" typescript: "npm:^5.9.3" uuid: "npm:^13.0.2" bin: twenty: dist/cli.cjs - checksum: 10c0/528f275f8bda022061a52c3563af514d2257f291fc5a232be90ca6011c50ff18a250984b6c68fc8e4c690644c92e1ccb0b89c511b8a19d832455c8860f2751a8 + checksum: 10c0/7e6d300894c427029e4124047614b55988671c914332613acb38f830cee11ecc480ff2a586b6153756aab1b7fa7f4520cf072c9db1428cd9ce9b9f7a62dfcd64 languageName: node linkType: hard