Fireflies: port app to the CallRecording standard object (#22642)

## Context

The Fireflies app predates the core `CallRecording` standard object: it
wrote transcripts and summaries as markdown into two rich-text field
extensions on `CalendarEvent`. That model has no per-call record,
silently drops orphan calls, and diverges from how the Call Recorder app
stores recordings. This is the first step of moving Fireflies onto the
CallRecording architecture; media ingestion, retry markers, and a
dedicated tab/front component iterate on top of this.

## What this PR does

Replaces the CalendarEvent field extensions with upserts into the core
`CallRecording` object:

- **One CallRecording per Fireflies call** via a deterministic UUID
derived from the Fireflies meeting id — the `meeting.transcribed`
webhook, the `meeting.summarized` webhook, and manual **Sync Fireflies
Call** runs all converge on the same row regardless of order, with a
create-race fallback to update
- **Transcript as diarized JSON** (participant + sentence-level relative
timestamps) instead of markdown, matching the entry shape the
CallRecording `transcript` field holds for other recording apps;
fetchers now request `date` and sentence `end_time`
- **Summary stays rich text** in `CallRecording.summary`, composed from
the Fireflies overview / action items / topics / keywords
- **Call metadata filled** — title, `startedAt`/`endedAt` (from `date` +
`duration`), `externalRecordingId`; transcript sync marks the row
`COMPLETED`, a summary-first sync creates it as `PROCESSING`
- **Orphan calls are kept**: when no CalendarEvent matches by
`eventExternalId` / `iCalUid`, the CallRecording is created without a
calendar event link instead of being dropped
- **Role**: now reads/writes `callRecording`; `calendarEvent` drops to
read-only; the two schema field extensions and the markdown transcript
formatter are removed

### Housekeeping

- App version bumped to **0.2.0** so installed workspaces pick up the
upgrade
- `twenty-sdk` / `twenty-client-sdk` aligned on `^2.18.0` (matches
call-recorder)
- Gallery screenshots depicting the removed CalendarEvent fields dropped
from the marketplace config
- README: "Upgrading from 0.1.x" section documenting the field removal
and the backfill path
- Integration test guarding the mirrored status constants against the
server's CallRecording select options

## Upgrade note

Upgrading removes the two app-owned CalendarEvent fields and their
stored content (app upgrades infer deletions from the manifest diff).
That data is a cache of Fireflies content and is re-derivable: any call
still in Fireflies can be re-ingested as a CallRecording via **Sync
Fireflies Call**.

A follow-up PR adds a post-install/upgrade sweep (same pattern as the
Call Recorder sweep in #22552) that pages through Fireflies history and
replays each call through the same sync flow, so history backfills
automatically on install and upgrade — the deterministic ids make
re-sweeping idempotent.



<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22642?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
This commit is contained in:
nitin
2026-07-08 14:11:49 +05:30
committed by GitHub
parent 4b127451b4
commit 525ed74b2f
33 changed files with 683 additions and 421 deletions
@@ -1,9 +1,10 @@
# Fireflies for Twenty # Fireflies for Twenty
Sync [Fireflies](https://fireflies.ai) call transcripts and AI summaries onto Sync [Fireflies](https://fireflies.ai) call transcripts and AI summaries into
the matching `CalendarEvent` in your Twenty CRM — searchable, in context, and `CallRecording` records linked to the matching `CalendarEvent` in your Twenty
ready for AI agents and workflows to act on. Plus on-demand workflow tools to CRM — searchable, in context, and ready for AI agents and workflows to act
sync, list, and search Fireflies calls from the AI chat or workflow builder. on. Plus on-demand workflow tools to sync, list, and search Fireflies calls
from the AI chat or workflow builder.
## What this app does ## What this app does
@@ -13,8 +14,9 @@ sync, list, and search Fireflies calls from the AI chat or workflow builder.
`meeting.summarized` webhook. `meeting.summarized` webhook.
3. For each event, this app fetches the relevant data via the Fireflies 3. For each event, this app fetches the relevant data via the Fireflies
GraphQL API. GraphQL API.
4. It finds the matching `CalendarEvent` in Twenty and writes the content 4. It upserts a `CallRecording` record (one per Fireflies call) with the
into either the **Transcript** or **Summary** field on that event. 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 Alongside the webhook, three [Workflow tools](#workflow-tools) let you
trigger Fireflies actions from the AI chat or as steps inside a workflow, 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 / CalendarEvent, so any meeting that's been pulled in via Google / Outlook /
CalDAV calendar sync will match exactly. The matcher does **not** fall back 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 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 event, the CallRecording is still created, just without a linked calendar
[Limitations](#limitations) below). This avoids silently writing transcripts event. This avoids silently linking transcripts to the wrong event.
to the wrong event.
## What gets added to your Twenty workspace ## 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:** - One CallRecording per Fireflies call, with a deterministic id — the
Hi there"*, then *"**John:** Doing well, thanks."*). `meeting.transcribed` webhook, the `meeting.summarized` webhook, and manual
- **Summary** — rich-text field with the Fireflies AI summary: a bullet-list syncs all converge on the same record.
overview, action items grouped by speaker, topics discussed, and keywords. - **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. 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 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: builder and the AI chat — covering the cases the webhook can't:
- **Sync Fireflies Call** — *"sync the Fireflies call `01HXYZ...` onto its - **Sync Fireflies Call** — *"sync the Fireflies call `01HXYZ...` into a
CalendarEvent now"*. As a workflow step: provide `transcriptId`. Runs the CallRecording now"*. As a workflow step: provide `transcriptId`. Runs the
same pipeline as the webhook (fetch transcript + AI summary, find matching 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 **backfilling** historical calls that happened before the app was
installed; **recovering** from a missed webhook (e.g. the calendar event installed; **recovering** from a missed webhook (e.g. the calendar event
hadn't synced yet when Fireflies pushed); or triggering a sync from a hadn't synced yet when Fireflies pushed); or triggering a sync from a
workflow instead of waiting for Fireflies. Output includes workflow instead of waiting for Fireflies. Output includes
`calendarEventId`, `updatedFields`, and a per-field outcome breakdown so `callRecordingId`, `calendarEventId`, `updatedFields`, and a per-field
partial successes are visible. outcome breakdown so partial successes are visible.
- **List Fireflies Calls By Participant** — *"show me my last 5 calls with - **List Fireflies Calls By Participant** — *"show me my last 5 calls with
john@acme.com"*. As a workflow step: provide `participantEmail` (and john@acme.com"*. As a workflow step: provide `participantEmail` (and
optional `limit`, max 50). Returns recent Fireflies calls — newest first — 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: The list-by-participant and search tools return the same compact call shape:
`id`, `title`, `date`, `durationMinutes`, `participants`, `hostEmail`, `id`, `title`, `date`, `durationMinutes`, `participants`, `hostEmail`,
`transcriptUrl`, `meetingLink`. To then sync any of those calls onto its `transcriptUrl`, `meetingLink`. To then sync any of those calls into a
CalendarEvent, pass the `id` from a list result into **Sync Fireflies Call**. CallRecording, pass the `id` from a list result into **Sync Fireflies Call**.
## Installing ## 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 > webhook, your Twenty admin needs to follow the
> [Self-hosting setup](#self-hosting-setup-admin-only) section. > [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 ## Limitations
What this connector intentionally does **not** support in v1: What this connector intentionally does **not** support in v1:
- **Calls without a matching CalendarEvent (orphan calls).** Ad-hoc calls - **Audio / video media ingestion.** Fireflies' `audio_url` / `video_url`
that were never on anyone's synced calendar are skipped. The webhook logs are not downloaded yet; the CallRecording holds the transcript and summary
the skip reason; the transcript still lives in Fireflies. Synthetic event only. Media ingestion is planned for a follow-up.
creation for orphans is planned for v2.
- **Fireflies sentiment, speaker analytics, transcript chapters.** Only - **Fireflies sentiment, speaker analytics, transcript chapters.** Only
the raw transcript and the AI summary (overview, action items, topics, the raw transcript and the AI summary (overview, action items, topics,
keywords) are synced today. 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 - **Per-user Fireflies accounts.** All transcripts come through one
workspace-shared API key (set by the admin). Per-user OAuth-style workspace-shared API key (set by the admin). Per-user OAuth-style
connections require extending Twenty's connection provider system and are connections require extending Twenty's connection provider system and are
planned once we have evidence that workspace-shared is too coarse. planned once we have evidence that workspace-shared is too coarse.
- **Editing transcripts or summaries in Twenty.** The fields are writable - **Editing transcripts or summaries in Twenty.** The CallRecording fields
but the next Fireflies sync overwrites any manual edits — treat them as are writable but the next Fireflies sync overwrites any manual edits —
read-only. treat them as read-only.
## Troubleshooting ## 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 `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 `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 | | 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 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 | | 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` | | 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 | | **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. `openssl rand -hex 32`). Save it; you'll paste it into Twenty next.
4. Under **Events**, subscribe to **both**: 4. Under **Events**, subscribe to **both**:
- **`meeting.transcribed`** — fires when the transcript is ready and - **`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 - **`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 Subscribing to only one is fine if you don't want the other field
populated; the app dispatches per event. populated; the app dispatches per event.
5. **Save** the configuration. 5. **Save** the configuration.
@@ -175,7 +195,8 @@ supported.
2. Paste the Fireflies API key into the `FIREFLIES_API_KEY` row. 2. Paste the Fireflies API key into the `FIREFLIES_API_KEY` row.
3. Paste the signing secret into the `FIREFLIES_WEBHOOK_SECRET` row. 3. Paste the signing secret into the `FIREFLIES_WEBHOOK_SECRET` row.
After saving, the next time Fireflies finishes processing a recording, the After saving, the next time Fireflies finishes processing a recording, a
transcript will land on the matching CalendarEvent within a few seconds; CallRecording with the transcript will appear (linked to the matching
the summary follows once Fireflies finishes the AI summarization step CalendarEvent) within a few seconds; the summary follows once Fireflies
(typically a minute or two later — Fireflies sends two separate webhooks). finishes the AI summarization step (typically a minute or two later —
Fireflies sends two separate webhooks).
@@ -1,6 +1,6 @@
{ {
"name": "@twentyhq/twenty-fireflies", "name": "@twentyhq/twenty-fireflies",
"version": "0.1.0", "version": "0.2.0",
"description": "Fireflies call-transcript connector for Twenty", "description": "Fireflies call-transcript connector for Twenty",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@@ -28,8 +28,8 @@
"oxlint": "^0.16.0", "oxlint": "^0.16.0",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"twenty-client-sdk": "^2.16.0", "twenty-client-sdk": "^2.18.0",
"twenty-sdk": "^2.16.0", "twenty-sdk": "^2.18.0",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"vite-tsconfig-paths": "^4.2.1", "vite-tsconfig-paths": "^4.2.1",
"vitest": "^4.0.0" "vitest": "^4.0.0"
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

@@ -1,7 +1,12 @@
import { CoreApiClient } from 'twenty-client-sdk/core';
import { MetadataApiClient } from 'twenty-client-sdk/metadata'; import { MetadataApiClient } from 'twenty-client-sdk/metadata';
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { APPLICATION_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers'; 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', () => { describe('App installation', () => {
it('should find the installed Fireflies app in the applications list', async () => { it('should find the installed Fireflies app in the applications list', async () => {
@@ -23,3 +28,86 @@ describe('App installation', () => {
expect(matchingApplication).toBeDefined(); 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<string, string>;
};
const getServerCallRecordingStatuses = async (): Promise<string[]> => {
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,
},
});
});
});
@@ -10,13 +10,11 @@ export default defineApplication({
universalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER, universalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER,
displayName: 'Twenty Fireflies', displayName: 'Twenty Fireflies',
description: 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', logoUrl: 'public/twenty-fireflies.svg',
author: 'Twenty', author: 'Twenty',
category: 'Productivity', category: 'Productivity',
screenshots: [ screenshots: [
'public/gallery/transcript-on-calendar-event.png',
'public/gallery/summary-on-calendar-event.png',
'public/gallery/workflow-builder-actions.png', 'public/gallery/workflow-builder-actions.png',
'public/gallery/app-settings.png', 'public/gallery/app-settings.png',
], ],
@@ -10,12 +10,6 @@ export const FIREFLIES_API_KEY_VARIABLE_UNIVERSAL_IDENTIFIER =
export const FIREFLIES_WEBHOOK_SECRET_VARIABLE_UNIVERSAL_IDENTIFIER = export const FIREFLIES_WEBHOOK_SECRET_VARIABLE_UNIVERSAL_IDENTIFIER =
'a1ef34d9-f0e7-483e-9909-fce2757bdd23'; '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 = export const FIREFLIES_WEBHOOK_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER =
'e0c2d033-0ef4-4b92-9e64-2d8f2e4c497e'; 'e0c2d033-0ef4-4b92-9e64-2d8f2e4c497e';
@@ -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;
@@ -9,7 +9,7 @@ export default defineLogicFunction({
universalIdentifier: FIREFLIES_SYNC_CALL_UNIVERSAL_IDENTIFIER, universalIdentifier: FIREFLIES_SYNC_CALL_UNIVERSAL_IDENTIFIER,
name: 'fireflies-sync-call', name: 'fireflies-sync-call',
description: 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, timeoutSeconds: 60,
toolTriggerSettings: { toolTriggerSettings: {
inputSchema: firefliesSyncCallInputSchema, inputSchema: firefliesSyncCallInputSchema,
@@ -25,6 +25,7 @@ export default defineLogicFunction({
message: { type: 'string' }, message: { type: 'string' },
error: { type: 'string' }, error: { type: 'string' },
transcriptId: { type: 'string' }, transcriptId: { type: 'string' },
callRecordingId: { type: 'string' },
calendarEventId: { type: 'string' }, calendarEventId: { type: 'string' },
updatedFields: { type: 'array', items: { type: 'string' } }, updatedFields: { type: 'array', items: { type: 'string' } },
fieldOutcomes: { fieldOutcomes: {
@@ -56,7 +56,7 @@ export default defineLogicFunction({
universalIdentifier: FIREFLIES_WEBHOOK_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER, universalIdentifier: FIREFLIES_WEBHOOK_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER,
name: 'fireflies-webhook', name: 'fireflies-webhook',
description: 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, timeoutSeconds: 60,
handler: firefliesWebhookRouteHandler, handler: firefliesWebhookRouteHandler,
httpRouteTriggerSettings: { httpRouteTriggerSettings: {
@@ -9,8 +9,9 @@ import {
import { getFirefliesApiKey } from 'src/logic-functions/utils/get-fireflies-api-key'; import { getFirefliesApiKey } from 'src/logic-functions/utils/get-fireflies-api-key';
import { import {
type FirefliesSyncableField, type FirefliesSyncableField,
syncFirefliesFieldToCalendarEvent, type SyncFirefliesCallResult,
} from 'src/logic-functions/utils/sync-fireflies-field-to-calendar-event'; syncFirefliesCallToCallRecording,
} from 'src/logic-functions/utils/sync-fireflies-call-to-call-recording';
const ALL_FIELDS: FirefliesSyncableField[] = ['transcript', 'summary']; const ALL_FIELDS: FirefliesSyncableField[] = ['transcript', 'summary'];
@@ -49,16 +50,19 @@ export const firefliesSyncCallHandler = async (
const client = new CoreApiClient(); const client = new CoreApiClient();
const results = await Promise.all( // Sequential: both fields upsert the same row, concurrent runs would race on the create.
ALL_FIELDS.map((field) => const results: SyncFirefliesCallResult[] = [];
syncFirefliesFieldToCalendarEvent({
for (const field of ALL_FIELDS) {
results.push(
await syncFirefliesCallToCallRecording({
apiKey: apiKeyResult.apiKey, apiKey: apiKeyResult.apiKey,
client, client,
transcriptId, transcriptId,
field, field,
}), }),
), );
); }
const fieldOutcomes: FirefliesSyncCallFieldOutcome[] = results.map( const fieldOutcomes: FirefliesSyncCallFieldOutcome[] = results.map(
(result) => { (result) => {
@@ -80,9 +84,10 @@ export const firefliesSyncCallHandler = async (
.filter((outcome) => outcome.status === 'updated') .filter((outcome) => outcome.status === 'updated')
.map((outcome) => outcome.field); .map((outcome) => outcome.field);
const calendarEventId = results.find( const updatedResult = results.find(
(result) => result.status === 'updated', (result): result is Extract<SyncFirefliesCallResult, { status: 'updated' }> =>
)?.calendarEventId; result.status === 'updated',
);
if (updatedFields.length === 0) { if (updatedFields.length === 0) {
const skipReasons = fieldOutcomes const skipReasons = fieldOutcomes
@@ -94,7 +99,7 @@ export const firefliesSyncCallHandler = async (
return { return {
success: false, 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.', error: [...errors, ...skipReasons].join(' | ') || 'No fields updated.',
transcriptId, transcriptId,
fieldOutcomes, fieldOutcomes,
@@ -111,14 +116,15 @@ export const firefliesSyncCallHandler = async (
partialFailures.length > 0 partialFailures.length > 0
? `Synced ${updatedFields.join( ? `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) .map((outcome) => outcome.field)
.join(', ')}).` .join(', ')}).`
: `Synced ${updatedFields.join( : `Synced ${updatedFields.join(
' + ', ' + ',
)} for Fireflies transcript ${transcriptId}.`, )} onto the CallRecording for Fireflies transcript ${transcriptId}.`,
transcriptId, transcriptId,
calendarEventId, callRecordingId: updatedResult?.callRecordingId,
calendarEventId: updatedResult?.calendarEventId,
updatedFields, updatedFields,
fieldOutcomes, fieldOutcomes,
}; };
@@ -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 { getFirefliesApiKey } from 'src/logic-functions/utils/get-fireflies-api-key';
import { import {
type FirefliesSyncableField, type FirefliesSyncableField,
syncFirefliesFieldToCalendarEvent, syncFirefliesCallToCallRecording,
} from 'src/logic-functions/utils/sync-fireflies-field-to-calendar-event'; } from 'src/logic-functions/utils/sync-fireflies-call-to-call-recording';
const TRANSCRIPT_READY_EVENT = 'meeting.transcribed'; const TRANSCRIPT_READY_EVENT = 'meeting.transcribed';
const SUMMARY_READY_EVENT = 'meeting.summarized'; const SUMMARY_READY_EVENT = 'meeting.summarized';
@@ -47,7 +47,7 @@ export const firefliesWebhookHandler = async ({
return { error: apiKeyResult.error, meetingId }; return { error: apiKeyResult.error, meetingId };
} }
const syncResult = await syncFirefliesFieldToCalendarEvent({ const syncResult = await syncFirefliesCallToCallRecording({
apiKey: apiKeyResult.apiKey, apiKey: apiKeyResult.apiKey,
client: new CoreApiClient(), client: new CoreApiClient(),
transcriptId: meetingId, transcriptId: meetingId,
@@ -65,6 +65,7 @@ export const firefliesWebhookHandler = async ({
return { return {
action: 'updated', action: 'updated',
field: syncResult.field, field: syncResult.field,
callRecordingId: syncResult.callRecordingId,
calendarEventId: syncResult.calendarEventId, calendarEventId: syncResult.calendarEventId,
meetingId, meetingId,
}; };
@@ -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 = export type FirefliesSyncCallFieldOutcome =
| { field: FirefliesSyncableField; status: 'updated' } | { field: FirefliesSyncableField; status: 'updated' }
@@ -10,6 +10,7 @@ export type FirefliesSyncCallResult = {
message: string; message: string;
error?: string; error?: string;
transcriptId?: string; transcriptId?: string;
callRecordingId?: string;
calendarEventId?: string; calendarEventId?: string;
updatedFields?: FirefliesSyncableField[]; updatedFields?: FirefliesSyncableField[];
fieldOutcomes?: FirefliesSyncCallFieldOutcome[]; fieldOutcomes?: FirefliesSyncCallFieldOutcome[];
@@ -2,6 +2,7 @@ export type FirefliesTranscriptSentence = {
speaker_name: string | null; speaker_name: string | null;
text: string; text: string;
start_time: number | null; start_time: number | null;
end_time?: number | null;
}; };
export type FirefliesSummary = { export type FirefliesSummary = {
@@ -11,7 +11,8 @@ export type FirefliesWebhookResult =
| { | {
action: 'updated'; action: 'updated';
field: FirefliesSyncedField; field: FirefliesSyncedField;
calendarEventId: string; callRecordingId: string;
calendarEventId?: string;
meetingId: string; meetingId: string;
} }
| { skipped: true; reason: string; meetingId?: string } | { skipped: true; reason: string; meetingId?: string }
@@ -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'),
);
});
});
@@ -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');
});
});
@@ -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([]);
});
});
@@ -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)}`;
};
@@ -9,6 +9,7 @@ const SUMMARY_QUERY = `
transcript(id: $transcriptId) { transcript(id: $transcriptId) {
id id
title title
date
duration duration
meeting_link meeting_link
participants participants
@@ -9,6 +9,7 @@ const TRANSCRIPT_QUERY = `
transcript(id: $transcriptId) { transcript(id: $transcriptId) {
id id
title title
date
duration duration
meeting_link meeting_link
participants participants
@@ -20,6 +21,7 @@ const TRANSCRIPT_QUERY = `
speaker_name speaker_name
text text
start_time start_time
end_time
} }
} }
} }
@@ -112,6 +112,6 @@ export const findMatchingCalendarEvent = async ({
return { return {
matched: false, matched: false,
reason: 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.',
}; };
}; };
@@ -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');
};
@@ -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 } }
: {}),
},
],
},
];
});
};
@@ -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<CallRecordingWriteFields, 'startedAt' | 'endedAt'> => {
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<SyncFirefliesCallResult> => {
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,
},
},
};
};
@@ -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<FirefliesApiResult<FirefliesTranscript>>;
format: (transcript: FirefliesTranscript) => string;
update: (args: {
client: CoreApiClient;
calendarEventId: string;
markdown: string;
}) => Promise<void>;
};
const FIELD_SYNC_STRATEGIES: Record<FirefliesSyncableField, FieldSyncStrategy> =
{
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<SyncFirefliesFieldResult> => {
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,
};
};
@@ -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<void> => {
await client.mutation({
updateCalendarEvent: {
__args: {
id: calendarEventId,
data: {
summary: {
markdown,
blocknote: null,
},
},
},
id: true,
},
});
};
@@ -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<void> => {
await client.mutation({
updateCalendarEvent: {
__args: {
id: calendarEventId,
data: {
transcript: {
markdown,
blocknote: null,
},
},
},
id: true,
},
});
};
@@ -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<string | undefined> => {
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<void> => {
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<UpsertCallRecordingResult> => {
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 };
}
};
@@ -9,7 +9,7 @@ export default defineApplicationRole({
universalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER, universalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
label: 'Twenty Fireflies sync role', label: 'Twenty Fireflies sync role',
description: 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, canReadAllObjectRecords: false,
canUpdateAllObjectRecords: false, canUpdateAllObjectRecords: false,
canSoftDeleteAllObjectRecords: false, canSoftDeleteAllObjectRecords: false,
@@ -23,7 +23,7 @@ export default defineApplicationRole({
objectUniversalIdentifier: objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.calendarEvent.universalIdentifier, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.calendarEvent.universalIdentifier,
canReadObjectRecords: true, canReadObjectRecords: true,
canUpdateObjectRecords: true, canUpdateObjectRecords: false,
canSoftDeleteObjectRecords: false, canSoftDeleteObjectRecords: false,
canDestroyObjectRecords: false, canDestroyObjectRecords: false,
}, },
@@ -36,6 +36,14 @@ export default defineApplicationRole({
canSoftDeleteObjectRecords: false, canSoftDeleteObjectRecords: false,
canDestroyObjectRecords: false, canDestroyObjectRecords: false,
}, },
{
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.callRecording.universalIdentifier,
canReadObjectRecords: true,
canUpdateObjectRecords: true,
canSoftDeleteObjectRecords: false,
canDestroyObjectRecords: false,
},
], ],
fieldPermissions: [], fieldPermissions: [],
permissionFlagUniversalIdentifiers: [], permissionFlagUniversalIdentifiers: [],
@@ -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,
});
@@ -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,
});
@@ -953,8 +953,8 @@ __metadata:
oxlint: "npm:^0.16.0" oxlint: "npm:^0.16.0"
react: "npm:^18.2.0" react: "npm:^18.2.0"
react-dom: "npm:^18.2.0" react-dom: "npm:^18.2.0"
twenty-client-sdk: "npm:^2.16.0" twenty-client-sdk: "npm:^2.18.0"
twenty-sdk: "npm:^2.16.0" twenty-sdk: "npm:^2.18.0"
typescript: "npm:^5.9.3" typescript: "npm:^5.9.3"
vite-tsconfig-paths: "npm:^4.2.1" vite-tsconfig-paths: "npm:^4.2.1"
vitest: "npm:^4.0.0" vitest: "npm:^4.0.0"
@@ -2888,22 +2888,22 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"twenty-client-sdk@npm:2.16.0, twenty-client-sdk@npm:^2.16.0": "twenty-client-sdk@npm:2.18.0, twenty-client-sdk@npm:^2.18.0":
version: 2.16.0 version: 2.18.0
resolution: "twenty-client-sdk@npm:2.16.0" resolution: "twenty-client-sdk@npm:2.18.0"
dependencies: dependencies:
"@genql/runtime": "npm:^2.10.0" "@genql/runtime": "npm:^2.10.0"
esbuild: "npm:^0.28.1" esbuild: "npm:^0.28.1"
graphql: "npm:^16.8.1" graphql: "npm:^16.8.1"
lodash: "npm:^4.17.21" lodash: "npm:^4.17.21"
prettier: "npm:^3.8.3" prettier: "npm:^3.8.3"
checksum: 10c0/f990220bec103a04ca50dc8bb38c295e94a4e51b27a3188f4a0ae515c204b76bf87f5591739eb705b97e7aa5a9aa6a215d0d4da05297e50a0392801ea64a2234 checksum: 10c0/3b7d6ca4e7b59c04cd2348af0f2ba8de153e70d565b4a38c2744b44998dfcc6c2bdb84ba6315cec208953d8c6fa7573ab33c17355f971f279299d470c57be06e
languageName: node languageName: node
linkType: hard linkType: hard
"twenty-sdk@npm:^2.16.0": "twenty-sdk@npm:^2.18.0":
version: 2.16.0 version: 2.18.0
resolution: "twenty-sdk@npm:2.16.0" resolution: "twenty-sdk@npm:2.18.0"
dependencies: dependencies:
"@sniptt/guards": "npm:^0.2.0" "@sniptt/guards": "npm:^0.2.0"
axios: "npm:^1.16.0" axios: "npm:^1.16.0"
@@ -2922,12 +2922,12 @@ __metadata:
semver: "npm:7.6.3" semver: "npm:7.6.3"
sharp: "npm:^0.34.5" sharp: "npm:^0.34.5"
tinyglobby: "npm:^0.2.15" tinyglobby: "npm:^0.2.15"
twenty-client-sdk: "npm:2.16.0" twenty-client-sdk: "npm:2.18.0"
typescript: "npm:^5.9.3" typescript: "npm:^5.9.3"
uuid: "npm:^13.0.2" uuid: "npm:^13.0.2"
bin: bin:
twenty: dist/cli.cjs twenty: dist/cli.cjs
checksum: 10c0/528f275f8bda022061a52c3563af514d2257f291fc5a232be90ca6011c50ff18a250984b6c68fc8e4c690644c92e1ccb0b89c511b8a19d832455c8860f2751a8 checksum: 10c0/7e6d300894c427029e4124047614b55988671c914332613acb38f830cee11ecc480ff2a586b6153756aab1b7fa7f4520cf072c9db1428cd9ce9b9f7a62dfcd64
languageName: node languageName: node
linkType: hard linkType: hard