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:
@@ -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"
|
||||||
|
|||||||
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 1.4 MiB |
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 1.5 MiB |
+88
@@ -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';
|
||||||
|
|
||||||
|
|||||||
+9
@@ -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;
|
||||||
+2
-1
@@ -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: {
|
||||||
|
|||||||
+1
-1
@@ -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: {
|
||||||
|
|||||||
+20
-14
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
+4
-3
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
+2
-1
@@ -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[];
|
||||||
|
|||||||
+1
@@ -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 = {
|
||||||
|
|||||||
+2
-1
@@ -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 }
|
||||||
|
|||||||
+26
@@ -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'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
-87
@@ -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');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
+79
@@ -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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
+18
@@ -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)}`;
|
||||||
|
};
|
||||||
+1
@@ -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
|
||||||
|
|||||||
+2
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -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.',
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
-57
@@ -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');
|
|
||||||
};
|
|
||||||
+63
@@ -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 } }
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
});
|
||||||
|
};
|
||||||
+186
@@ -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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
-102
@@ -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,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
-26
@@ -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,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
-26
@@ -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,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
+96
@@ -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: [],
|
||||||
|
|||||||
-20
@@ -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,
|
|
||||||
});
|
|
||||||
-20
@@ -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
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user