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
|
||||
|
||||
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"
|
||||
|
||||
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 { 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';
|
||||
|
||||
|
||||
+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,
|
||||
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: {
|
||||
|
||||
+1
-1
@@ -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: {
|
||||
|
||||
+20
-14
@@ -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,
|
||||
};
|
||||
|
||||
+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 {
|
||||
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,
|
||||
};
|
||||
|
||||
+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 =
|
||||
| { 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[];
|
||||
|
||||
+1
@@ -2,6 +2,7 @@ export type FirefliesTranscriptSentence = {
|
||||
speaker_name: string | null;
|
||||
text: string;
|
||||
start_time: number | null;
|
||||
end_time?: number | null;
|
||||
};
|
||||
|
||||
export type FirefliesSummary = {
|
||||
|
||||
+2
-1
@@ -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 }
|
||||
|
||||
+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) {
|
||||
id
|
||||
title
|
||||
date
|
||||
duration
|
||||
meeting_link
|
||||
participants
|
||||
|
||||
+2
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -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.',
|
||||
};
|
||||
};
|
||||
|
||||
-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,
|
||||
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: [],
|
||||
|
||||
-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"
|
||||
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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user