@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,82 @@
1
+ import { isUndefined } from '@sniptt/guards';
2
+ import { type CoreApiClient } from 'twenty-client-sdk/core';
3
+
4
+ import { type CalendarEventRecord } from 'src/logic-functions/types/calendar-event-record.type';
5
+ import { ensureCallRecorder } from 'src/logic-functions/flows/ensure-call-recorder.util';
6
+ import { fetchCalendarEventsByIds } from 'src/logic-functions/data/fetch-calendar-events-by-ids.util';
7
+ import { findOpenScheduledCallRecordings } from 'src/logic-functions/data/find-open-scheduled-call-recordings.util';
8
+ import { getUniqueSortedIds } from 'src/logic-functions/utils/get-unique-sorted-ids.util';
9
+
10
+ export type HealCallRecordingsMissingBotResult = {
11
+ scheduledCallRecordingIds: string[];
12
+ };
13
+
14
+ // Closes the create-winner crash gap: a run that inserted the row but died before POSTing leaves a botless recording, and the cron is the single writer that re-POSTs it.
15
+ export const healCallRecordingsMissingBot = async ({
16
+ client,
17
+ now,
18
+ }: {
19
+ client: CoreApiClient;
20
+ now: Date;
21
+ }): Promise<HealCallRecordingsMissingBotResult> => {
22
+ const botlessCallRecordings = (
23
+ await findOpenScheduledCallRecordings(client)
24
+ ).filter((callRecording) => isUndefined(callRecording.externalBotId));
25
+
26
+ if (botlessCallRecordings.length === 0) {
27
+ return { scheduledCallRecordingIds: [] };
28
+ }
29
+
30
+ const calendarEventsById = new Map(
31
+ (
32
+ await fetchCalendarEventsByIds(
33
+ client,
34
+ getUniqueSortedIds(
35
+ botlessCallRecordings.map(
36
+ (callRecording) => callRecording.calendarEventId,
37
+ ),
38
+ ),
39
+ )
40
+ ).map((calendarEvent) => [calendarEvent.id, calendarEvent]),
41
+ );
42
+ const scheduledCallRecordingIds: string[] = [];
43
+
44
+ for (const callRecording of botlessCallRecordings) {
45
+ const calendarEvent = isUndefined(callRecording.calendarEventId)
46
+ ? undefined
47
+ : calendarEventsById.get(callRecording.calendarEventId);
48
+
49
+ if (isUndefined(calendarEvent) || hasMeetingEnded({ calendarEvent, now })) {
50
+ continue;
51
+ }
52
+
53
+ const didScheduleCallRecorder = await ensureCallRecorder(client, {
54
+ callRecording,
55
+ calendarEvent,
56
+ });
57
+
58
+ if (didScheduleCallRecorder) {
59
+ scheduledCallRecordingIds.push(callRecording.id);
60
+ }
61
+ }
62
+
63
+ return { scheduledCallRecordingIds };
64
+ };
65
+
66
+ const hasMeetingEnded = ({
67
+ calendarEvent,
68
+ now,
69
+ }: {
70
+ calendarEvent: CalendarEventRecord;
71
+ now: Date;
72
+ }): boolean => {
73
+ const reference = calendarEvent.endsAt ?? calendarEvent.startsAt;
74
+
75
+ if (isUndefined(reference)) {
76
+ return false;
77
+ }
78
+
79
+ const referenceTime = new Date(reference).getTime();
80
+
81
+ return !Number.isNaN(referenceTime) && referenceTime <= now.getTime();
82
+ };
@@ -0,0 +1,128 @@
1
+ import { isUndefined } from '@sniptt/guards';
2
+ import { MetadataApiClient } from 'twenty-client-sdk/metadata';
3
+
4
+ import { CALL_RECORDING_AUDIO_FIELD_UNIVERSAL_IDENTIFIER } from 'src/constants/call-recording-audio-field-universal-identifier';
5
+ import { CALL_RECORDING_VIDEO_FIELD_UNIVERSAL_IDENTIFIER } from 'src/constants/call-recording-video-field-universal-identifier';
6
+ import { extractRecallMediaUrls } from 'src/logic-functions/recall-api/extract-recall-media-urls.util';
7
+ import { getRecallRecording } from 'src/logic-functions/recall-api/get-recall-recording.util';
8
+ import { type CallRecordingMediaFile } from 'src/logic-functions/types/call-recording-media-file.type';
9
+ import { type CallRecordingUpdateFields } from 'src/logic-functions/types/call-recording-update-fields.type';
10
+
11
+ type CallRecordingMediaUpdateFields = Pick<
12
+ CallRecordingUpdateFields,
13
+ 'audio' | 'video'
14
+ >;
15
+
16
+ const MEDIA_DOWNLOAD_TIMEOUT_MS = 120_000;
17
+
18
+ export const ingestCallRecordingMedia = async ({
19
+ callRecordingId,
20
+ externalRecordingId,
21
+ hasAudio,
22
+ hasVideo,
23
+ }: {
24
+ callRecordingId: string;
25
+ externalRecordingId: string;
26
+ hasAudio: boolean;
27
+ hasVideo: boolean;
28
+ }): Promise<CallRecordingMediaUpdateFields> => {
29
+ if (hasAudio && hasVideo) {
30
+ return {};
31
+ }
32
+
33
+ const recordingResult = await getRecallRecording({ externalRecordingId });
34
+
35
+ if (!recordingResult.ok) {
36
+ console.warn(
37
+ `[call-recorder] failed to fetch Recall recording ${externalRecordingId} while ingesting media for call recording ${callRecordingId}: ${recordingResult.errorMessage}`,
38
+ );
39
+
40
+ return {};
41
+ }
42
+
43
+ const mediaUrls = extractRecallMediaUrls(recordingResult.recording);
44
+ const metadataClient = new MetadataApiClient();
45
+ const updateFields: CallRecordingMediaUpdateFields = {};
46
+
47
+ if (!hasVideo && !isUndefined(mediaUrls.videoUrl)) {
48
+ const video = await ingestMediaArtifact({
49
+ callRecordingId,
50
+ metadataClient,
51
+ url: mediaUrls.videoUrl,
52
+ fileName: 'video.mp4',
53
+ fieldMetadataUniversalIdentifier:
54
+ CALL_RECORDING_VIDEO_FIELD_UNIVERSAL_IDENTIFIER,
55
+ });
56
+
57
+ if (!isUndefined(video)) {
58
+ updateFields.video = video;
59
+ }
60
+ }
61
+
62
+ if (!hasAudio && !isUndefined(mediaUrls.audioUrl)) {
63
+ const audio = await ingestMediaArtifact({
64
+ callRecordingId,
65
+ metadataClient,
66
+ url: mediaUrls.audioUrl,
67
+ fileName: 'audio.mp3',
68
+ fieldMetadataUniversalIdentifier:
69
+ CALL_RECORDING_AUDIO_FIELD_UNIVERSAL_IDENTIFIER,
70
+ });
71
+
72
+ if (!isUndefined(audio)) {
73
+ updateFields.audio = audio;
74
+ }
75
+ }
76
+
77
+ return updateFields;
78
+ };
79
+
80
+ const ingestMediaArtifact = async ({
81
+ callRecordingId,
82
+ metadataClient,
83
+ url,
84
+ fileName,
85
+ fieldMetadataUniversalIdentifier,
86
+ }: {
87
+ callRecordingId: string;
88
+ metadataClient: InstanceType<typeof MetadataApiClient>;
89
+ url: string;
90
+ fileName: string;
91
+ fieldMetadataUniversalIdentifier: string;
92
+ }): Promise<CallRecordingMediaFile[] | undefined> => {
93
+ try {
94
+ const { buffer, contentType } = await downloadMediaFile(url);
95
+ const uploadedFile = await metadataClient.uploadFile(
96
+ buffer,
97
+ fileName,
98
+ contentType,
99
+ fieldMetadataUniversalIdentifier,
100
+ );
101
+
102
+ return [{ fileId: uploadedFile.id, label: fileName }];
103
+ } catch (error) {
104
+ console.warn(
105
+ `[call-recorder] failed to ingest ${fileName} for call recording ${callRecordingId}: ${error instanceof Error ? error.message : String(error)}`,
106
+ );
107
+
108
+ return undefined;
109
+ }
110
+ };
111
+
112
+ const downloadMediaFile = async (
113
+ url: string,
114
+ ): Promise<{ buffer: Buffer; contentType: string }> => {
115
+ const response = await fetch(url, {
116
+ signal: AbortSignal.timeout(MEDIA_DOWNLOAD_TIMEOUT_MS),
117
+ });
118
+
119
+ if (!response.ok) {
120
+ throw new Error(`download failed with status ${response.status}`);
121
+ }
122
+
123
+ return {
124
+ buffer: Buffer.from(await response.arrayBuffer()),
125
+ contentType:
126
+ response.headers.get('content-type') ?? 'application/octet-stream',
127
+ };
128
+ };
@@ -0,0 +1,58 @@
1
+ import { type CoreApiClient } from 'twenty-client-sdk/core';
2
+
3
+ import { type FilesFieldValue } from 'src/logic-functions/types/files-field-value.type';
4
+ import { completeAndChargeCallRecording } from 'src/logic-functions/flows/complete-and-charge-call-recording.util';
5
+ import { shouldCompleteCallRecordingIngestion } from 'src/logic-functions/domain/should-complete-call-recording-ingestion.util';
6
+ import { updateCallRecording } from 'src/logic-functions/data/update-call-recording.util';
7
+ import { type CallRecordingUpdateFields } from 'src/logic-functions/types/call-recording-update-fields.type';
8
+
9
+ type PersistCallRecordingProgressCurrent = {
10
+ status?: string;
11
+ startedAt?: string;
12
+ endedAt?: string;
13
+ transcript?: unknown;
14
+ audio?: FilesFieldValue;
15
+ video?: FilesFieldValue;
16
+ };
17
+
18
+ export const persistCallRecordingProgress = async (
19
+ client: CoreApiClient,
20
+ {
21
+ id,
22
+ current,
23
+ updateData,
24
+ }: {
25
+ id: string;
26
+ current: PersistCallRecordingProgressCurrent;
27
+ updateData: CallRecordingUpdateFields;
28
+ },
29
+ ): Promise<{ completesIngestion: boolean }> => {
30
+ const completesIngestion = shouldCompleteCallRecordingIngestion({
31
+ current,
32
+ updateData,
33
+ });
34
+
35
+ if (!completesIngestion) {
36
+ await updateCallRecording(client, { id, data: updateData });
37
+
38
+ return { completesIngestion: false };
39
+ }
40
+
41
+ // Strip status so COMPLETED is written only by the atomic claim — its single winner bills once.
42
+ const nonStatusUpdate: CallRecordingUpdateFields = { ...updateData };
43
+
44
+ delete nonStatusUpdate.status;
45
+ delete nonStatusUpdate.callRecorderFailureReason;
46
+
47
+ if (Object.keys(nonStatusUpdate).length > 0) {
48
+ await updateCallRecording(client, { id, data: nonStatusUpdate });
49
+ }
50
+
51
+ await completeAndChargeCallRecording(client, {
52
+ id,
53
+ startedAt: updateData.startedAt ?? current.startedAt,
54
+ endedAt: updateData.endedAt ?? current.endedAt,
55
+ });
56
+
57
+ return { completesIngestion: true };
58
+ };
@@ -0,0 +1,183 @@
1
+ import { isNull, isUndefined } from '@sniptt/guards';
2
+ import { type CoreApiClient } from 'twenty-client-sdk/core';
3
+
4
+ import { CallRecordingRequestStatus } from 'src/logic-functions/constants/call-recording-request-status';
5
+ import { type CallRecordingRecord } from 'src/logic-functions/types/call-recording-record.type';
6
+ import { cancelRecallBot } from 'src/logic-functions/recall-api/cancel-recall-bot.util';
7
+ import { ejectRecallBot } from 'src/logic-functions/recall-api/eject-recall-bot.util';
8
+ import { findCallRecordingsByIds } from 'src/logic-functions/data/find-call-recordings-by-ids.util';
9
+ import { getCurrentWorkspaceId } from 'src/logic-functions/data/get-current-workspace-id.util';
10
+ import { getUniqueSortedIds } from 'src/logic-functions/utils/get-unique-sorted-ids.util';
11
+ import { isNonEmptyString } from 'src/logic-functions/utils/is-non-empty-string.util';
12
+ import {
13
+ listScheduledRecallBots,
14
+ type RecallScheduledBot,
15
+ } from 'src/logic-functions/recall-api/list-scheduled-recall-bots.util';
16
+
17
+ export type ReapOrphanedCallRecordersResult = {
18
+ scannedBotCount: number;
19
+ canceledExternalBotIds: string[];
20
+ };
21
+
22
+ // Bots no open CallRecording request claims would still join; cancel them on Recall.
23
+ export const reapOrphanedCallRecorders = async ({
24
+ client,
25
+ joinAtAfter,
26
+ joinAtBefore,
27
+ }: {
28
+ client: CoreApiClient;
29
+ joinAtAfter: string;
30
+ joinAtBefore: string;
31
+ }): Promise<ReapOrphanedCallRecordersResult> => {
32
+ const listResult = await listScheduledRecallBots({
33
+ joinAtAfter,
34
+ joinAtBefore,
35
+ });
36
+
37
+ if (!listResult.ok) {
38
+ console.warn(
39
+ `[call-recorder] failed to list Recall bots for orphan reaping: ${listResult.errorMessage}`,
40
+ );
41
+
42
+ return { scannedBotCount: 0, canceledExternalBotIds: [] };
43
+ }
44
+
45
+ const currentWorkspaceId = getCurrentWorkspaceId();
46
+
47
+ if (isUndefined(currentWorkspaceId)) {
48
+ console.warn(
49
+ '[call-recorder] cannot reap orphaned Recall bots: workspace id unavailable',
50
+ );
51
+
52
+ return {
53
+ scannedBotCount: listResult.bots.length,
54
+ canceledExternalBotIds: [],
55
+ };
56
+ }
57
+
58
+ const workspaceManagedBots = listResult.bots.filter((bot) =>
59
+ isCurrentWorkspaceManagedBot({ bot, currentWorkspaceId }),
60
+ );
61
+
62
+ if (workspaceManagedBots.length === 0) {
63
+ return {
64
+ scannedBotCount: listResult.bots.length,
65
+ canceledExternalBotIds: [],
66
+ };
67
+ }
68
+
69
+ const callRecordings = await findCallRecordingsByIds(
70
+ client,
71
+ getUniqueSortedIds(
72
+ workspaceManagedBots.map((bot) => getClaimedCallRecordingId(bot)),
73
+ ),
74
+ );
75
+ const callRecordingsById = new Map(
76
+ callRecordings.map((callRecording) => [callRecording.id, callRecording]),
77
+ );
78
+ const canceledExternalBotIds: string[] = [];
79
+
80
+ for (const bot of workspaceManagedBots) {
81
+ const claimedCallRecordingId = getClaimedCallRecordingId(bot);
82
+ const callRecording = isUndefined(claimedCallRecordingId)
83
+ ? undefined
84
+ : callRecordingsById.get(claimedCallRecordingId);
85
+
86
+ if (isBotClaimed({ bot, callRecording })) {
87
+ continue;
88
+ }
89
+
90
+ console.warn(
91
+ `[call-recorder] canceling orphaned Recall bot ${bot.id} (claimed callRecording: ${claimedCallRecordingId})`,
92
+ );
93
+
94
+ if (await cancelOrEjectRecallBot(bot.id)) {
95
+ canceledExternalBotIds.push(bot.id);
96
+ }
97
+ }
98
+
99
+ return {
100
+ scannedBotCount: listResult.bots.length,
101
+ canceledExternalBotIds,
102
+ };
103
+ };
104
+
105
+ const getClaimedCallRecordingId = (
106
+ bot: RecallScheduledBot,
107
+ ): string | undefined => {
108
+ const claimedCallRecordingId = bot.metadata.twentyCallRecordingId;
109
+
110
+ return normalizeOptionalString(claimedCallRecordingId);
111
+ };
112
+
113
+ const getClaimedWorkspaceId = (bot: RecallScheduledBot): string | undefined => {
114
+ const claimedWorkspaceId = bot.metadata.twentyWorkspaceId;
115
+
116
+ return normalizeOptionalString(claimedWorkspaceId);
117
+ };
118
+
119
+ const isCurrentWorkspaceManagedBot = ({
120
+ bot,
121
+ currentWorkspaceId,
122
+ }: {
123
+ bot: RecallScheduledBot;
124
+ currentWorkspaceId: string;
125
+ }): boolean => {
126
+ if (isUndefined(getClaimedCallRecordingId(bot))) {
127
+ return false;
128
+ }
129
+
130
+ const claimedWorkspaceId = getClaimedWorkspaceId(bot);
131
+
132
+ return claimedWorkspaceId === currentWorkspaceId;
133
+ };
134
+
135
+ const isBotClaimed = ({
136
+ bot,
137
+ callRecording,
138
+ }: {
139
+ bot: RecallScheduledBot;
140
+ callRecording: CallRecordingRecord | undefined;
141
+ }): boolean => {
142
+ if (
143
+ callRecording?.recordingRequestStatus !==
144
+ CallRecordingRequestStatus.REQUESTED
145
+ ) {
146
+ return false;
147
+ }
148
+
149
+ if (callRecording.externalBotId === bot.id) {
150
+ return true;
151
+ }
152
+
153
+ // An id-less REQUESTED recording may have a bot-id write-back in flight; spare its bot.
154
+ return isUndefined(callRecording.externalBotId);
155
+ };
156
+
157
+ const cancelOrEjectRecallBot = async (
158
+ externalBotId: string,
159
+ ): Promise<boolean> => {
160
+ const cancelResult = await cancelRecallBot({ externalBotId });
161
+
162
+ if (cancelResult.ok) {
163
+ return true;
164
+ }
165
+
166
+ // Deleting only works for not-yet-joined bots; eject the ones already in a call.
167
+ if (!isNull(cancelResult.status)) {
168
+ const ejectResult = await ejectRecallBot({ externalBotId });
169
+
170
+ if (ejectResult.ok) {
171
+ return true;
172
+ }
173
+ }
174
+
175
+ console.warn(
176
+ `[call-recorder] failed to cancel orphaned Recall bot ${externalBotId}: ${cancelResult.errorMessage}`,
177
+ );
178
+
179
+ return false;
180
+ };
181
+
182
+ const normalizeOptionalString = (value: unknown): string | undefined =>
183
+ isNonEmptyString(value) ? value.trim() : undefined;