@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,672 @@
1
+ import { isNonEmptyArray, isNull, isUndefined } from '@sniptt/guards';
2
+ import { type CoreApiClient } from 'twenty-client-sdk/core';
3
+
4
+ import { CallRecordingStatus } from 'src/logic-functions/constants/call-recording-status';
5
+ import { type FilesFieldValue } from 'src/logic-functions/types/files-field-value.type';
6
+ import { buildFailedTranscriptMarker } from 'src/logic-functions/domain/build-failed-transcript-marker.util';
7
+ import { buildTranscriptFailureReason } from 'src/logic-functions/domain/build-transcript-failure-reason.util';
8
+ import { downloadTranscript } from 'src/logic-functions/flows/download-transcript.util';
9
+ import { extractRecallBotConvergence } from 'src/logic-functions/recall-api/extract-recall-bot-convergence.util';
10
+ import { getRecallBot } from 'src/logic-functions/recall-api/get-recall-bot.util';
11
+ import { getString } from 'src/logic-functions/utils/get-string.util';
12
+ import { ingestCallRecordingMedia } from 'src/logic-functions/flows/ingest-call-recording-media.util';
13
+ import { isCallRecordingStatusDowngrade } from 'src/logic-functions/domain/is-call-recording-status-downgrade.util';
14
+ import { isRecallRecordingDoneSignal } from 'src/logic-functions/domain/is-recall-recording-done-signal.util';
15
+ import { mapRecallStatusCodeToCallRecordingStatus } from 'src/logic-functions/domain/map-recall-status-code-to-call-recording-status.util';
16
+ import {
17
+ parseRecallWebhookEvent,
18
+ type RecallWebhookBody,
19
+ type RecallWebhookEvent,
20
+ } from 'src/logic-functions/recall-api/parse-recall-webhook-event.util';
21
+ import { parseTranscriptMarker } from 'src/logic-functions/domain/parse-transcript-marker.util';
22
+ import { persistCallRecordingProgress } from 'src/logic-functions/flows/persist-call-recording-progress.util';
23
+ import { reconcileCallRecordingTranscriptArtifact } from 'src/logic-functions/flows/reconcile-call-recording-transcript-artifact.util';
24
+ import { updateCallRecording } from 'src/logic-functions/data/update-call-recording.util';
25
+ import { type CallRecordingUpdateFields } from 'src/logic-functions/types/call-recording-update-fields.type';
26
+
27
+ type MatchedCallRecording = {
28
+ id: string;
29
+ status?: string;
30
+ startedAt?: string;
31
+ endedAt?: string;
32
+ externalRecordingId?: string;
33
+ transcript?: unknown;
34
+ audio?: FilesFieldValue;
35
+ video?: FilesFieldValue;
36
+ };
37
+
38
+ type ExternalRecordingIdResolution = {
39
+ externalRecordingId: string | undefined;
40
+ providerLookupFailed: boolean;
41
+ };
42
+
43
+ type RecallWebhookHandlerResult =
44
+ | {
45
+ status: 'updated';
46
+ callRecordingId: string;
47
+ event: string;
48
+ callRecordingStatus: string;
49
+ }
50
+ | {
51
+ status: 'updated';
52
+ callRecordingId: string;
53
+ event: string;
54
+ transcriptOutcome: 'FILLED' | 'FAILED';
55
+ }
56
+ | {
57
+ status: 'skipped';
58
+ event: string | null;
59
+ reason: string;
60
+ };
61
+
62
+ export const handleRecallWebhook = async ({
63
+ client,
64
+ body,
65
+ }: {
66
+ client: CoreApiClient;
67
+ body: RecallWebhookBody;
68
+ }): Promise<RecallWebhookHandlerResult> => {
69
+ const webhookEvent = parseRecallWebhookEvent(body);
70
+
71
+ if (isUndefined(webhookEvent)) {
72
+ return {
73
+ status: 'skipped',
74
+ event: null,
75
+ reason: 'missing event type',
76
+ };
77
+ }
78
+
79
+ const { event } = webhookEvent;
80
+
81
+ if (event === 'transcript.done' || event === 'transcript.failed') {
82
+ return handleRecallTranscriptEvent({ client, webhookEvent, event });
83
+ }
84
+
85
+ return handleRecallStatusEvent({ client, webhookEvent });
86
+ };
87
+
88
+ const handleRecallStatusEvent = async ({
89
+ client,
90
+ webhookEvent,
91
+ }: {
92
+ client: CoreApiClient;
93
+ webhookEvent: RecallWebhookEvent;
94
+ }): Promise<RecallWebhookHandlerResult> => {
95
+ const { event, statusCode } = webhookEvent;
96
+ const callRecordingStatus = mapRecallEventToCallRecordingStatus({
97
+ event,
98
+ statusCode,
99
+ });
100
+
101
+ if (isUndefined(callRecordingStatus)) {
102
+ return {
103
+ status: 'skipped',
104
+ event,
105
+ reason: `unsupported Recall event status ${statusCode ?? event}`,
106
+ };
107
+ }
108
+
109
+ const callRecording = await findMatchingCallRecording({
110
+ client,
111
+ webhookEvent,
112
+ });
113
+
114
+ if (isUndefined(callRecording)) {
115
+ return {
116
+ status: 'skipped',
117
+ event,
118
+ reason: 'no matching call recording',
119
+ };
120
+ }
121
+
122
+ if (
123
+ isCallRecordingStatusDowngrade({
124
+ fromStatus: callRecording.status,
125
+ toStatus: callRecordingStatus,
126
+ })
127
+ ) {
128
+ return {
129
+ status: 'skipped',
130
+ event,
131
+ reason: `stale status event (${callRecording.status} -> ${callRecordingStatus})`,
132
+ };
133
+ }
134
+
135
+ const updateData: CallRecordingUpdateFields = {
136
+ ...(isUndefined(webhookEvent.externalBotId)
137
+ ? {}
138
+ : { externalBotId: webhookEvent.externalBotId }),
139
+ ...buildExternalRecordingIdUpdate(webhookEvent),
140
+ ...buildCallRecordingStatusUpdate({
141
+ reason: getRecallWebhookFailureReason(webhookEvent),
142
+ status: callRecordingStatus,
143
+ }),
144
+ ...buildRecordingTimestampsUpdate({ webhookEvent, callRecording }),
145
+ };
146
+
147
+ if (isRecallRecordingDoneSignal({ event, statusCode })) {
148
+ const externalRecordingIdResolution = await resolveExternalRecordingId({
149
+ callRecording,
150
+ webhookEvent,
151
+ });
152
+
153
+ Object.assign(
154
+ updateData,
155
+ await buildTranscriptArtifactUpdate({
156
+ callRecording,
157
+ externalRecordingId: externalRecordingIdResolution.externalRecordingId,
158
+ }),
159
+ );
160
+
161
+ Object.assign(
162
+ updateData,
163
+ await buildMediaIngestionUpdate({
164
+ callRecording,
165
+ externalRecordingId: externalRecordingIdResolution.externalRecordingId,
166
+ }),
167
+ );
168
+
169
+ const terminalArtifactGateFailureUpdate =
170
+ buildTerminalArtifactGateFailureUpdate({
171
+ callRecording,
172
+ providerLookupFailed:
173
+ externalRecordingIdResolution.providerLookupFailed,
174
+ updateData,
175
+ webhookEvent,
176
+ });
177
+
178
+ if (!isUndefined(terminalArtifactGateFailureUpdate)) {
179
+ Object.assign(updateData, terminalArtifactGateFailureUpdate);
180
+ }
181
+ }
182
+
183
+ const { completesIngestion } = await persistCallRecordingProgress(client, {
184
+ id: callRecording.id,
185
+ current: callRecording,
186
+ updateData,
187
+ });
188
+
189
+ return {
190
+ status: 'updated',
191
+ event,
192
+ callRecordingId: callRecording.id,
193
+ callRecordingStatus: completesIngestion
194
+ ? CallRecordingStatus.COMPLETED
195
+ : (updateData.status ?? callRecordingStatus),
196
+ };
197
+ };
198
+
199
+ const findMatchingCallRecording = async ({
200
+ client,
201
+ webhookEvent,
202
+ }: {
203
+ client: CoreApiClient;
204
+ webhookEvent: RecallWebhookEvent;
205
+ }): Promise<MatchedCallRecording | undefined> => {
206
+ if (!isUndefined(webhookEvent.callRecordingIdFromMetadata)) {
207
+ return findCallRecordingByFilter(client, {
208
+ id: { eq: webhookEvent.callRecordingIdFromMetadata },
209
+ });
210
+ }
211
+
212
+ if (isUndefined(webhookEvent.externalBotId)) {
213
+ return undefined;
214
+ }
215
+
216
+ return findCallRecordingByFilter(client, {
217
+ externalBotId: { eq: webhookEvent.externalBotId },
218
+ });
219
+ };
220
+
221
+ const findCallRecordingByFilter = async (
222
+ client: CoreApiClient,
223
+ filter: Record<string, unknown>,
224
+ ): Promise<MatchedCallRecording | undefined> => {
225
+ const queryResult = await client.query({
226
+ callRecordings: {
227
+ __args: {
228
+ filter,
229
+ first: 1,
230
+ },
231
+ edges: {
232
+ node: {
233
+ id: true,
234
+ status: true,
235
+ startedAt: true,
236
+ endedAt: true,
237
+ externalRecordingId: true,
238
+ transcript: true,
239
+ audio: { fileId: true },
240
+ video: { fileId: true },
241
+ },
242
+ },
243
+ },
244
+ });
245
+
246
+ const node = queryResult.callRecordings?.edges?.[0]?.node;
247
+
248
+ if (isUndefined(node) || isNull(node)) {
249
+ return undefined;
250
+ }
251
+
252
+ return {
253
+ id: node.id,
254
+ status: getString(node.status),
255
+ startedAt: getString(node.startedAt),
256
+ endedAt: getString(node.endedAt),
257
+ externalRecordingId: getString(node.externalRecordingId),
258
+ transcript: node.transcript ?? undefined,
259
+ audio: node.audio ?? undefined,
260
+ video: node.video ?? undefined,
261
+ };
262
+ };
263
+
264
+ const mapRecallEventToCallRecordingStatus = ({
265
+ event,
266
+ statusCode,
267
+ }: {
268
+ event: string;
269
+ statusCode: string | undefined;
270
+ }): CallRecordingStatus | undefined => {
271
+ if (event === 'recording.done') {
272
+ return CallRecordingStatus.PROCESSING;
273
+ }
274
+
275
+ if (event === 'recording.failed') {
276
+ return CallRecordingStatus.FAILED;
277
+ }
278
+
279
+ return mapRecallStatusCodeToCallRecordingStatus(statusCode);
280
+ };
281
+
282
+ const buildRecordingTimestampsUpdate = ({
283
+ webhookEvent,
284
+ callRecording,
285
+ }: {
286
+ webhookEvent: RecallWebhookEvent;
287
+ callRecording: MatchedCallRecording;
288
+ }): { startedAt?: string; endedAt?: string } => {
289
+ const { event, statusCode, statusTimestamp } = webhookEvent;
290
+
291
+ const impliesRecordingStarted = statusCode === 'in_call_recording';
292
+ const impliesRecordingEnded =
293
+ event === 'recording.done' ||
294
+ statusCode === 'call_ended' ||
295
+ statusCode === 'done';
296
+
297
+ const startedAt =
298
+ webhookEvent.recordingStartedAt ??
299
+ (impliesRecordingStarted ? statusTimestamp : undefined);
300
+ const endedAt =
301
+ webhookEvent.recordingEndedAt ??
302
+ (impliesRecordingEnded ? statusTimestamp : undefined);
303
+
304
+ return {
305
+ ...(!isUndefined(startedAt) && isUndefined(callRecording.startedAt)
306
+ ? { startedAt }
307
+ : {}),
308
+ ...(!isUndefined(endedAt) && isUndefined(callRecording.endedAt)
309
+ ? { endedAt }
310
+ : {}),
311
+ };
312
+ };
313
+
314
+ const buildExternalRecordingIdUpdate = (
315
+ webhookEvent: RecallWebhookEvent,
316
+ ): { externalRecordingId?: string } =>
317
+ isUndefined(webhookEvent.externalRecordingId)
318
+ ? {}
319
+ : { externalRecordingId: webhookEvent.externalRecordingId };
320
+
321
+ type NonFailedCallRecordingStatus = Exclude<
322
+ CallRecordingStatus,
323
+ CallRecordingStatus.FAILED
324
+ >;
325
+
326
+ type CallRecordingStatusUpdate =
327
+ | {
328
+ status: NonFailedCallRecordingStatus;
329
+ }
330
+ | {
331
+ status: CallRecordingStatus.FAILED;
332
+ callRecorderFailureReason: string;
333
+ };
334
+
335
+ type TerminalArtifactGateFailureUpdate = {
336
+ status: CallRecordingStatus.FAILED;
337
+ callRecorderFailureReason: string;
338
+ };
339
+
340
+ const buildCallRecordingStatusUpdate = ({
341
+ reason,
342
+ status,
343
+ }: {
344
+ reason: string;
345
+ status: CallRecordingStatus;
346
+ }): CallRecordingStatusUpdate => {
347
+ if (status === CallRecordingStatus.FAILED) {
348
+ return { status, callRecorderFailureReason: reason };
349
+ }
350
+
351
+ return { status };
352
+ };
353
+
354
+ const buildTerminalArtifactGateFailureUpdate = ({
355
+ callRecording,
356
+ providerLookupFailed,
357
+ updateData,
358
+ webhookEvent,
359
+ }: {
360
+ callRecording: MatchedCallRecording;
361
+ providerLookupFailed: boolean;
362
+ updateData: CallRecordingUpdateFields;
363
+ webhookEvent: RecallWebhookEvent;
364
+ }): TerminalArtifactGateFailureUpdate | undefined => {
365
+ if (updateData.status === CallRecordingStatus.FAILED) {
366
+ return isUndefined(updateData.callRecorderFailureReason)
367
+ ? {
368
+ status: CallRecordingStatus.FAILED,
369
+ callRecorderFailureReason:
370
+ getRecallWebhookFailureReason(webhookEvent),
371
+ }
372
+ : undefined;
373
+ }
374
+
375
+ if (
376
+ providerLookupFailed ||
377
+ hasRecordingArtifactPath({ callRecording, updateData })
378
+ ) {
379
+ return undefined;
380
+ }
381
+
382
+ return {
383
+ status: CallRecordingStatus.FAILED,
384
+ callRecorderFailureReason: 'recording_artifacts_unavailable',
385
+ };
386
+ };
387
+
388
+ const getRecallWebhookFailureReason = ({
389
+ event,
390
+ statusCode,
391
+ }: RecallWebhookEvent): string => statusCode ?? event;
392
+
393
+ const hasRecordingArtifactPath = ({
394
+ callRecording,
395
+ updateData,
396
+ }: {
397
+ callRecording: MatchedCallRecording;
398
+ updateData: CallRecordingUpdateFields;
399
+ }): boolean => {
400
+ return (
401
+ !isUndefined(
402
+ updateData.externalRecordingId ?? callRecording.externalRecordingId,
403
+ ) ||
404
+ isNonEmptyArray(updateData.audio ?? callRecording.audio) ||
405
+ isNonEmptyArray(updateData.video ?? callRecording.video) ||
406
+ hasReachableTranscript(updateData.transcript ?? callRecording.transcript)
407
+ );
408
+ };
409
+
410
+ const hasReachableTranscript = (transcript: unknown): boolean => {
411
+ if (isNull(transcript) || isUndefined(transcript)) {
412
+ return false;
413
+ }
414
+
415
+ const marker = parseTranscriptMarker(transcript);
416
+
417
+ return isUndefined(marker) || marker.status === 'PENDING';
418
+ };
419
+
420
+ const isTranscriptUnset = (callRecording: MatchedCallRecording): boolean =>
421
+ isUndefined(callRecording.transcript);
422
+
423
+ const buildMediaIngestionUpdate = async ({
424
+ callRecording,
425
+ externalRecordingId,
426
+ }: {
427
+ callRecording: MatchedCallRecording;
428
+ externalRecordingId: string | undefined;
429
+ }): Promise<Pick<CallRecordingUpdateFields, 'audio' | 'video'>> => {
430
+ const hasAudio = isNonEmptyArray(callRecording.audio);
431
+ const hasVideo = isNonEmptyArray(callRecording.video);
432
+
433
+ if (hasAudio && hasVideo) {
434
+ return {};
435
+ }
436
+
437
+ if (isUndefined(externalRecordingId)) {
438
+ console.warn(
439
+ `[call-recorder] cannot ingest media for call recording ${callRecording.id}: no Recall recording id available`,
440
+ );
441
+
442
+ return {};
443
+ }
444
+
445
+ return ingestCallRecordingMedia({
446
+ callRecordingId: callRecording.id,
447
+ externalRecordingId,
448
+ hasAudio,
449
+ hasVideo,
450
+ });
451
+ };
452
+
453
+ const buildTranscriptArtifactUpdate = async ({
454
+ callRecording,
455
+ externalRecordingId,
456
+ }: {
457
+ callRecording: MatchedCallRecording;
458
+ externalRecordingId: string | undefined;
459
+ }): Promise<CallRecordingUpdateFields> => {
460
+ if (isUndefined(externalRecordingId)) {
461
+ console.warn(
462
+ `[call-recorder] cannot reconcile transcript for call recording ${callRecording.id}: no Recall recording id available`,
463
+ );
464
+
465
+ return {};
466
+ }
467
+
468
+ const transcriptArtifactResult =
469
+ await reconcileCallRecordingTranscriptArtifact({
470
+ callRecordingId: callRecording.id,
471
+ currentStatus: callRecording.status,
472
+ externalRecordingId,
473
+ requestedAt: new Date().toISOString(),
474
+ transcript: callRecording.transcript,
475
+ });
476
+
477
+ return {
478
+ ...(isUndefined(callRecording.externalRecordingId)
479
+ ? { externalRecordingId }
480
+ : {}),
481
+ ...transcriptArtifactResult.updateData,
482
+ };
483
+ };
484
+
485
+ const resolveExternalRecordingId = async ({
486
+ callRecording,
487
+ webhookEvent,
488
+ }: {
489
+ callRecording: MatchedCallRecording;
490
+ webhookEvent: RecallWebhookEvent;
491
+ }): Promise<ExternalRecordingIdResolution> => {
492
+ const externalRecordingId =
493
+ webhookEvent.externalRecordingId ?? callRecording.externalRecordingId;
494
+
495
+ if (!isUndefined(externalRecordingId)) {
496
+ return { externalRecordingId, providerLookupFailed: false };
497
+ }
498
+
499
+ if (isUndefined(webhookEvent.externalBotId)) {
500
+ return { externalRecordingId: undefined, providerLookupFailed: false };
501
+ }
502
+
503
+ return fetchExternalRecordingIdFromRecallBot(webhookEvent.externalBotId);
504
+ };
505
+
506
+ const fetchExternalRecordingIdFromRecallBot = async (
507
+ externalBotId: string,
508
+ ): Promise<ExternalRecordingIdResolution> => {
509
+ const botResult = await getRecallBot({ externalBotId });
510
+
511
+ if (!botResult.ok) {
512
+ console.warn(
513
+ `[call-recorder] failed to fetch Recall bot ${externalBotId} while resolving a recording id: ${botResult.errorMessage}`,
514
+ );
515
+
516
+ return { externalRecordingId: undefined, providerLookupFailed: true };
517
+ }
518
+
519
+ return {
520
+ externalRecordingId: extractRecallBotConvergence(botResult.bot)
521
+ .externalRecordingId,
522
+ providerLookupFailed: false,
523
+ };
524
+ };
525
+
526
+ const handleRecallTranscriptEvent = async ({
527
+ client,
528
+ webhookEvent,
529
+ event,
530
+ }: {
531
+ client: CoreApiClient;
532
+ webhookEvent: RecallWebhookEvent;
533
+ event: 'transcript.done' | 'transcript.failed';
534
+ }): Promise<RecallWebhookHandlerResult> => {
535
+ const callRecording = await findMatchingCallRecording({
536
+ client,
537
+ webhookEvent,
538
+ });
539
+
540
+ if (isUndefined(callRecording)) {
541
+ return {
542
+ status: 'skipped',
543
+ event,
544
+ reason: 'no matching call recording',
545
+ };
546
+ }
547
+
548
+ const { transcriptId } = webhookEvent;
549
+
550
+ if (event === 'transcript.failed') {
551
+ return applyTranscriptFailure({
552
+ client,
553
+ callRecording,
554
+ event,
555
+ transcriptId,
556
+ subCode: webhookEvent.transcriptFailureSubCode ?? null,
557
+ });
558
+ }
559
+
560
+ if (isUndefined(transcriptId)) {
561
+ return {
562
+ status: 'skipped',
563
+ event,
564
+ reason: 'missing transcript id',
565
+ };
566
+ }
567
+
568
+ const downloadResult = await downloadTranscript({ transcriptId });
569
+
570
+ switch (downloadResult.outcome) {
571
+ case 'filled': {
572
+ const updateData: CallRecordingUpdateFields = {
573
+ transcript: downloadResult.content as Record<string, unknown>,
574
+ ...(isUndefined(callRecording.externalRecordingId)
575
+ ? buildExternalRecordingIdUpdate(webhookEvent)
576
+ : {}),
577
+ };
578
+
579
+ await persistCallRecordingProgress(client, {
580
+ id: callRecording.id,
581
+ current: callRecording,
582
+ updateData,
583
+ });
584
+
585
+ return {
586
+ status: 'updated',
587
+ event,
588
+ callRecordingId: callRecording.id,
589
+ transcriptOutcome: 'FILLED',
590
+ };
591
+ }
592
+ case 'failed':
593
+ return applyTranscriptFailure({
594
+ client,
595
+ callRecording,
596
+ event,
597
+ transcriptId,
598
+ subCode: downloadResult.subCode,
599
+ });
600
+ case 'pending':
601
+ case 'error': {
602
+ // 200-acked either way, Svix never redelivers; the cron re-check retries this.
603
+ const reason =
604
+ downloadResult.outcome === 'pending'
605
+ ? 'transcript not downloadable yet'
606
+ : downloadResult.errorMessage;
607
+
608
+ console.warn(
609
+ `[call-recorder] could not fill transcript for call recording ${callRecording.id}: ${reason}`,
610
+ );
611
+
612
+ return {
613
+ status: 'skipped',
614
+ event,
615
+ reason,
616
+ };
617
+ }
618
+ }
619
+ };
620
+
621
+ const applyTranscriptFailure = async ({
622
+ client,
623
+ callRecording,
624
+ event,
625
+ transcriptId,
626
+ subCode,
627
+ }: {
628
+ client: CoreApiClient;
629
+ callRecording: MatchedCallRecording;
630
+ event: string;
631
+ transcriptId: string | undefined;
632
+ subCode: string | null;
633
+ }): Promise<RecallWebhookHandlerResult> => {
634
+ const existingMarker = parseTranscriptMarker(callRecording.transcript);
635
+
636
+ if (!isTranscriptUnset(callRecording) && isUndefined(existingMarker)) {
637
+ return {
638
+ status: 'skipped',
639
+ event,
640
+ reason: 'transcript already filled',
641
+ };
642
+ }
643
+
644
+ console.warn(
645
+ `[call-recorder] transcript failed for call recording ${callRecording.id}${isNull(subCode) ? '' : ` (${subCode})`}`,
646
+ );
647
+
648
+ await updateCallRecording(client, {
649
+ id: callRecording.id,
650
+ data: {
651
+ transcript: buildFailedTranscriptMarker({
652
+ recallTranscriptId:
653
+ transcriptId ?? existingMarker?.recallTranscriptId ?? null,
654
+ subCode,
655
+ }),
656
+ callRecorderFailureReason: buildTranscriptFailureReason(subCode),
657
+ ...(isCallRecordingStatusDowngrade({
658
+ fromStatus: callRecording.status,
659
+ toStatus: CallRecordingStatus.FAILED,
660
+ })
661
+ ? {}
662
+ : { status: CallRecordingStatus.FAILED }),
663
+ },
664
+ });
665
+
666
+ return {
667
+ status: 'updated',
668
+ event,
669
+ callRecordingId: callRecording.id,
670
+ transcriptOutcome: 'FAILED',
671
+ };
672
+ };