@twentyhq/call-recorder 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (229) hide show
  1. package/.env.example +5 -0
  2. package/.nvmrc +1 -0
  3. package/.oxlintrc.json +20 -0
  4. package/.yarnrc.yml +1 -0
  5. package/AGENTS.md +67 -0
  6. package/CLAUDE.md +67 -0
  7. package/README.md +24 -0
  8. package/SETUP.md +95 -0
  9. package/package.json +42 -0
  10. package/public/gallery/call-recorder-cover.png +0 -0
  11. package/public/logo.svg +5 -0
  12. package/src/__tests__/global-setup.ts +100 -0
  13. package/src/__tests__/schema.integration-test.ts +104 -0
  14. package/src/application-config.ts +96 -0
  15. package/src/constants/__tests__/call-recording-field-universal-identifiers.test.ts +19 -0
  16. package/src/constants/app-description.ts +2 -0
  17. package/src/constants/app-display-name.ts +1 -0
  18. package/src/constants/application-universal-identifier.ts +2 -0
  19. package/src/constants/calendar-event-reconciliation-logic-function-universal-identifier.ts +2 -0
  20. package/src/constants/calendar-event-record-page-layout-universal-identifier.ts +2 -0
  21. package/src/constants/calendar-event-recording-front-component-universal-identifier.ts +2 -0
  22. package/src/constants/calendar-event-recording-page-layout-tab-universal-identifier.ts +2 -0
  23. package/src/constants/calendar-event-recording-page-layout-widget-universal-identifier.ts +2 -0
  24. package/src/constants/call-recorder-everyone-left-timeout-seconds-app-variable-universal-identifier.ts +2 -0
  25. package/src/constants/call-recorder-failure-reason-on-call-recording-field-universal-identifier.ts +2 -0
  26. package/src/constants/call-recorder-join-early-minutes-app-variable-universal-identifier.ts +2 -0
  27. package/src/constants/call-recorder-name-app-variable-universal-identifier.ts +2 -0
  28. package/src/constants/call-recorder-noone-joined-timeout-seconds-app-variable-universal-identifier.ts +2 -0
  29. package/src/constants/call-recorder-preference-off-option-id.ts +2 -0
  30. package/src/constants/call-recorder-preference-on-calendar-event-field-universal-identifier.ts +2 -0
  31. package/src/constants/call-recorder-preference-on-calendar-event-view-field-universal-identifier.ts +2 -0
  32. package/src/constants/call-recorder-preference-on-option-id.ts +2 -0
  33. package/src/constants/call-recorder-preference.ts +4 -0
  34. package/src/constants/call-recorder-waiting-room-timeout-seconds-app-variable-universal-identifier.ts +2 -0
  35. package/src/constants/call-recording-audio-field-universal-identifier.ts +2 -0
  36. package/src/constants/call-recording-video-field-universal-identifier.ts +2 -0
  37. package/src/constants/default-role-universal-identifier.ts +2 -0
  38. package/src/constants/process-recall-webhook-logic-function-universal-identifier.ts +2 -0
  39. package/src/constants/recall-webhook-logic-function-universal-identifier.ts +2 -0
  40. package/src/constants/stale-bot-state-logic-function-universal-identifier.ts +2 -0
  41. package/src/default-role.ts +69 -0
  42. package/src/fields/call-recorder-failure-reason-on-call-recording.field.ts +22 -0
  43. package/src/fields/call-recorder-preference-on-calendar-event.field.ts +41 -0
  44. package/src/front-components/calendar-event-recording.front-component.tsx +13 -0
  45. package/src/front-components/components/CalendarEventRecording.tsx +39 -0
  46. package/src/front-components/components/CalendarEventRecordingBody.tsx +96 -0
  47. package/src/front-components/components/CalendarEventRecordingContent.tsx +111 -0
  48. package/src/front-components/components/RecordingTranscript.tsx +92 -0
  49. package/src/front-components/components/RecordingVideoPlayer.tsx +52 -0
  50. package/src/front-components/components/TranscriptEntryList.tsx +61 -0
  51. package/src/front-components/components/TranscriptEntryListItem.tsx +115 -0
  52. package/src/front-components/components/TranscriptErrorBox.tsx +48 -0
  53. package/src/front-components/components/TranscriptSpeakerAvatar.tsx +141 -0
  54. package/src/front-components/components/TranscriptSpeakerChip.tsx +51 -0
  55. package/src/front-components/constants/recording-theme-css-variables.ts +40 -0
  56. package/src/front-components/hooks/use-calendar-event-participants.ts +172 -0
  57. package/src/front-components/hooks/use-calendar-event-recording.ts +155 -0
  58. package/src/front-components/types/calendar-event-participant-by-speaker-name.type.ts +6 -0
  59. package/src/front-components/types/calendar-event-recording-participant.type.ts +7 -0
  60. package/src/front-components/types/transcript-entry.type.ts +13 -0
  61. package/src/front-components/utils/__tests__/find-active-transcript-entry-index.test.ts +66 -0
  62. package/src/front-components/utils/__tests__/format-transcript-timestamp.test.ts +29 -0
  63. package/src/front-components/utils/__tests__/get-speaker-name-match-keys.test.ts +22 -0
  64. package/src/front-components/utils/__tests__/parse-transcript-entries.test.ts +162 -0
  65. package/src/front-components/utils/build-calendar-event-participant-by-speaker-name.util.ts +45 -0
  66. package/src/front-components/utils/find-active-transcript-entry-index.util.ts +77 -0
  67. package/src/front-components/utils/format-transcript-timestamp.util.ts +16 -0
  68. package/src/front-components/utils/get-absolute-avatar-url.util.ts +48 -0
  69. package/src/front-components/utils/get-calendar-event-participant-for-speaker-name.util.ts +24 -0
  70. package/src/front-components/utils/get-speaker-name-match-keys.util.ts +64 -0
  71. package/src/front-components/utils/get-video-file-extension.util.ts +23 -0
  72. package/src/front-components/utils/parse-transcript-entries.util.ts +85 -0
  73. package/src/logic-functions/__tests__/process-recall-webhook.test.ts +62 -0
  74. package/src/logic-functions/__tests__/recall-webhook.test.ts +180 -0
  75. package/src/logic-functions/constants/call-recorder-everyone-left-timeout-seconds-env-var-name.ts +2 -0
  76. package/src/logic-functions/constants/call-recorder-everyone-left-timeout-seconds.ts +1 -0
  77. package/src/logic-functions/constants/call-recorder-join-early-minutes-env-var-name.ts +2 -0
  78. package/src/logic-functions/constants/call-recorder-name-env-var-name.ts +1 -0
  79. package/src/logic-functions/constants/call-recorder-noone-joined-timeout-seconds-env-var-name.ts +2 -0
  80. package/src/logic-functions/constants/call-recorder-noone-joined-timeout-seconds.ts +1 -0
  81. package/src/logic-functions/constants/call-recorder-recording-retention-hours-env-var-name.ts +2 -0
  82. package/src/logic-functions/constants/call-recorder-waiting-room-timeout-seconds-env-var-name.ts +2 -0
  83. package/src/logic-functions/constants/call-recorder-waiting-room-timeout-seconds.ts +1 -0
  84. package/src/logic-functions/constants/call-recording-micro-credits-per-hour.ts +1 -0
  85. package/src/logic-functions/constants/call-recording-request-status.ts +5 -0
  86. package/src/logic-functions/constants/call-recording-status.ts +9 -0
  87. package/src/logic-functions/constants/default-call-recorder-join-early-minutes.ts +1 -0
  88. package/src/logic-functions/constants/default-call-recorder-name.ts +1 -0
  89. package/src/logic-functions/constants/default-call-recorder-recording-retention-hours.ts +2 -0
  90. package/src/logic-functions/constants/default-recall-region.ts +1 -0
  91. package/src/logic-functions/constants/milliseconds-per-minute.ts +1 -0
  92. package/src/logic-functions/constants/non-terminal-call-recording-statuses.ts +8 -0
  93. package/src/logic-functions/constants/recall-api-key-env-var-name.ts +1 -0
  94. package/src/logic-functions/constants/recall-api-max-attempts.ts +1 -0
  95. package/src/logic-functions/constants/recall-api-retry-delay-ms.ts +1 -0
  96. package/src/logic-functions/constants/recall-bot-automatic-leave.ts +74 -0
  97. package/src/logic-functions/constants/recall-bot-everyone-left-min-activate-after-seconds.ts +1 -0
  98. package/src/logic-functions/constants/recall-bot-recording-config.ts +34 -0
  99. package/src/logic-functions/constants/recall-region-env-var-name.ts +1 -0
  100. package/src/logic-functions/constants/recall-webhook-secret-env-var-name.ts +1 -0
  101. package/src/logic-functions/constants/restricted-field-placeholder.ts +3 -0
  102. package/src/logic-functions/constants/stale-bot-state-cron-pattern.ts +1 -0
  103. package/src/logic-functions/constants/twenty-page-size.ts +1 -0
  104. package/src/logic-functions/data/__tests__/complete-call-recording-ingestion.test.ts +55 -0
  105. package/src/logic-functions/data/__tests__/fetch-all-nodes.test.ts +43 -0
  106. package/src/logic-functions/data/__tests__/get-current-workspace-id.test.ts +38 -0
  107. package/src/logic-functions/data/__tests__/strip-restricted-field-value.test.ts +22 -0
  108. package/src/logic-functions/data/complete-call-recording-ingestion.util.ts +24 -0
  109. package/src/logic-functions/data/create-call-recording.util.ts +41 -0
  110. package/src/logic-functions/data/fetch-all-nodes.util.ts +44 -0
  111. package/src/logic-functions/data/fetch-calendar-events-by-filter.util.ts +80 -0
  112. package/src/logic-functions/data/fetch-calendar-events-by-ids.util.ts +20 -0
  113. package/src/logic-functions/data/fetch-calendar-events-by-starts-at-values.util.ts +19 -0
  114. package/src/logic-functions/data/find-call-recordings-by-calendar-event-ids.util.ts +17 -0
  115. package/src/logic-functions/data/find-call-recordings-by-filter.util.ts +102 -0
  116. package/src/logic-functions/data/find-call-recordings-by-ids.util.ts +17 -0
  117. package/src/logic-functions/data/find-open-scheduled-call-recordings.util.ts +14 -0
  118. package/src/logic-functions/data/get-current-workspace-id.util.ts +36 -0
  119. package/src/logic-functions/data/strip-restricted-field-value.util.ts +6 -0
  120. package/src/logic-functions/data/update-call-recording.util.ts +24 -0
  121. package/src/logic-functions/domain/__tests__/build-call-recorder-policy-result.test.ts +47 -0
  122. package/src/logic-functions/domain/__tests__/compute-call-recording-charge.test.ts +71 -0
  123. package/src/logic-functions/domain/__tests__/compute-call-recording-id-for-meeting.test.ts +37 -0
  124. package/src/logic-functions/domain/__tests__/compute-real-meeting-key.test.ts +88 -0
  125. package/src/logic-functions/domain/__tests__/is-call-recording-ingestion-complete.test.ts +59 -0
  126. package/src/logic-functions/domain/__tests__/is-call-recording-status-downgrade.test.ts +37 -0
  127. package/src/logic-functions/domain/__tests__/resolve-call-recorder-policy-result.test.ts +120 -0
  128. package/src/logic-functions/domain/__tests__/should-complete-call-recording-ingestion.test.ts +102 -0
  129. package/src/logic-functions/domain/aggregate-call-recorder-policy-results-by-meeting.util.ts +42 -0
  130. package/src/logic-functions/domain/build-call-recorder-policy-result.util.ts +53 -0
  131. package/src/logic-functions/domain/build-failed-transcript-marker.util.ts +13 -0
  132. package/src/logic-functions/domain/build-pending-transcript-marker.util.ts +13 -0
  133. package/src/logic-functions/domain/build-recall-routing-metadata.util.ts +12 -0
  134. package/src/logic-functions/domain/build-transcript-failure-reason.util.ts +7 -0
  135. package/src/logic-functions/domain/compute-call-recording-charge.util.ts +41 -0
  136. package/src/logic-functions/domain/compute-call-recording-id-for-meeting.util.ts +16 -0
  137. package/src/logic-functions/domain/compute-real-meeting-key.util.ts +48 -0
  138. package/src/logic-functions/domain/compute-recall-bot-join-at.util.ts +34 -0
  139. package/src/logic-functions/domain/is-call-recording-ingestion-complete.util.ts +19 -0
  140. package/src/logic-functions/domain/is-call-recording-status-downgrade.util.ts +37 -0
  141. package/src/logic-functions/domain/is-recall-recording-done-signal.util.ts +13 -0
  142. package/src/logic-functions/domain/map-recall-status-code-to-call-recording-status.util.ts +26 -0
  143. package/src/logic-functions/domain/parse-transcript-marker.util.ts +29 -0
  144. package/src/logic-functions/domain/resolve-call-recorder-policy-result.util.ts +72 -0
  145. package/src/logic-functions/domain/should-complete-call-recording-ingestion.util.ts +32 -0
  146. package/src/logic-functions/flows/__tests__/charge-completed-call-recording.test.ts +45 -0
  147. package/src/logic-functions/flows/__tests__/complete-and-charge-call-recording.test.ts +61 -0
  148. package/src/logic-functions/flows/__tests__/converge-diverged-call-recordings.test.ts +727 -0
  149. package/src/logic-functions/flows/__tests__/download-transcript.test.ts +74 -0
  150. package/src/logic-functions/flows/__tests__/handle-recall-webhook.test.ts +1301 -0
  151. package/src/logic-functions/flows/__tests__/heal-call-recordings-missing-bot.test.ts +225 -0
  152. package/src/logic-functions/flows/__tests__/ingest-call-recording-media.test.ts +153 -0
  153. package/src/logic-functions/flows/__tests__/reap-orphaned-call-recorders.test.ts +425 -0
  154. package/src/logic-functions/flows/__tests__/reconcile-call-recorder.test.ts +1007 -0
  155. package/src/logic-functions/flows/cancel-call-recording-request.util.ts +46 -0
  156. package/src/logic-functions/flows/charge-completed-call-recording.util.ts +31 -0
  157. package/src/logic-functions/flows/complete-and-charge-call-recording.util.ts +29 -0
  158. package/src/logic-functions/flows/converge-diverged-call-recordings-result.type.ts +8 -0
  159. package/src/logic-functions/flows/converge-diverged-call-recordings.util.ts +447 -0
  160. package/src/logic-functions/flows/download-transcript.util.ts +67 -0
  161. package/src/logic-functions/flows/ensure-call-recorder.util.ts +73 -0
  162. package/src/logic-functions/flows/handle-recall-webhook.util.ts +672 -0
  163. package/src/logic-functions/flows/heal-call-recordings-missing-bot.util.ts +82 -0
  164. package/src/logic-functions/flows/ingest-call-recording-media.util.ts +128 -0
  165. package/src/logic-functions/flows/persist-call-recording-progress.util.ts +58 -0
  166. package/src/logic-functions/flows/reap-orphaned-call-recorders.util.ts +183 -0
  167. package/src/logic-functions/flows/reconcile-call-recorder.util.ts +495 -0
  168. package/src/logic-functions/flows/reconcile-call-recording-transcript-artifact-result.type.ts +11 -0
  169. package/src/logic-functions/flows/reconcile-call-recording-transcript-artifact.util.ts +182 -0
  170. package/src/logic-functions/flows/reschedule-call-recording-bot.util.ts +69 -0
  171. package/src/logic-functions/process-recall-webhook.ts +23 -0
  172. package/src/logic-functions/recall-api/__tests__/extract-recall-bot-convergence.test.ts +153 -0
  173. package/src/logic-functions/recall-api/__tests__/extract-recall-media-urls.test.ts +67 -0
  174. package/src/logic-functions/recall-api/__tests__/recall-bot-api.test.ts +744 -0
  175. package/src/logic-functions/recall-api/__tests__/verify-recall-webhook-signature.test.ts +122 -0
  176. package/src/logic-functions/recall-api/cancel-recall-bot.util.ts +28 -0
  177. package/src/logic-functions/recall-api/create-async-recall-transcript.util.ts +47 -0
  178. package/src/logic-functions/recall-api/eject-recall-bot.util.ts +28 -0
  179. package/src/logic-functions/recall-api/extract-recall-bot-convergence.util.ts +149 -0
  180. package/src/logic-functions/recall-api/extract-recall-bot-id.util.ts +10 -0
  181. package/src/logic-functions/recall-api/extract-recall-media-urls.util.ts +30 -0
  182. package/src/logic-functions/recall-api/extract-twenty-workspace-id-from-recall-webhook.util.ts +8 -0
  183. package/src/logic-functions/recall-api/get-recall-api-config.util.ts +59 -0
  184. package/src/logic-functions/recall-api/get-recall-bot.util.ts +42 -0
  185. package/src/logic-functions/recall-api/get-recall-recording.util.ts +31 -0
  186. package/src/logic-functions/recall-api/get-recall-webhook-bot-metadata.util.ts +18 -0
  187. package/src/logic-functions/recall-api/list-recall-transcripts.util.ts +141 -0
  188. package/src/logic-functions/recall-api/list-scheduled-recall-bots.util.ts +106 -0
  189. package/src/logic-functions/recall-api/normalize-recall-timestamp.util.ts +14 -0
  190. package/src/logic-functions/recall-api/parse-recall-webhook-event.util.ts +88 -0
  191. package/src/logic-functions/recall-api/recall-bot-api-request.util.ts +165 -0
  192. package/src/logic-functions/recall-api/recall-transcript-summary.type.ts +5 -0
  193. package/src/logic-functions/recall-api/reschedule-recall-bot.util.ts +56 -0
  194. package/src/logic-functions/recall-api/retrieve-recall-transcript.util.ts +71 -0
  195. package/src/logic-functions/recall-api/schedule-recall-bot.util.ts +68 -0
  196. package/src/logic-functions/recall-api/verify-recall-webhook-signature.util.ts +109 -0
  197. package/src/logic-functions/recall-webhook.ts +90 -0
  198. package/src/logic-functions/reconcile-call-recorder-calendar-event.ts +178 -0
  199. package/src/logic-functions/reconcile-stale-bot-state.ts +106 -0
  200. package/src/logic-functions/types/calendar-event-record.type.ts +5 -0
  201. package/src/logic-functions/types/call-recorder-policy-calendar-event-input.type.ts +10 -0
  202. package/src/logic-functions/types/call-recorder-policy-input.type.ts +9 -0
  203. package/src/logic-functions/types/call-recorder-policy-not-required-reason.type.ts +5 -0
  204. package/src/logic-functions/types/call-recorder-policy-required-reason.type.ts +1 -0
  205. package/src/logic-functions/types/call-recorder-policy-result-for-calendar-event.type.ts +9 -0
  206. package/src/logic-functions/types/call-recorder-policy-result-for-meeting.type.ts +6 -0
  207. package/src/logic-functions/types/call-recorder-policy-result.type.ts +12 -0
  208. package/src/logic-functions/types/call-recorder-reconciliation-result.type.ts +16 -0
  209. package/src/logic-functions/types/call-recording-media-file.type.ts +1 -0
  210. package/src/logic-functions/types/call-recording-record.type.ts +15 -0
  211. package/src/logic-functions/types/call-recording-update-fields.type.ts +20 -0
  212. package/src/logic-functions/types/files-field-value.type.ts +1 -0
  213. package/src/logic-functions/types/meeting-recording.type.ts +7 -0
  214. package/src/logic-functions/types/recall-bot-operation-result.type.ts +19 -0
  215. package/src/logic-functions/types/recall-routing-metadata.type.ts +4 -0
  216. package/src/logic-functions/types/removed-call-recorder-occurrence.type.ts +6 -0
  217. package/src/logic-functions/types/transcript-marker.type.ts +6 -0
  218. package/src/logic-functions/utils/as-record.util.ts +6 -0
  219. package/src/logic-functions/utils/get-application-variable-value.util.ts +3 -0
  220. package/src/logic-functions/utils/get-record-at-path.util.ts +10 -0
  221. package/src/logic-functions/utils/get-string.util.ts +4 -0
  222. package/src/logic-functions/utils/get-unique-sorted-ids.util.ts +8 -0
  223. package/src/logic-functions/utils/is-non-empty-string.util.ts +5 -0
  224. package/src/page-layouts/calendar-event-recording-tab.ts +33 -0
  225. package/src/view-fields/call-recorder-preference-on-calendar-event.view-field.ts +27 -0
  226. package/tsconfig.json +42 -0
  227. package/tsconfig.spec.json +9 -0
  228. package/vitest.config.ts +31 -0
  229. package/vitest.unit.config.ts +14 -0
