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
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).
@@ -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"
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 { 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<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,
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',
],
@@ -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';
@@ -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,
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: {
@@ -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: {
@@ -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<SyncFirefliesCallResult, { status: 'updated' }> =>
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,
};
@@ -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,
};
@@ -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[];
@@ -2,6 +2,7 @@ export type FirefliesTranscriptSentence = {
speaker_name: string | null;
text: string;
start_time: number | null;
end_time?: number | null;
};
export type FirefliesSummary = {
@@ -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 }
@@ -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) {
id
title
date
duration
meeting_link
participants
@@ -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
}
}
}
@@ -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.',
};
};
@@ -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,
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: [],
@@ -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"
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