@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,141 @@
1
+ import { isArray, isUndefined } from '@sniptt/guards';
2
+
3
+ import { type RecallBotOperationFailure } from 'src/logic-functions/types/recall-bot-operation-result.type';
4
+ import { asRecord } from 'src/logic-functions/utils/as-record.util';
5
+ import { getString } from 'src/logic-functions/utils/get-string.util';
6
+ import { getRecallApiConfig } from 'src/logic-functions/recall-api/get-recall-api-config.util';
7
+ import { recallBotApiRequest } from 'src/logic-functions/recall-api/recall-bot-api-request.util';
8
+ import { type RecallTranscriptSummary } from 'src/logic-functions/recall-api/recall-transcript-summary.type';
9
+
10
+ type ListRecallTranscriptsResult =
11
+ | { ok: true; transcripts: RecallTranscriptSummary[] }
12
+ | RecallBotOperationFailure;
13
+
14
+ type RecallTranscriptListResponse = {
15
+ next?: unknown;
16
+ results?: unknown;
17
+ };
18
+
19
+ const RECALL_TRANSCRIPT_LIST_MAX_PAGES = 10;
20
+
21
+ export const listRecallTranscripts = async ({
22
+ externalRecordingId,
23
+ }: {
24
+ externalRecordingId: string;
25
+ }): Promise<ListRecallTranscriptsResult> => {
26
+ const configResult = getRecallApiConfig();
27
+
28
+ if (!configResult.success) {
29
+ return { ok: false, status: null, errorMessage: configResult.error };
30
+ }
31
+
32
+ const transcripts: RecallTranscriptSummary[] = [];
33
+ let path: string | undefined = buildListRecallTranscriptsPath({
34
+ externalRecordingId,
35
+ });
36
+
37
+ for (
38
+ let pageIndex = 0;
39
+ !isUndefined(path) && pageIndex < RECALL_TRANSCRIPT_LIST_MAX_PAGES;
40
+ pageIndex++
41
+ ) {
42
+ const result = await recallBotApiRequest<RecallTranscriptListResponse>({
43
+ config: configResult.config,
44
+ path,
45
+ method: 'GET',
46
+ });
47
+
48
+ if (!result.ok) {
49
+ return result;
50
+ }
51
+
52
+ const pageTranscripts = extractRecallTranscriptSummaries(result.data);
53
+
54
+ if (isUndefined(pageTranscripts)) {
55
+ return {
56
+ ok: false,
57
+ status: result.status,
58
+ errorMessage: 'Recall API returned malformed transcript list',
59
+ };
60
+ }
61
+
62
+ transcripts.push(...pageTranscripts);
63
+ path = extractNextPath(result.data, configResult.config.baseUrl);
64
+ }
65
+
66
+ if (!isUndefined(path)) {
67
+ return {
68
+ ok: false,
69
+ status: null,
70
+ errorMessage: `Recall transcript list exceeded ${RECALL_TRANSCRIPT_LIST_MAX_PAGES} pages`,
71
+ };
72
+ }
73
+
74
+ return { ok: true, transcripts };
75
+ };
76
+
77
+ const buildListRecallTranscriptsPath = ({
78
+ externalRecordingId,
79
+ }: {
80
+ externalRecordingId: string;
81
+ }): string => {
82
+ const searchParams = new URLSearchParams({
83
+ recording_id: externalRecordingId,
84
+ });
85
+
86
+ return `/transcript/?${searchParams.toString()}`;
87
+ };
88
+
89
+ const extractRecallTranscriptSummaries = (
90
+ response: RecallTranscriptListResponse | undefined,
91
+ ): RecallTranscriptSummary[] | undefined => {
92
+ if (!isArray(response?.results)) {
93
+ return undefined;
94
+ }
95
+
96
+ const transcripts: RecallTranscriptSummary[] = [];
97
+
98
+ for (const result of response.results) {
99
+ const transcript = extractRecallTranscriptSummary(result);
100
+
101
+ if (isUndefined(transcript)) {
102
+ return undefined;
103
+ }
104
+
105
+ transcripts.push(transcript);
106
+ }
107
+
108
+ return transcripts;
109
+ };
110
+
111
+ const extractRecallTranscriptSummary = (
112
+ transcript: unknown,
113
+ ): RecallTranscriptSummary | undefined => {
114
+ const transcriptRecord = asRecord(transcript);
115
+ const transcriptId = getString(transcriptRecord?.id);
116
+
117
+ if (isUndefined(transcriptRecord) || isUndefined(transcriptId)) {
118
+ return undefined;
119
+ }
120
+
121
+ const status = asRecord(transcriptRecord.status);
122
+
123
+ return {
124
+ id: transcriptId,
125
+ statusCode: getString(status?.code),
126
+ statusSubCode: getString(status?.sub_code),
127
+ };
128
+ };
129
+
130
+ const extractNextPath = (
131
+ response: RecallTranscriptListResponse | undefined,
132
+ baseUrl: string,
133
+ ): string | undefined => {
134
+ const nextPage = getString(response?.next);
135
+
136
+ if (isUndefined(nextPage) || !nextPage.startsWith(baseUrl)) {
137
+ return undefined;
138
+ }
139
+
140
+ return nextPage.slice(baseUrl.length);
141
+ };
@@ -0,0 +1,106 @@
1
+ import { isString, isUndefined } from '@sniptt/guards';
2
+
3
+ import { type RecallBotOperationFailure } from 'src/logic-functions/types/recall-bot-operation-result.type';
4
+ import { asRecord } from 'src/logic-functions/utils/as-record.util';
5
+ import { getRecallApiConfig } from 'src/logic-functions/recall-api/get-recall-api-config.util';
6
+ import { recallBotApiRequest } from 'src/logic-functions/recall-api/recall-bot-api-request.util';
7
+
8
+ export type RecallScheduledBot = {
9
+ id: string;
10
+ metadata: Record<string, unknown>;
11
+ };
12
+
13
+ type RecallBotListResponse = {
14
+ next?: unknown;
15
+ results?: unknown;
16
+ };
17
+
18
+ type ListScheduledRecallBotsResult =
19
+ | { ok: true; bots: RecallScheduledBot[] }
20
+ | RecallBotOperationFailure;
21
+
22
+ const RECALL_BOT_LIST_MAX_PAGES = 10;
23
+
24
+ export const listScheduledRecallBots = async ({
25
+ joinAtAfter,
26
+ joinAtBefore,
27
+ }: {
28
+ joinAtAfter: string;
29
+ joinAtBefore: string;
30
+ }): Promise<ListScheduledRecallBotsResult> => {
31
+ const configResult = getRecallApiConfig();
32
+
33
+ if (!configResult.success) {
34
+ return { ok: false, status: null, errorMessage: configResult.error };
35
+ }
36
+
37
+ const bots: RecallScheduledBot[] = [];
38
+ let path: string | undefined = `/bot/?join_at_after=${encodeURIComponent(
39
+ joinAtAfter,
40
+ )}&join_at_before=${encodeURIComponent(joinAtBefore)}`;
41
+
42
+ for (
43
+ let pageIndex = 0;
44
+ !isUndefined(path) && pageIndex < RECALL_BOT_LIST_MAX_PAGES;
45
+ pageIndex++
46
+ ) {
47
+ const result = await recallBotApiRequest<RecallBotListResponse>({
48
+ config: configResult.config,
49
+ path,
50
+ method: 'GET',
51
+ });
52
+
53
+ if (!result.ok) {
54
+ return result;
55
+ }
56
+
57
+ bots.push(...extractRecallBots(result.data));
58
+ path = extractNextPath(result.data, configResult.config.baseUrl);
59
+ }
60
+
61
+ if (!isUndefined(path)) {
62
+ return {
63
+ ok: false,
64
+ status: null,
65
+ errorMessage: `Recall bot list exceeded ${RECALL_BOT_LIST_MAX_PAGES} pages`,
66
+ };
67
+ }
68
+
69
+ return { ok: true, bots };
70
+ };
71
+
72
+ const extractRecallBots = (
73
+ response: RecallBotListResponse | undefined,
74
+ ): RecallScheduledBot[] => {
75
+ if (!Array.isArray(response?.results)) {
76
+ return [];
77
+ }
78
+
79
+ return response.results.flatMap((candidate: unknown) => {
80
+ const bot = asRecord(candidate);
81
+
82
+ if (isUndefined(bot) || !isString(bot.id)) {
83
+ return [];
84
+ }
85
+
86
+ return [
87
+ {
88
+ id: bot.id,
89
+ metadata: asRecord(bot.metadata) ?? {},
90
+ },
91
+ ];
92
+ });
93
+ };
94
+
95
+ const extractNextPath = (
96
+ response: RecallBotListResponse | undefined,
97
+ baseUrl: string,
98
+ ): string | undefined => {
99
+ const next = response?.next;
100
+
101
+ if (!isString(next) || !next.startsWith(baseUrl)) {
102
+ return undefined;
103
+ }
104
+
105
+ return next.slice(baseUrl.length);
106
+ };
@@ -0,0 +1,14 @@
1
+ import { isUndefined } from '@sniptt/guards';
2
+
3
+ // Twenty rejects Recall's microsecond precision; truncate to millisecond ISO.
4
+ export const normalizeRecallTimestamp = (
5
+ value: string | undefined,
6
+ ): string | undefined => {
7
+ if (isUndefined(value)) {
8
+ return undefined;
9
+ }
10
+
11
+ const parsed = new Date(value);
12
+
13
+ return Number.isNaN(parsed.getTime()) ? undefined : parsed.toISOString();
14
+ };
@@ -0,0 +1,88 @@
1
+ import { isUndefined } from '@sniptt/guards';
2
+
3
+ import { getRecallWebhookBotMetadata } from 'src/logic-functions/recall-api/get-recall-webhook-bot-metadata.util';
4
+ import { asRecord } from 'src/logic-functions/utils/as-record.util';
5
+ import { getRecordAtPath } from 'src/logic-functions/utils/get-record-at-path.util';
6
+ import { getString } from 'src/logic-functions/utils/get-string.util';
7
+ import { normalizeRecallTimestamp } from 'src/logic-functions/recall-api/normalize-recall-timestamp.util';
8
+
9
+ export type RecallWebhookBody = {
10
+ event?: unknown;
11
+ type?: unknown;
12
+ data?: unknown;
13
+ bot?: unknown;
14
+ };
15
+
16
+ export type RecallWebhookEvent = {
17
+ event: string;
18
+ statusCode: string | undefined;
19
+ statusTimestamp: string | undefined;
20
+ externalBotId: string | undefined;
21
+ externalRecordingId: string | undefined;
22
+ callRecordingIdFromMetadata: string | undefined;
23
+ recordingStartedAt: string | undefined;
24
+ recordingEndedAt: string | undefined;
25
+ transcriptId: string | undefined;
26
+ transcriptFailureSubCode: string | undefined;
27
+ };
28
+
29
+ // The only reader of raw webhook payloads; Recall delivers several body shapes per event family.
30
+ export const parseRecallWebhookEvent = (
31
+ body: RecallWebhookBody,
32
+ ): RecallWebhookEvent | undefined => {
33
+ const event = getString(body.event) ?? getString(body.type);
34
+
35
+ if (isUndefined(event)) {
36
+ return undefined;
37
+ }
38
+
39
+ const data = asRecord(body.data);
40
+ const bot = asRecord(body.bot);
41
+
42
+ return {
43
+ event,
44
+ statusCode:
45
+ getString(getRecordAtPath(data, ['status', 'code'])) ??
46
+ getString(getRecordAtPath(data, ['data', 'code'])) ??
47
+ getString(getRecordAtPath(bot, ['status', 'code'])) ??
48
+ getStatusCodeFromEventName(event),
49
+ statusTimestamp: normalizeRecallTimestamp(
50
+ getString(getRecordAtPath(data, ['status', 'created_at'])) ??
51
+ getString(getRecordAtPath(data, ['data', 'updated_at'])) ??
52
+ getString(getRecordAtPath(bot, ['status', 'created_at'])),
53
+ ),
54
+ externalBotId:
55
+ getString(data?.bot_id) ??
56
+ getString(getRecordAtPath(data, ['bot', 'id'])) ??
57
+ getString(getRecordAtPath(data, ['recording', 'bot_id'])) ??
58
+ getString(getRecordAtPath(data, ['recording', 'bot', 'id'])) ??
59
+ getString(bot?.id),
60
+ externalRecordingId:
61
+ getString(getRecordAtPath(data, ['status', 'recording_id'])) ??
62
+ getString(getRecordAtPath(data, ['recording', 'id'])) ??
63
+ getString(data?.recording_id),
64
+ callRecordingIdFromMetadata: getString(
65
+ getRecallWebhookBotMetadata(body)?.twentyCallRecordingId,
66
+ ),
67
+ recordingStartedAt: normalizeRecallTimestamp(
68
+ getString(getRecordAtPath(data, ['recording', 'started_at'])),
69
+ ),
70
+ recordingEndedAt: normalizeRecallTimestamp(
71
+ getString(getRecordAtPath(data, ['recording', 'completed_at'])),
72
+ ),
73
+ transcriptId: getString(getRecordAtPath(data, ['transcript', 'id'])),
74
+ transcriptFailureSubCode: getString(
75
+ getRecordAtPath(data, ['status', 'sub_code']),
76
+ ),
77
+ };
78
+ };
79
+
80
+ const getStatusCodeFromEventName = (event: string): string | undefined => {
81
+ if (!event.startsWith('bot.')) {
82
+ return undefined;
83
+ }
84
+
85
+ const statusCode = event.slice('bot.'.length);
86
+
87
+ return statusCode === 'status_change' ? undefined : statusCode;
88
+ };
@@ -0,0 +1,165 @@
1
+ import { isUndefined } from '@sniptt/guards';
2
+
3
+ import { RECALL_API_MAX_ATTEMPTS } from 'src/logic-functions/constants/recall-api-max-attempts';
4
+ import { RECALL_API_RETRY_DELAY_MS } from 'src/logic-functions/constants/recall-api-retry-delay-ms';
5
+ import { type RecallApiConfig } from 'src/logic-functions/recall-api/get-recall-api-config.util';
6
+
7
+ type RecallBotApiRequestArgs = {
8
+ config: RecallApiConfig;
9
+ path: string;
10
+ method: 'GET' | 'POST' | 'PATCH' | 'DELETE';
11
+ body?: unknown;
12
+ allowNotFound?: boolean;
13
+ maxAttempts?: number;
14
+ };
15
+
16
+ type RecallBotApiRequestResult<TData> =
17
+ | {
18
+ ok: true;
19
+ status: number;
20
+ data: TData;
21
+ }
22
+ | {
23
+ ok: false;
24
+ status: number | null;
25
+ errorMessage: string;
26
+ };
27
+
28
+ // Bot creates tolerate retries because duplicates stay unclaimed and get reaped.
29
+ // Callers that cannot retry idempotently can lower maxAttempts.
30
+ export const recallBotApiRequest = async <TData>(
31
+ requestArgs: RecallBotApiRequestArgs,
32
+ ): Promise<RecallBotApiRequestResult<TData>> => {
33
+ const maxAttempts = requestArgs.maxAttempts ?? RECALL_API_MAX_ATTEMPTS;
34
+
35
+ for (let attemptNumber = 1; ; attemptNumber++) {
36
+ const { result, isRetryable } =
37
+ await performRecallBotApiRequestAttempt<TData>(requestArgs);
38
+
39
+ if (!isRetryable || attemptNumber >= maxAttempts) {
40
+ return result;
41
+ }
42
+
43
+ await sleep(RECALL_API_RETRY_DELAY_MS * attemptNumber);
44
+ }
45
+ };
46
+
47
+ const performRecallBotApiRequestAttempt = async <TData>({
48
+ config,
49
+ path,
50
+ method,
51
+ body,
52
+ allowNotFound = false,
53
+ }: RecallBotApiRequestArgs): Promise<{
54
+ result: RecallBotApiRequestResult<TData>;
55
+ isRetryable: boolean;
56
+ }> => {
57
+ let response: Response;
58
+
59
+ try {
60
+ response = await fetch(`${config.baseUrl}${path}`, {
61
+ method,
62
+ headers: {
63
+ Authorization: buildRecallApiAuthorizationHeader(config.apiKey),
64
+ ...(isUndefined(body) ? {} : { 'Content-Type': 'application/json' }),
65
+ },
66
+ ...(isUndefined(body) ? {} : { body: JSON.stringify(body) }),
67
+ });
68
+ } catch (error) {
69
+ return {
70
+ isRetryable: true,
71
+ result: {
72
+ ok: false,
73
+ status: null,
74
+ errorMessage: `Recall API request failed: ${
75
+ error instanceof Error ? error.message : String(error)
76
+ }`,
77
+ },
78
+ };
79
+ }
80
+
81
+ if (allowNotFound && response.status === 404) {
82
+ return {
83
+ isRetryable: false,
84
+ result: {
85
+ ok: true,
86
+ status: response.status,
87
+ data: undefined as TData,
88
+ },
89
+ };
90
+ }
91
+
92
+ if (response.status === 204) {
93
+ return {
94
+ isRetryable: false,
95
+ result: {
96
+ ok: true,
97
+ status: response.status,
98
+ data: undefined as TData,
99
+ },
100
+ };
101
+ }
102
+
103
+ if (!response.ok) {
104
+ return {
105
+ isRetryable: isRetryableRecallApiStatus(response.status),
106
+ result: {
107
+ ok: false,
108
+ status: response.status,
109
+ errorMessage: await extractRecallApiErrorMessage(response),
110
+ },
111
+ };
112
+ }
113
+
114
+ try {
115
+ return {
116
+ isRetryable: false,
117
+ result: {
118
+ ok: true,
119
+ status: response.status,
120
+ data: (await response.json()) as TData,
121
+ },
122
+ };
123
+ } catch (error) {
124
+ return {
125
+ isRetryable: false,
126
+ result: {
127
+ ok: false,
128
+ status: response.status,
129
+ errorMessage: `Recall API returned a non-JSON response: ${
130
+ error instanceof Error ? error.message : String(error)
131
+ }`,
132
+ },
133
+ };
134
+ }
135
+ };
136
+
137
+ const isRetryableRecallApiStatus = (status: number): boolean =>
138
+ status === 429 || status >= 500;
139
+
140
+ const sleep = (delayMs: number): Promise<void> =>
141
+ new Promise((resolve) => {
142
+ setTimeout(resolve, delayMs);
143
+ });
144
+
145
+ const buildRecallApiAuthorizationHeader = (apiKey: string): string => {
146
+ const trimmedApiKey = apiKey.trim();
147
+
148
+ return trimmedApiKey.toLowerCase().startsWith('token ')
149
+ ? trimmedApiKey
150
+ : `Token ${trimmedApiKey}`;
151
+ };
152
+
153
+ const extractRecallApiErrorMessage = async (
154
+ response: Response,
155
+ ): Promise<string> => {
156
+ const fallback = `Recall API responded with HTTP ${response.status}`;
157
+
158
+ try {
159
+ const body = (await response.json()) as unknown;
160
+
161
+ return `${fallback}: ${JSON.stringify(body)}`;
162
+ } catch {
163
+ return fallback;
164
+ }
165
+ };
@@ -0,0 +1,5 @@
1
+ export type RecallTranscriptSummary = {
2
+ id: string;
3
+ statusCode: string | undefined;
4
+ statusSubCode: string | undefined;
5
+ };
@@ -0,0 +1,56 @@
1
+ import { isUndefined } from '@sniptt/guards';
2
+
3
+ import { getRecallBotAutomaticLeave } from 'src/logic-functions/constants/recall-bot-automatic-leave';
4
+ import { getRecallBotRecordingConfig } from 'src/logic-functions/constants/recall-bot-recording-config';
5
+ import { type RecallBotScheduleResult } from 'src/logic-functions/types/recall-bot-operation-result.type';
6
+ import {
7
+ extractRecallBotId,
8
+ type RecallBotResponse,
9
+ } from 'src/logic-functions/recall-api/extract-recall-bot-id.util';
10
+ import { getRecallApiConfig } from 'src/logic-functions/recall-api/get-recall-api-config.util';
11
+ import { recallBotApiRequest } from 'src/logic-functions/recall-api/recall-bot-api-request.util';
12
+ import { type ScheduleRecallBotArgs } from 'src/logic-functions/recall-api/schedule-recall-bot.util';
13
+
14
+ type RescheduleRecallBotArgs = ScheduleRecallBotArgs & {
15
+ externalBotId: string;
16
+ };
17
+
18
+ export const rescheduleRecallBot = async ({
19
+ externalBotId,
20
+ meetingUrl,
21
+ joinAt,
22
+ metadata,
23
+ }: RescheduleRecallBotArgs): Promise<RecallBotScheduleResult> => {
24
+ const configResult = getRecallApiConfig();
25
+
26
+ if (!configResult.success) {
27
+ return { ok: false, status: null, errorMessage: configResult.error };
28
+ }
29
+
30
+ const automaticLeave = getRecallBotAutomaticLeave();
31
+
32
+ const result = await recallBotApiRequest<RecallBotResponse>({
33
+ config: configResult.config,
34
+ path: `/bot/${externalBotId}/`,
35
+ method: 'PATCH',
36
+ body: {
37
+ meeting_url: meetingUrl,
38
+ join_at: joinAt,
39
+ bot_name: configResult.config.botName,
40
+ ...(isUndefined(automaticLeave)
41
+ ? {}
42
+ : { automatic_leave: automaticLeave }),
43
+ recording_config: getRecallBotRecordingConfig(),
44
+ metadata,
45
+ },
46
+ });
47
+
48
+ if (!result.ok) {
49
+ return result;
50
+ }
51
+
52
+ return {
53
+ ok: true,
54
+ externalBotId: extractRecallBotId(result.data) ?? externalBotId,
55
+ };
56
+ };
@@ -0,0 +1,71 @@
1
+ import { isUndefined } from '@sniptt/guards';
2
+
3
+ import { type RecallBotOperationFailure } from 'src/logic-functions/types/recall-bot-operation-result.type';
4
+ import { asRecord } from 'src/logic-functions/utils/as-record.util';
5
+ import { getRecallApiConfig } from 'src/logic-functions/recall-api/get-recall-api-config.util';
6
+ import { getString } from 'src/logic-functions/utils/get-string.util';
7
+ import { recallBotApiRequest } from 'src/logic-functions/recall-api/recall-bot-api-request.util';
8
+
9
+ export type RecallTranscriptDetails = {
10
+ downloadUrl: string | undefined;
11
+ statusCode: string | undefined;
12
+ statusSubCode: string | undefined;
13
+ };
14
+
15
+ type RetrieveRecallTranscriptResult =
16
+ | { ok: true; transcript: RecallTranscriptDetails }
17
+ | RecallBotOperationFailure;
18
+
19
+ export const retrieveRecallTranscript = async ({
20
+ transcriptId,
21
+ }: {
22
+ transcriptId: string;
23
+ }): Promise<RetrieveRecallTranscriptResult> => {
24
+ const configResult = getRecallApiConfig();
25
+
26
+ if (!configResult.success) {
27
+ return { ok: false, status: null, errorMessage: configResult.error };
28
+ }
29
+
30
+ const result = await recallBotApiRequest<Record<string, unknown>>({
31
+ config: configResult.config,
32
+ path: `/transcript/${transcriptId}/`,
33
+ method: 'GET',
34
+ });
35
+
36
+ if (!result.ok) {
37
+ return result;
38
+ }
39
+
40
+ const transcript = extractRecallTranscriptDetails(result.data);
41
+
42
+ if (isMalformedRecallTranscriptDetails(transcript)) {
43
+ return {
44
+ ok: false,
45
+ status: result.status,
46
+ errorMessage: 'Recall API returned malformed transcript details',
47
+ };
48
+ }
49
+
50
+ return { ok: true, transcript };
51
+ };
52
+
53
+ const extractRecallTranscriptDetails = (
54
+ response: Record<string, unknown> | undefined,
55
+ ): RecallTranscriptDetails => {
56
+ const data = asRecord(response?.data);
57
+ const status = asRecord(response?.status);
58
+
59
+ return {
60
+ downloadUrl: getString(data?.download_url),
61
+ statusCode: getString(status?.code),
62
+ statusSubCode: getString(status?.sub_code),
63
+ };
64
+ };
65
+
66
+ const isMalformedRecallTranscriptDetails = ({
67
+ downloadUrl,
68
+ statusCode,
69
+ }: RecallTranscriptDetails): boolean =>
70
+ (isUndefined(downloadUrl) && isUndefined(statusCode)) ||
71
+ (isUndefined(downloadUrl) && statusCode === 'done');