@@ -0,0 +1,1301 @@
1
+ import { type CoreApiClient } from 'twenty-client-sdk/core';
2
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
3
+
4
+ import { handleRecallWebhook } from 'src/logic-functions/flows/handle-recall-webhook.util';
5
+
6
+ const WORKSPACE_ID = '123e4567-e89b-12d3-a456-426614174000';
7
+
8
+ const buildRecordingDoneWebhookBody = () => ({
9
+ event: 'recording.done',
10
+ data: {
11
+ bot: {
12
+ id: 'recall-bot-1',
13
+ metadata: {
14
+ twentyWorkspaceId: WORKSPACE_ID,
15
+ twentyCallRecordingId: 'call-recording-1',
16
+ },
17
+ },
18
+ recording: {
19
+ id: 'recall-recording-1',
20
+ },
21
+ },
22
+ });
23
+
24
+ const getRecallBotMock = vi.hoisted(() => vi.fn());
25
+ const listRecallTranscriptsMock = vi.hoisted(() => vi.fn());
26
+ const createAsyncRecallTranscriptMock = vi.hoisted(() => vi.fn());
27
+ const retrieveRecallTranscriptMock = vi.hoisted(() => vi.fn());
28
+ const ingestCallRecordingMediaMock = vi.hoisted(() => vi.fn());
29
+ const chargeCompletedCallRecordingMock = vi.hoisted(() => vi.fn());
30
+
31
+ vi.mock('src/logic-functions/recall-api/get-recall-bot.util', () => ({
32
+ getRecallBot: getRecallBotMock,
33
+ }));
34
+
35
+ vi.mock('src/logic-functions/recall-api/list-recall-transcripts.util', () => ({
36
+ listRecallTranscripts: listRecallTranscriptsMock,
37
+ }));
38
+
39
+ vi.mock(
40
+ 'src/logic-functions/recall-api/create-async-recall-transcript.util',
41
+ () => ({
42
+ createAsyncRecallTranscript: createAsyncRecallTranscriptMock,
43
+ }),
44
+ );
45
+
46
+ vi.mock(
47
+ 'src/logic-functions/recall-api/retrieve-recall-transcript.util',
48
+ () => ({
49
+ retrieveRecallTranscript: retrieveRecallTranscriptMock,
50
+ }),
51
+ );
52
+
53
+ vi.mock('src/logic-functions/flows/ingest-call-recording-media.util', () => ({
54
+ ingestCallRecordingMedia: ingestCallRecordingMediaMock,
55
+ }));
56
+
57
+ vi.mock(
58
+ 'src/logic-functions/flows/charge-completed-call-recording.util',
59
+ () => ({
60
+ chargeCompletedCallRecording: chargeCompletedCallRecordingMock,
61
+ }),
62
+ );
63
+
64
+ type CallRecordingNode = {
65
+ id: string;
66
+ status?: string | null;
67
+ externalBotId?: string | null;
68
+ externalRecordingId?: string | null;
69
+ startedAt?: string | null;
70
+ endedAt?: string | null;
71
+ transcript?: unknown;
72
+ audio?: unknown;
73
+ video?: unknown;
74
+ };
75
+
76
+ class FakeCoreApiClient {
77
+ callRecordings: CallRecordingNode[];
78
+ mutations: Array<{ id: string; data: Record<string, unknown> }> = [];
79
+
80
+ constructor(callRecordings: CallRecordingNode[]) {
81
+ this.callRecordings = callRecordings;
82
+ }
83
+
84
+ async query(query: any): Promise<any> {
85
+ if (query.callRecordings !== undefined) {
86
+ const filter = query.callRecordings.__args.filter;
87
+
88
+ return {
89
+ callRecordings: {
90
+ edges: this.filterCallRecordings(filter).map((callRecording) => ({
91
+ node: callRecording,
92
+ })),
93
+ },
94
+ };
95
+ }
96
+
97
+ throw new Error(`Unhandled query: ${JSON.stringify(query)}`);
98
+ }
99
+
100
+ async mutation(mutation: any): Promise<any> {
101
+ if (mutation.updateCallRecordings !== undefined) {
102
+ const { filter, data } = mutation.updateCallRecordings.__args;
103
+ const id = filter.id.eq;
104
+
105
+ this.mutations.push({ id, data });
106
+
107
+ return { updateCallRecordings: [{ id }] };
108
+ }
109
+
110
+ if (mutation.updateCallRecording !== undefined) {
111
+ const { id, data } = mutation.updateCallRecording.__args;
112
+
113
+ this.mutations.push({ id, data });
114
+
115
+ return {
116
+ updateCallRecording: {
117
+ id,
118
+ },
119
+ };
120
+ }
121
+
122
+ throw new Error(`Unhandled mutation: ${JSON.stringify(mutation)}`);
123
+ }
124
+
125
+ private filterCallRecordings(filter: any): CallRecordingNode[] {
126
+ if (filter.id?.eq !== undefined) {
127
+ return this.callRecordings.filter(
128
+ (callRecording) => callRecording.id === filter.id.eq,
129
+ );
130
+ }
131
+
132
+ if (filter.externalBotId?.eq !== undefined) {
133
+ return this.callRecordings.filter(
134
+ (callRecording) =>
135
+ callRecording.externalBotId === filter.externalBotId.eq,
136
+ );
137
+ }
138
+
139
+ throw new Error(
140
+ `Unhandled call recording filter: ${JSON.stringify(filter)}`,
141
+ );
142
+ }
143
+ }
144
+
145
+ describe('handleRecallWebhook', () => {
146
+ beforeEach(() => {
147
+ vi.spyOn(console, 'warn').mockImplementation(() => {});
148
+ getRecallBotMock.mockReset();
149
+ getRecallBotMock.mockResolvedValue({
150
+ ok: false,
151
+ status: null,
152
+ errorMessage: 'bot fetch disabled in test',
153
+ });
154
+ listRecallTranscriptsMock.mockReset();
155
+ listRecallTranscriptsMock.mockResolvedValue({
156
+ ok: true,
157
+ transcripts: [],
158
+ });
159
+ createAsyncRecallTranscriptMock.mockReset();
160
+ createAsyncRecallTranscriptMock.mockResolvedValue({
161
+ ok: false,
162
+ status: null,
163
+ errorMessage: 'transcript request disabled in test',
164
+ });
165
+ retrieveRecallTranscriptMock.mockReset();
166
+ retrieveRecallTranscriptMock.mockResolvedValue({
167
+ ok: false,
168
+ status: null,
169
+ errorMessage: 'transcript retrieval disabled in test',
170
+ });
171
+ ingestCallRecordingMediaMock.mockReset();
172
+ ingestCallRecordingMediaMock.mockResolvedValue({});
173
+ chargeCompletedCallRecordingMock.mockReset();
174
+ chargeCompletedCallRecordingMock.mockResolvedValue(undefined);
175
+ });
176
+
177
+ it('updates a call recording from bot metadata on status change events', async () => {
178
+ const client = new FakeCoreApiClient([
179
+ {
180
+ id: 'call-recording-1',
181
+ status: 'JOINING',
182
+ externalBotId: 'recall-bot-1',
183
+ },
184
+ ]);
185
+
186
+ const result = await handleRecallWebhook({
187
+ client: client as unknown as CoreApiClient,
188
+ body: {
189
+ event: 'bot.status_change',
190
+ data: {
191
+ bot: {
192
+ id: 'recall-bot-1',
193
+ metadata: {
194
+ twentyWorkspaceId: WORKSPACE_ID,
195
+ twentyCallRecordingId: 'call-recording-1',
196
+ },
197
+ },
198
+ status: {
199
+ code: 'in_call_recording',
200
+ },
201
+ },
202
+ },
203
+ });
204
+
205
+ expect(result).toEqual({
206
+ status: 'updated',
207
+ event: 'bot.status_change',
208
+ callRecordingId: 'call-recording-1',
209
+ callRecordingStatus: 'RECORDING',
210
+ });
211
+ expect(client.mutations).toEqual([
212
+ {
213
+ id: 'call-recording-1',
214
+ data: {
215
+ status: 'RECORDING',
216
+ externalBotId: 'recall-bot-1',
217
+ },
218
+ },
219
+ ]);
220
+ });
221
+
222
+ it('reads bot metadata nested under data when a top-level bot has none', async () => {
223
+ const client = new FakeCoreApiClient([
224
+ {
225
+ id: 'call-recording-1',
226
+ status: 'JOINING',
227
+ externalBotId: 'recall-bot-1',
228
+ },
229
+ ]);
230
+
231
+ const result = await handleRecallWebhook({
232
+ client: client as unknown as CoreApiClient,
233
+ body: {
234
+ event: 'bot.status_change',
235
+ bot: {
236
+ id: 'recall-bot-1',
237
+ },
238
+ data: {
239
+ bot: {
240
+ id: 'recall-bot-1',
241
+ metadata: {
242
+ twentyWorkspaceId: WORKSPACE_ID,
243
+ twentyCallRecordingId: 'call-recording-1',
244
+ },
245
+ },
246
+ status: {
247
+ code: 'in_call_recording',
248
+ },
249
+ },
250
+ },
251
+ });
252
+
253
+ expect(result).toEqual({
254
+ status: 'updated',
255
+ event: 'bot.status_change',
256
+ callRecordingId: 'call-recording-1',
257
+ callRecordingStatus: 'RECORDING',
258
+ });
259
+ });
260
+
261
+ it('matches by metadata id when the recording carries no external bot id', async () => {
262
+ const client = new FakeCoreApiClient([
263
+ {
264
+ id: 'call-recording-1',
265
+ status: 'SCHEDULED',
266
+ externalBotId: null,
267
+ },
268
+ ]);
269
+
270
+ const result = await handleRecallWebhook({
271
+ client: client as unknown as CoreApiClient,
272
+ body: {
273
+ event: 'bot.status_change',
274
+ data: {
275
+ bot: {
276
+ id: 'recall-bot-1',
277
+ metadata: {
278
+ twentyWorkspaceId: WORKSPACE_ID,
279
+ twentyCallRecordingId: 'call-recording-1',
280
+ },
281
+ },
282
+ status: {
283
+ code: 'in_call_recording',
284
+ },
285
+ },
286
+ },
287
+ });
288
+
289
+ expect(result).toEqual({
290
+ status: 'updated',
291
+ event: 'bot.status_change',
292
+ callRecordingId: 'call-recording-1',
293
+ callRecordingStatus: 'RECORDING',
294
+ });
295
+ expect(client.mutations).toEqual([
296
+ {
297
+ id: 'call-recording-1',
298
+ data: {
299
+ status: 'RECORDING',
300
+ externalBotId: 'recall-bot-1',
301
+ },
302
+ },
303
+ ]);
304
+ });
305
+
306
+ it('prefers the metadata id over a different recording carrying the bot id', async () => {
307
+ const client = new FakeCoreApiClient([
308
+ {
309
+ id: 'call-recording-stale',
310
+ status: 'SCHEDULED',
311
+ externalBotId: 'recall-bot-1',
312
+ },
313
+ {
314
+ id: 'call-recording-current',
315
+ status: 'SCHEDULED',
316
+ externalBotId: null,
317
+ },
318
+ ]);
319
+
320
+ const result = await handleRecallWebhook({
321
+ client: client as unknown as CoreApiClient,
322
+ body: {
323
+ event: 'bot.status_change',
324
+ data: {
325
+ bot: {
326
+ id: 'recall-bot-1',
327
+ metadata: {
328
+ twentyWorkspaceId: WORKSPACE_ID,
329
+ twentyCallRecordingId: 'call-recording-current',
330
+ },
331
+ },
332
+ status: {
333
+ code: 'in_call_recording',
334
+ },
335
+ },
336
+ },
337
+ });
338
+
339
+ expect(result).toEqual({
340
+ status: 'updated',
341
+ event: 'bot.status_change',
342
+ callRecordingId: 'call-recording-current',
343
+ callRecordingStatus: 'RECORDING',
344
+ });
345
+ expect(client.mutations).toEqual([
346
+ {
347
+ id: 'call-recording-current',
348
+ data: {
349
+ status: 'RECORDING',
350
+ externalBotId: 'recall-bot-1',
351
+ },
352
+ },
353
+ ]);
354
+ });
355
+
356
+ it('falls back to external bot id matching when call recording metadata is absent', async () => {
357
+ const client = new FakeCoreApiClient([
358
+ {
359
+ id: 'call-recording-1',
360
+ status: 'PROCESSING',
361
+ externalBotId: 'recall-bot-1',
362
+ },
363
+ ]);
364
+
365
+ const result = await handleRecallWebhook({
366
+ client: client as unknown as CoreApiClient,
367
+ body: {
368
+ event: 'recording.done',
369
+ data: {
370
+ bot: {
371
+ id: 'recall-bot-1',
372
+ metadata: {
373
+ twentyWorkspaceId: WORKSPACE_ID,
374
+ },
375
+ },
376
+ recording: {
377
+ id: 'recall-recording-1',
378
+ },
379
+ },
380
+ },
381
+ });
382
+
383
+ expect(result).toEqual({
384
+ status: 'updated',
385
+ event: 'recording.done',
386
+ callRecordingId: 'call-recording-1',
387
+ callRecordingStatus: 'PROCESSING',
388
+ });
389
+ expect(client.mutations).toEqual([
390
+ {
391
+ id: 'call-recording-1',
392
+ data: {
393
+ status: 'PROCESSING',
394
+ externalBotId: 'recall-bot-1',
395
+ externalRecordingId: 'recall-recording-1',
396
+ },
397
+ },
398
+ ]);
399
+ });
400
+
401
+ it('fills startedAt from the status timestamp when the bot starts recording', async () => {
402
+ const client = new FakeCoreApiClient([
403
+ {
404
+ id: 'call-recording-1',
405
+ status: 'JOINING',
406
+ externalBotId: 'recall-bot-1',
407
+ },
408
+ ]);
409
+
410
+ const result = await handleRecallWebhook({
411
+ client: client as unknown as CoreApiClient,
412
+ body: {
413
+ event: 'bot.status_change',
414
+ data: {
415
+ bot: {
416
+ id: 'recall-bot-1',
417
+ metadata: {
418
+ twentyWorkspaceId: WORKSPACE_ID,
419
+ twentyCallRecordingId: 'call-recording-1',
420
+ },
421
+ },
422
+ status: {
423
+ code: 'in_call_recording',
424
+ created_at: '2026-01-01T13:02:00.000Z',
425
+ },
426
+ },
427
+ },
428
+ });
429
+
430
+ expect(result).toEqual({
431
+ status: 'updated',
432
+ event: 'bot.status_change',
433
+ callRecordingId: 'call-recording-1',
434
+ callRecordingStatus: 'RECORDING',
435
+ });
436
+ expect(client.mutations).toEqual([
437
+ {
438
+ id: 'call-recording-1',
439
+ data: {
440
+ status: 'RECORDING',
441
+ externalBotId: 'recall-bot-1',
442
+ startedAt: '2026-01-01T13:02:00.000Z',
443
+ },
444
+ },
445
+ ]);
446
+ });
447
+
448
+ it('fills endedAt from the status timestamp when the recording is done', async () => {
449
+ const client = new FakeCoreApiClient([
450
+ {
451
+ id: 'call-recording-1',
452
+ status: 'PROCESSING',
453
+ externalBotId: 'recall-bot-1',
454
+ startedAt: '2026-01-01T13:02:00.000Z',
455
+ },
456
+ ]);
457
+
458
+ const result = await handleRecallWebhook({
459
+ client: client as unknown as CoreApiClient,
460
+ body: {
461
+ event: 'bot.status_change',
462
+ data: {
463
+ bot: {
464
+ id: 'recall-bot-1',
465
+ metadata: {
466
+ twentyWorkspaceId: WORKSPACE_ID,
467
+ twentyCallRecordingId: 'call-recording-1',
468
+ },
469
+ },
470
+ status: {
471
+ code: 'done',
472
+ created_at: '2026-01-01T14:05:00.000Z',
473
+ },
474
+ },
475
+ },
476
+ });
477
+
478
+ expect(result).toEqual({
479
+ status: 'updated',
480
+ event: 'bot.status_change',
481
+ callRecordingId: 'call-recording-1',
482
+ callRecordingStatus: 'PROCESSING',
483
+ });
484
+ expect(client.mutations).toEqual([
485
+ {
486
+ id: 'call-recording-1',
487
+ data: {
488
+ status: 'PROCESSING',
489
+ externalBotId: 'recall-bot-1',
490
+ endedAt: '2026-01-01T14:05:00.000Z',
491
+ },
492
+ },
493
+ ]);
494
+ });
495
+
496
+ it('normalizes microsecond-precision Recall timestamps before writing them', async () => {
497
+ const client = new FakeCoreApiClient([
498
+ {
499
+ id: 'call-recording-1',
500
+ status: 'PROCESSING',
501
+ externalBotId: 'recall-bot-1',
502
+ startedAt: '2026-06-10T11:02:00.000Z',
503
+ },
504
+ ]);
505
+
506
+ await handleRecallWebhook({
507
+ client: client as unknown as CoreApiClient,
508
+ body: {
509
+ event: 'bot.status_change',
510
+ data: {
511
+ bot: {
512
+ id: 'recall-bot-1',
513
+ metadata: {
514
+ twentyWorkspaceId: WORKSPACE_ID,
515
+ twentyCallRecordingId: 'call-recording-1',
516
+ },
517
+ },
518
+ status: {
519
+ code: 'done',
520
+ created_at: '2026-06-10T12:17:28.281597+00:00',
521
+ },
522
+ },
523
+ },
524
+ });
525
+
526
+ expect(client.mutations).toEqual([
527
+ {
528
+ id: 'call-recording-1',
529
+ data: {
530
+ status: 'PROCESSING',
531
+ externalBotId: 'recall-bot-1',
532
+ endedAt: '2026-06-10T12:17:28.281Z',
533
+ },
534
+ },
535
+ ]);
536
+ });
537
+
538
+ it('does not overwrite an already-set startedAt on a redelivered recording event', async () => {
539
+ const client = new FakeCoreApiClient([
540
+ {
541
+ id: 'call-recording-1',
542
+ status: 'RECORDING',
543
+ externalBotId: 'recall-bot-1',
544
+ startedAt: '2026-01-01T13:02:00.000Z',
545
+ },
546
+ ]);
547
+
548
+ await handleRecallWebhook({
549
+ client: client as unknown as CoreApiClient,
550
+ body: {
551
+ event: 'bot.status_change',
552
+ data: {
553
+ bot: {
554
+ id: 'recall-bot-1',
555
+ metadata: {
556
+ twentyWorkspaceId: WORKSPACE_ID,
557
+ twentyCallRecordingId: 'call-recording-1',
558
+ },
559
+ },
560
+ status: {
561
+ code: 'in_call_recording',
562
+ created_at: '2026-01-01T13:09:00.000Z',
563
+ },
564
+ },
565
+ },
566
+ });
567
+
568
+ expect(client.mutations).toEqual([
569
+ {
570
+ id: 'call-recording-1',
571
+ data: {
572
+ status: 'RECORDING',
573
+ externalBotId: 'recall-bot-1',
574
+ },
575
+ },
576
+ ]);
577
+ });
578
+
579
+ it('does not overwrite an already-set endedAt on a redelivered done event', async () => {
580
+ const client = new FakeCoreApiClient([
581
+ {
582
+ id: 'call-recording-1',
583
+ status: 'PROCESSING',
584
+ externalBotId: 'recall-bot-1',
585
+ startedAt: '2026-01-01T13:02:00.000Z',
586
+ endedAt: '2026-01-01T14:05:00.000Z',
587
+ transcript: {
588
+ recallTranscriptId: 'recall-transcript-1',
589
+ status: 'PENDING',
590
+ requestedAt: '2026-01-01T14:06:00.000Z',
591
+ },
592
+ },
593
+ ]);
594
+
595
+ await handleRecallWebhook({
596
+ client: client as unknown as CoreApiClient,
597
+ body: {
598
+ event: 'bot.status_change',
599
+ data: {
600
+ bot: {
601
+ id: 'recall-bot-1',
602
+ metadata: {
603
+ twentyWorkspaceId: WORKSPACE_ID,
604
+ twentyCallRecordingId: 'call-recording-1',
605
+ },
606
+ },
607
+ status: {
608
+ code: 'done',
609
+ created_at: '2026-01-01T14:11:00.000Z',
610
+ },
611
+ },
612
+ },
613
+ });
614
+
615
+ expect(client.mutations).toEqual([
616
+ {
617
+ id: 'call-recording-1',
618
+ data: {
619
+ status: 'PROCESSING',
620
+ externalBotId: 'recall-bot-1',
621
+ },
622
+ },
623
+ ]);
624
+ });
625
+
626
+ it('skips a late done event once the recording is COMPLETED', async () => {
627
+ const client = new FakeCoreApiClient([
628
+ {
629
+ id: 'call-recording-1',
630
+ status: 'COMPLETED',
631
+ externalBotId: 'recall-bot-1',
632
+ startedAt: '2026-01-01T13:02:00.000Z',
633
+ endedAt: '2026-01-01T14:05:00.000Z',
634
+ },
635
+ ]);
636
+
637
+ const result = await handleRecallWebhook({
638
+ client: client as unknown as CoreApiClient,
639
+ body: {
640
+ event: 'bot.status_change',
641
+ data: {
642
+ bot: {
643
+ id: 'recall-bot-1',
644
+ metadata: {
645
+ twentyWorkspaceId: WORKSPACE_ID,
646
+ twentyCallRecordingId: 'call-recording-1',
647
+ },
648
+ },
649
+ status: {
650
+ code: 'done',
651
+ created_at: '2026-01-01T14:11:00.000Z',
652
+ },
653
+ },
654
+ },
655
+ });
656
+
657
+ expect(result).toEqual({
658
+ status: 'skipped',
659
+ event: 'bot.status_change',
660
+ reason: 'stale status event (COMPLETED -> PROCESSING)',
661
+ });
662
+ expect(client.mutations).toEqual([]);
663
+ });
664
+
665
+ it('skips out-of-order events that would move the status backwards', async () => {
666
+ const client = new FakeCoreApiClient([
667
+ {
668
+ id: 'call-recording-1',
669
+ status: 'COMPLETED',
670
+ externalBotId: 'recall-bot-1',
671
+ },
672
+ ]);
673
+
674
+ const result = await handleRecallWebhook({
675
+ client: client as unknown as CoreApiClient,
676
+ body: {
677
+ event: 'bot.status_change',
678
+ data: {
679
+ bot: {
680
+ id: 'recall-bot-1',
681
+ metadata: {
682
+ twentyWorkspaceId: WORKSPACE_ID,
683
+ twentyCallRecordingId: 'call-recording-1',
684
+ },
685
+ },
686
+ status: {
687
+ code: 'in_call_recording',
688
+ },
689
+ },
690
+ },
691
+ });
692
+
693
+ expect(result).toEqual({
694
+ status: 'skipped',
695
+ event: 'bot.status_change',
696
+ reason: 'stale status event (COMPLETED -> RECORDING)',
697
+ });
698
+ expect(client.mutations).toEqual([]);
699
+ });
700
+
701
+ it('skips events whose metadata points at a missing call recording', async () => {
702
+ const client = new FakeCoreApiClient([]);
703
+
704
+ const result = await handleRecallWebhook({
705
+ client: client as unknown as CoreApiClient,
706
+ body: {
707
+ event: 'bot.status_change',
708
+ data: {
709
+ bot: {
710
+ metadata: {
711
+ twentyWorkspaceId: WORKSPACE_ID,
712
+ twentyCallRecordingId: 'call-recording-deleted',
713
+ },
714
+ },
715
+ status: {
716
+ code: 'in_call_recording',
717
+ },
718
+ },
719
+ },
720
+ });
721
+
722
+ expect(result).toEqual({
723
+ status: 'skipped',
724
+ event: 'bot.status_change',
725
+ reason: 'no matching call recording',
726
+ });
727
+ expect(client.mutations).toEqual([]);
728
+ });
729
+
730
+ it('skips unsupported events', async () => {
731
+ const client = new FakeCoreApiClient([]);
732
+
733
+ const result = await handleRecallWebhook({
734
+ client: client as unknown as CoreApiClient,
735
+ body: {
736
+ event: 'participant_events.done',
737
+ data: {},
738
+ },
739
+ });
740
+
741
+ expect(result).toEqual({
742
+ status: 'skipped',
743
+ event: 'participant_events.done',
744
+ reason: 'unsupported Recall event status participant_events.done',
745
+ });
746
+ expect(client.mutations).toEqual([]);
747
+ });
748
+
749
+ it('requests a transcript once when the recording first completes', async () => {
750
+ createAsyncRecallTranscriptMock.mockResolvedValue({
751
+ ok: true,
752
+ transcriptId: 'recall-transcript-1',
753
+ });
754
+ const client = new FakeCoreApiClient([
755
+ {
756
+ id: 'call-recording-1',
757
+ status: 'PROCESSING',
758
+ externalBotId: 'recall-bot-1',
759
+ transcript: null,
760
+ },
761
+ ]);
762
+
763
+ await handleRecallWebhook({
764
+ client: client as unknown as CoreApiClient,
765
+ body: buildRecordingDoneWebhookBody(),
766
+ });
767
+
768
+ expect(createAsyncRecallTranscriptMock).toHaveBeenCalledTimes(1);
769
+ expect(createAsyncRecallTranscriptMock).toHaveBeenCalledWith({
770
+ externalRecordingId: 'recall-recording-1',
771
+ });
772
+ expect(client.mutations).toEqual([
773
+ {
774
+ id: 'call-recording-1',
775
+ data: {
776
+ status: 'PROCESSING',
777
+ externalBotId: 'recall-bot-1',
778
+ externalRecordingId: 'recall-recording-1',
779
+ transcript: {
780
+ recallTranscriptId: 'recall-transcript-1',
781
+ status: 'PENDING',
782
+ requestedAt: expect.any(String),
783
+ },
784
+ },
785
+ },
786
+ ]);
787
+ });
788
+
789
+ it('does not re-request a transcript on a redelivered done event while Recall list is stale', async () => {
790
+ const client = new FakeCoreApiClient([
791
+ {
792
+ id: 'call-recording-1',
793
+ status: 'PROCESSING',
794
+ externalBotId: 'recall-bot-1',
795
+ externalRecordingId: 'recall-recording-1',
796
+ transcript: {
797
+ recallTranscriptId: 'recall-transcript-1',
798
+ status: 'PENDING',
799
+ requestedAt: '2026-01-01T14:06:00.000Z',
800
+ },
801
+ },
802
+ ]);
803
+
804
+ await handleRecallWebhook({
805
+ client: client as unknown as CoreApiClient,
806
+ body: buildRecordingDoneWebhookBody(),
807
+ });
808
+
809
+ expect(createAsyncRecallTranscriptMock).not.toHaveBeenCalled();
810
+ expect(listRecallTranscriptsMock).toHaveBeenCalledWith({
811
+ externalRecordingId: 'recall-recording-1',
812
+ });
813
+ expect(retrieveRecallTranscriptMock).toHaveBeenCalledWith({
814
+ transcriptId: 'recall-transcript-1',
815
+ });
816
+ expect(client.mutations).toEqual([
817
+ {
818
+ id: 'call-recording-1',
819
+ data: {
820
+ status: 'PROCESSING',
821
+ externalBotId: 'recall-bot-1',
822
+ externalRecordingId: 'recall-recording-1',
823
+ },
824
+ },
825
+ ]);
826
+ });
827
+
828
+ it('resolves the recording id from the bot when the payload and record lack one', async () => {
829
+ getRecallBotMock.mockResolvedValue({
830
+ ok: true,
831
+ bot: {
832
+ recordings: [{ id: 'recall-recording-9' }],
833
+ },
834
+ });
835
+ createAsyncRecallTranscriptMock.mockResolvedValue({
836
+ ok: true,
837
+ transcriptId: 'recall-transcript-9',
838
+ });
839
+ const client = new FakeCoreApiClient([
840
+ {
841
+ id: 'call-recording-1',
842
+ status: 'PROCESSING',
843
+ externalBotId: 'recall-bot-1',
844
+ transcript: null,
845
+ },
846
+ ]);
847
+
848
+ await handleRecallWebhook({
849
+ client: client as unknown as CoreApiClient,
850
+ body: {
851
+ event: 'bot.status_change',
852
+ data: {
853
+ bot: {
854
+ id: 'recall-bot-1',
855
+ metadata: {
856
+ twentyWorkspaceId: WORKSPACE_ID,
857
+ twentyCallRecordingId: 'call-recording-1',
858
+ },
859
+ },
860
+ status: {
861
+ code: 'done',
862
+ },
863
+ },
864
+ },
865
+ });
866
+
867
+ expect(getRecallBotMock).toHaveBeenCalledWith({
868
+ externalBotId: 'recall-bot-1',
869
+ });
870
+ expect(createAsyncRecallTranscriptMock).toHaveBeenCalledWith({
871
+ externalRecordingId: 'recall-recording-9',
872
+ });
873
+ expect(client.mutations).toEqual([
874
+ expect.objectContaining({
875
+ id: 'call-recording-1',
876
+ data: expect.objectContaining({
877
+ status: 'PROCESSING',
878
+ externalBotId: 'recall-bot-1',
879
+ externalRecordingId: 'recall-recording-9',
880
+ }),
881
+ }),
882
+ ]);
883
+ });
884
+
885
+ it('ingests media on recording.done and completes once all artifacts are present', async () => {
886
+ getRecallBotMock.mockResolvedValue({
887
+ ok: true,
888
+ bot: { id: 'recall-bot-1' },
889
+ });
890
+ ingestCallRecordingMediaMock.mockResolvedValue({
891
+ audio: [{ fileId: 'file-audio-1', label: 'audio.mp3' }],
892
+ video: [{ fileId: 'file-video-1', label: 'video.mp4' }],
893
+ });
894
+ const client = new FakeCoreApiClient([
895
+ {
896
+ id: 'call-recording-1',
897
+ status: 'PROCESSING',
898
+ externalBotId: 'recall-bot-1',
899
+ externalRecordingId: 'recall-recording-1',
900
+ startedAt: '2026-01-01T13:02:00.000Z',
901
+ endedAt: '2026-01-01T14:05:00.000Z',
902
+ transcript: [{ participant: { id: 1 }, words: [] }],
903
+ },
904
+ ]);
905
+
906
+ await handleRecallWebhook({
907
+ client: client as unknown as CoreApiClient,
908
+ body: buildRecordingDoneWebhookBody(),
909
+ });
910
+
911
+ expect(ingestCallRecordingMediaMock).toHaveBeenCalledWith({
912
+ callRecordingId: 'call-recording-1',
913
+ externalRecordingId: 'recall-recording-1',
914
+ hasAudio: false,
915
+ hasVideo: false,
916
+ });
917
+ expect(client.mutations).toEqual([
918
+ {
919
+ id: 'call-recording-1',
920
+ data: {
921
+ externalBotId: 'recall-bot-1',
922
+ externalRecordingId: 'recall-recording-1',
923
+ audio: [{ fileId: 'file-audio-1', label: 'audio.mp3' }],
924
+ video: [{ fileId: 'file-video-1', label: 'video.mp4' }],
925
+ },
926
+ },
927
+ {
928
+ id: 'call-recording-1',
929
+ data: { status: 'COMPLETED' },
930
+ },
931
+ ]);
932
+ expect(chargeCompletedCallRecordingMock).toHaveBeenCalledWith({
933
+ callRecordingId: 'call-recording-1',
934
+ startedAt: '2026-01-01T13:02:00.000Z',
935
+ endedAt: '2026-01-01T14:05:00.000Z',
936
+ });
937
+ });
938
+
939
+ it('stays PROCESSING on recording.done while artifacts are missing', async () => {
940
+ getRecallBotMock.mockResolvedValue({
941
+ ok: true,
942
+ bot: { id: 'recall-bot-1' },
943
+ });
944
+ ingestCallRecordingMediaMock.mockResolvedValue({
945
+ audio: [{ fileId: 'file-audio-1', label: 'audio.mp3' }],
946
+ });
947
+ createAsyncRecallTranscriptMock.mockResolvedValue({
948
+ ok: true,
949
+ transcriptId: 'recall-transcript-1',
950
+ });
951
+ const client = new FakeCoreApiClient([
952
+ {
953
+ id: 'call-recording-1',
954
+ status: 'PROCESSING',
955
+ externalBotId: 'recall-bot-1',
956
+ startedAt: '2026-01-01T13:02:00.000Z',
957
+ endedAt: '2026-01-01T14:05:00.000Z',
958
+ transcript: null,
959
+ },
960
+ ]);
961
+
962
+ await handleRecallWebhook({
963
+ client: client as unknown as CoreApiClient,
964
+ body: buildRecordingDoneWebhookBody(),
965
+ });
966
+
967
+ expect(createAsyncRecallTranscriptMock).toHaveBeenCalledWith({
968
+ externalRecordingId: 'recall-recording-1',
969
+ });
970
+ expect(client.mutations).toEqual([
971
+ expect.objectContaining({
972
+ id: 'call-recording-1',
973
+ data: expect.objectContaining({
974
+ status: 'PROCESSING',
975
+ externalBotId: 'recall-bot-1',
976
+ externalRecordingId: 'recall-recording-1',
977
+ audio: [{ fileId: 'file-audio-1', label: 'audio.mp3' }],
978
+ }),
979
+ }),
980
+ ]);
981
+ expect(chargeCompletedCallRecordingMock).not.toHaveBeenCalled();
982
+ });
983
+
984
+ it('marks FAILED on recording.done when no recording artifact path exists', async () => {
985
+ getRecallBotMock.mockResolvedValue({
986
+ ok: true,
987
+ bot: { id: 'recall-bot-1', recordings: [] },
988
+ });
989
+ const client = new FakeCoreApiClient([
990
+ {
991
+ id: 'call-recording-1',
992
+ status: 'PROCESSING',
993
+ externalBotId: 'recall-bot-1',
994
+ startedAt: '2026-01-01T13:02:00.000Z',
995
+ endedAt: '2026-01-01T14:05:00.000Z',
996
+ transcript: null,
997
+ },
998
+ ]);
999
+
1000
+ const result = await handleRecallWebhook({
1001
+ client: client as unknown as CoreApiClient,
1002
+ body: {
1003
+ event: 'recording.done',
1004
+ data: {
1005
+ bot: {
1006
+ id: 'recall-bot-1',
1007
+ metadata: {
1008
+ twentyWorkspaceId: WORKSPACE_ID,
1009
+ },
1010
+ },
1011
+ },
1012
+ },
1013
+ });
1014
+
1015
+ expect(result).toEqual({
1016
+ status: 'updated',
1017
+ event: 'recording.done',
1018
+ callRecordingId: 'call-recording-1',
1019
+ callRecordingStatus: 'FAILED',
1020
+ });
1021
+ expect(client.mutations).toEqual([
1022
+ {
1023
+ id: 'call-recording-1',
1024
+ data: {
1025
+ status: 'FAILED',
1026
+ externalBotId: 'recall-bot-1',
1027
+ callRecorderFailureReason: 'recording_artifacts_unavailable',
1028
+ },
1029
+ },
1030
+ ]);
1031
+ expect(chargeCompletedCallRecordingMock).not.toHaveBeenCalled();
1032
+ });
1033
+
1034
+ it('completes and charges on transcript.done when media is already ingested', async () => {
1035
+ const transcriptContent = [
1036
+ {
1037
+ participant: { id: 1, name: 'Alice' },
1038
+ words: [{ text: 'hello', start_timestamp: { relative: 0.5 } }],
1039
+ },
1040
+ ];
1041
+
1042
+ retrieveRecallTranscriptMock.mockResolvedValue({
1043
+ ok: true,
1044
+ transcript: {
1045
+ downloadUrl: 'https://recall-transcripts.example.com/transcript-1',
1046
+ statusCode: 'done',
1047
+ statusSubCode: null,
1048
+ },
1049
+ });
1050
+ vi.stubGlobal(
1051
+ 'fetch',
1052
+ vi.fn().mockResolvedValue({
1053
+ ok: true,
1054
+ json: async () => transcriptContent,
1055
+ }),
1056
+ );
1057
+
1058
+ const client = new FakeCoreApiClient([
1059
+ {
1060
+ id: 'call-recording-1',
1061
+ status: 'PROCESSING',
1062
+ externalBotId: 'recall-bot-1',
1063
+ externalRecordingId: 'recall-recording-1',
1064
+ startedAt: '2026-01-01T13:02:00.000Z',
1065
+ endedAt: '2026-01-01T14:05:00.000Z',
1066
+ transcript: {
1067
+ recallTranscriptId: 'recall-transcript-1',
1068
+ status: 'PENDING',
1069
+ requestedAt: '2026-01-01T14:06:00.000Z',
1070
+ },
1071
+ audio: [{ fileId: 'file-audio-1', label: 'audio.mp3' }],
1072
+ video: [{ fileId: 'file-video-1', label: 'video.mp4' }],
1073
+ },
1074
+ ]);
1075
+
1076
+ const result = await handleRecallWebhook({
1077
+ client: client as unknown as CoreApiClient,
1078
+ body: {
1079
+ event: 'transcript.done',
1080
+ data: {
1081
+ bot: {
1082
+ id: 'recall-bot-1',
1083
+ metadata: {
1084
+ twentyWorkspaceId: WORKSPACE_ID,
1085
+ twentyCallRecordingId: 'call-recording-1',
1086
+ },
1087
+ },
1088
+ transcript: {
1089
+ id: 'recall-transcript-1',
1090
+ },
1091
+ },
1092
+ },
1093
+ });
1094
+
1095
+ expect(result).toEqual({
1096
+ status: 'updated',
1097
+ event: 'transcript.done',
1098
+ callRecordingId: 'call-recording-1',
1099
+ transcriptOutcome: 'FILLED',
1100
+ });
1101
+ expect(client.mutations).toEqual([
1102
+ {
1103
+ id: 'call-recording-1',
1104
+ data: { transcript: transcriptContent },
1105
+ },
1106
+ {
1107
+ id: 'call-recording-1',
1108
+ data: { status: 'COMPLETED' },
1109
+ },
1110
+ ]);
1111
+ expect(chargeCompletedCallRecordingMock).toHaveBeenCalledWith({
1112
+ callRecordingId: 'call-recording-1',
1113
+ startedAt: '2026-01-01T13:02:00.000Z',
1114
+ endedAt: '2026-01-01T14:05:00.000Z',
1115
+ });
1116
+
1117
+ vi.unstubAllGlobals();
1118
+ });
1119
+
1120
+ it('fills the transcript from the download URL on transcript.done', async () => {
1121
+ const transcriptContent = [
1122
+ {
1123
+ participant: { id: 1, name: 'Alice' },
1124
+ words: [{ text: 'hello', start_timestamp: { relative: 0.5 } }],
1125
+ },
1126
+ ];
1127
+
1128
+ retrieveRecallTranscriptMock.mockResolvedValue({
1129
+ ok: true,
1130
+ transcript: {
1131
+ downloadUrl: 'https://recall-transcripts.example.com/transcript-1',
1132
+ statusCode: 'done',
1133
+ statusSubCode: null,
1134
+ },
1135
+ });
1136
+ vi.stubGlobal(
1137
+ 'fetch',
1138
+ vi.fn().mockResolvedValue({
1139
+ ok: true,
1140
+ json: async () => transcriptContent,
1141
+ }),
1142
+ );
1143
+
1144
+ const client = new FakeCoreApiClient([
1145
+ {
1146
+ id: 'call-recording-1',
1147
+ status: 'COMPLETED',
1148
+ externalBotId: 'recall-bot-1',
1149
+ transcript: {
1150
+ recallTranscriptId: 'recall-transcript-1',
1151
+ status: 'PENDING',
1152
+ requestedAt: '2026-01-01T14:06:00.000Z',
1153
+ },
1154
+ },
1155
+ ]);
1156
+
1157
+ const result = await handleRecallWebhook({
1158
+ client: client as unknown as CoreApiClient,
1159
+ body: {
1160
+ event: 'transcript.done',
1161
+ data: {
1162
+ bot: {
1163
+ id: 'recall-bot-1',
1164
+ metadata: {
1165
+ twentyWorkspaceId: WORKSPACE_ID,
1166
+ twentyCallRecordingId: 'call-recording-1',
1167
+ },
1168
+ },
1169
+ transcript: {
1170
+ id: 'recall-transcript-1',
1171
+ },
1172
+ recording: {
1173
+ id: 'recall-recording-1',
1174
+ },
1175
+ },
1176
+ },
1177
+ });
1178
+
1179
+ expect(result).toEqual({
1180
+ status: 'updated',
1181
+ event: 'transcript.done',
1182
+ callRecordingId: 'call-recording-1',
1183
+ transcriptOutcome: 'FILLED',
1184
+ });
1185
+ expect(retrieveRecallTranscriptMock).toHaveBeenCalledWith({
1186
+ transcriptId: 'recall-transcript-1',
1187
+ });
1188
+ expect(client.mutations).toEqual([
1189
+ {
1190
+ id: 'call-recording-1',
1191
+ data: {
1192
+ transcript: transcriptContent,
1193
+ externalRecordingId: 'recall-recording-1',
1194
+ },
1195
+ },
1196
+ ]);
1197
+ expect(chargeCompletedCallRecordingMock).not.toHaveBeenCalled();
1198
+
1199
+ vi.unstubAllGlobals();
1200
+ });
1201
+
1202
+ it('writes a FAILED marker on transcript.failed', async () => {
1203
+ const client = new FakeCoreApiClient([
1204
+ {
1205
+ id: 'call-recording-1',
1206
+ status: 'PROCESSING',
1207
+ externalBotId: 'recall-bot-1',
1208
+ externalRecordingId: 'recall-recording-1',
1209
+ transcript: {
1210
+ recallTranscriptId: 'recall-transcript-1',
1211
+ status: 'PENDING',
1212
+ requestedAt: '2026-01-01T14:06:00.000Z',
1213
+ },
1214
+ },
1215
+ ]);
1216
+
1217
+ const result = await handleRecallWebhook({
1218
+ client: client as unknown as CoreApiClient,
1219
+ body: {
1220
+ event: 'transcript.failed',
1221
+ data: {
1222
+ bot: {
1223
+ id: 'recall-bot-1',
1224
+ metadata: {
1225
+ twentyWorkspaceId: WORKSPACE_ID,
1226
+ twentyCallRecordingId: 'call-recording-1',
1227
+ },
1228
+ },
1229
+ transcript: {
1230
+ id: 'recall-transcript-1',
1231
+ },
1232
+ status: {
1233
+ sub_code: 'transcription_failed',
1234
+ },
1235
+ },
1236
+ },
1237
+ });
1238
+
1239
+ expect(result).toEqual({
1240
+ status: 'updated',
1241
+ event: 'transcript.failed',
1242
+ callRecordingId: 'call-recording-1',
1243
+ transcriptOutcome: 'FAILED',
1244
+ });
1245
+ expect(client.mutations).toEqual([
1246
+ {
1247
+ id: 'call-recording-1',
1248
+ data: {
1249
+ transcript: {
1250
+ recallTranscriptId: 'recall-transcript-1',
1251
+ status: 'FAILED',
1252
+ subCode: 'transcription_failed',
1253
+ },
1254
+ callRecorderFailureReason: 'transcript_failed:transcription_failed',
1255
+ status: 'FAILED',
1256
+ },
1257
+ },
1258
+ ]);
1259
+ expect(console.warn).toHaveBeenCalled();
1260
+ });
1261
+
1262
+ it('does not clobber a downloaded transcript with a late transcript.failed', async () => {
1263
+ const client = new FakeCoreApiClient([
1264
+ {
1265
+ id: 'call-recording-1',
1266
+ status: 'COMPLETED',
1267
+ externalBotId: 'recall-bot-1',
1268
+ transcript: [{ participant: { id: 1 }, words: [] }],
1269
+ },
1270
+ ]);
1271
+
1272
+ const result = await handleRecallWebhook({
1273
+ client: client as unknown as CoreApiClient,
1274
+ body: {
1275
+ event: 'transcript.failed',
1276
+ data: {
1277
+ bot: {
1278
+ id: 'recall-bot-1',
1279
+ metadata: {
1280
+ twentyWorkspaceId: WORKSPACE_ID,
1281
+ twentyCallRecordingId: 'call-recording-1',
1282
+ },
1283
+ },
1284
+ transcript: {
1285
+ id: 'recall-transcript-1',
1286
+ },
1287
+ status: {
1288
+ sub_code: 'transcription_failed',
1289
+ },
1290
+ },
1291
+ },
1292
+ });
1293
+
1294
+ expect(result).toEqual({
1295
+ status: 'skipped',
1296
+ event: 'transcript.failed',
1297
+ reason: 'transcript already filled',
1298
+ });
1299
+ expect(client.mutations).toEqual([]);
1300
+ });
1301
+ });