@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,64 @@
1
+ import { isNonEmptyString } from 'src/logic-functions/utils/is-non-empty-string.util';
2
+
3
+ const MINIMUM_FUZZY_MATCH_KEY_LENGTH = 5;
4
+
5
+ export const getSpeakerNameMatchKeys = (speakerName: string): string[] => {
6
+ const normalizedSpeakerName = normalizeSpeakerName(speakerName);
7
+ const compactSpeakerName = getCompactSpeakerName(normalizedSpeakerName);
8
+ const compactSpeakerNameWithoutDigits = compactSpeakerName.replace(/\d/g, '');
9
+ const abbreviatedSpeakerNameMatchKey = getAbbreviatedSpeakerNameMatchKey(
10
+ normalizedSpeakerName,
11
+ );
12
+
13
+ return [
14
+ ...new Set(
15
+ [
16
+ normalizedSpeakerName,
17
+ compactSpeakerName,
18
+ compactSpeakerNameWithoutDigits,
19
+ abbreviatedSpeakerNameMatchKey,
20
+ ].filter(isSpeakerNameMatchKey),
21
+ ),
22
+ ];
23
+ };
24
+
25
+ const normalizeSpeakerName = (speakerName: string): string =>
26
+ speakerName
27
+ .trim()
28
+ .normalize('NFD')
29
+ .replace(/[\u0300-\u036f]/g, '')
30
+ .toLocaleLowerCase();
31
+
32
+ const getCompactSpeakerName = (speakerName: string): string =>
33
+ normalizeSpeakerName(speakerName).replace(/[^a-z0-9]/g, '');
34
+
35
+ const getAbbreviatedSpeakerNameMatchKey = (
36
+ speakerName: string,
37
+ ): string | undefined => {
38
+ const speakerNameParts = normalizeSpeakerName(speakerName)
39
+ .split(/\s+/)
40
+ .map(getCompactSpeakerName)
41
+ .filter(isNonEmptyString);
42
+
43
+ if (speakerNameParts.length < 2) {
44
+ return undefined;
45
+ }
46
+
47
+ const firstSpeakerNamePart = speakerNameParts[0];
48
+ const lastSpeakerNamePart = speakerNameParts[speakerNameParts.length - 1];
49
+ const abbreviatedSpeakerNameMatchKey = `${firstSpeakerNamePart.slice(
50
+ 0,
51
+ 4,
52
+ )}${lastSpeakerNamePart.slice(0, 4)}`;
53
+
54
+ return abbreviatedSpeakerNameMatchKey.length >= MINIMUM_FUZZY_MATCH_KEY_LENGTH
55
+ ? abbreviatedSpeakerNameMatchKey
56
+ : undefined;
57
+ };
58
+
59
+ const isSpeakerNameMatchKey = (
60
+ speakerNameMatchKey: string | undefined,
61
+ ): speakerNameMatchKey is string =>
62
+ isNonEmptyString(speakerNameMatchKey) &&
63
+ (speakerNameMatchKey.includes(' ') ||
64
+ speakerNameMatchKey.length >= MINIMUM_FUZZY_MATCH_KEY_LENGTH);
@@ -0,0 +1,23 @@
1
+ import { isUndefined } from '@sniptt/guards';
2
+
3
+ import { isNonEmptyString } from 'src/logic-functions/utils/is-non-empty-string.util';
4
+
5
+ export const getVideoFileExtension = ({
6
+ extension,
7
+ label,
8
+ }: {
9
+ extension: string | null;
10
+ label: string | null;
11
+ }): string | undefined => {
12
+ const labelParts = label?.split('.');
13
+ const videoFileExtension =
14
+ extension ??
15
+ (isUndefined(labelParts) ? undefined : labelParts[labelParts.length - 1]);
16
+ const normalizedVideoFileExtension = videoFileExtension
17
+ ?.toLowerCase()
18
+ .replace(/^\./, '');
19
+
20
+ return isNonEmptyString(normalizedVideoFileExtension)
21
+ ? normalizedVideoFileExtension
22
+ : undefined;
23
+ };
@@ -0,0 +1,85 @@
1
+ import { isArray, isNumber, isUndefined } from '@sniptt/guards';
2
+
3
+ import {
4
+ type TranscriptEntry,
5
+ type TranscriptWord,
6
+ } from 'src/front-components/types/transcript-entry.type';
7
+ import { asRecord } from 'src/logic-functions/utils/as-record.util';
8
+ import { isNonEmptyString } from 'src/logic-functions/utils/is-non-empty-string.util';
9
+
10
+ type TranscriptRecord = NonNullable<ReturnType<typeof asRecord>>;
11
+
12
+ const isTranscriptRecord = (
13
+ candidate: TranscriptRecord | undefined,
14
+ ): candidate is TranscriptRecord => !isUndefined(candidate);
15
+
16
+ const readRelativeTimestamp = (
17
+ timestamp: TranscriptRecord | undefined,
18
+ ): number | undefined => {
19
+ const relativeTimestamp = timestamp?.relative;
20
+
21
+ return isNumber(relativeTimestamp) && Number.isFinite(relativeTimestamp)
22
+ ? relativeTimestamp
23
+ : undefined;
24
+ };
25
+
26
+ const readTranscriptWord = (
27
+ candidate: TranscriptRecord,
28
+ ): TranscriptWord | undefined => {
29
+ if (!isNonEmptyString(candidate.text)) {
30
+ return undefined;
31
+ }
32
+
33
+ return {
34
+ text: candidate.text.trim(),
35
+ startSeconds: readRelativeTimestamp(asRecord(candidate.start_timestamp)),
36
+ endSeconds: readRelativeTimestamp(asRecord(candidate.end_timestamp)),
37
+ };
38
+ };
39
+
40
+ const readSpeakerName = (participant: TranscriptRecord | undefined): string => {
41
+ const name = participant?.name;
42
+
43
+ return isNonEmptyString(name) ? name.trim() : 'Unknown speaker';
44
+ };
45
+
46
+ const readTranscriptEntry = (
47
+ candidate: TranscriptRecord,
48
+ ): TranscriptEntry | undefined => {
49
+ if (!isArray(candidate.words)) {
50
+ return undefined;
51
+ }
52
+
53
+ const words = candidate.words
54
+ .map(asRecord)
55
+ .filter(isTranscriptRecord)
56
+ .map(readTranscriptWord)
57
+ .filter((word): word is TranscriptWord => !isUndefined(word));
58
+
59
+ if (words.length === 0) {
60
+ return undefined;
61
+ }
62
+
63
+ return {
64
+ speakerName: readSpeakerName(asRecord(candidate.participant)),
65
+ startSeconds: words[0].startSeconds,
66
+ endSeconds: words[words.length - 1].endSeconds,
67
+ text: words.map((word) => word.text).join(' '),
68
+ words,
69
+ };
70
+ };
71
+
72
+ // Undefined means the value is not a diarized transcript; malformed entries are skipped, not fatal.
73
+ export const parseTranscriptEntries = (
74
+ transcript: unknown,
75
+ ): TranscriptEntry[] | undefined => {
76
+ if (!isArray(transcript)) {
77
+ return undefined;
78
+ }
79
+
80
+ return transcript
81
+ .map(asRecord)
82
+ .filter(isTranscriptRecord)
83
+ .map(readTranscriptEntry)
84
+ .filter((entry): entry is TranscriptEntry => !isUndefined(entry));
85
+ };
@@ -0,0 +1,62 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+
3
+ import processRecallWebhookLogicFunction, {
4
+ processRecallWebhookHandler,
5
+ } from 'src/logic-functions/process-recall-webhook';
6
+
7
+ const handleRecallWebhookMock = vi.hoisted(() => vi.fn());
8
+ const coreApiClientMock = vi.hoisted(() => vi.fn());
9
+
10
+ vi.mock('src/logic-functions/flows/handle-recall-webhook.util', () => ({
11
+ handleRecallWebhook: handleRecallWebhookMock,
12
+ }));
13
+
14
+ vi.mock('twenty-client-sdk/core', () => ({
15
+ CoreApiClient: coreApiClientMock,
16
+ }));
17
+
18
+ const buildRecordingDoneWebhookBody = () => ({
19
+ event: 'recording.done',
20
+ data: {
21
+ bot: {
22
+ id: 'recall-bot-1',
23
+ metadata: {
24
+ twentyWorkspaceId: '123e4567-e89b-12d3-a456-426614174000',
25
+ twentyCallRecordingId: 'call-recording-1',
26
+ },
27
+ },
28
+ recording: { id: 'recall-recording-1' },
29
+ },
30
+ });
31
+
32
+ describe('process-recall-webhook', () => {
33
+ beforeEach(() => {
34
+ handleRecallWebhookMock.mockReset();
35
+ handleRecallWebhookMock.mockResolvedValue({ status: 'updated' });
36
+ coreApiClientMock.mockReset();
37
+ });
38
+
39
+ it('declares no external trigger so it only runs when dispatched by the resolver', () => {
40
+ expect(processRecallWebhookLogicFunction.success).toBe(true);
41
+ expect(
42
+ 'serverRouteTriggerSettings' in processRecallWebhookLogicFunction.config,
43
+ ).toBe(false);
44
+ expect(
45
+ processRecallWebhookLogicFunction.config.httpRouteTriggerSettings,
46
+ ).toBeUndefined();
47
+ });
48
+
49
+ it('forwards the resolved payload to handleRecallWebhook with a workspace-scoped client', async () => {
50
+ const body = buildRecordingDoneWebhookBody();
51
+
52
+ const result = await processRecallWebhookHandler(body);
53
+
54
+ expect(coreApiClientMock).toHaveBeenCalledTimes(1);
55
+ expect(handleRecallWebhookMock).toHaveBeenCalledTimes(1);
56
+ expect(handleRecallWebhookMock).toHaveBeenCalledWith({
57
+ client: coreApiClientMock.mock.instances[0],
58
+ body,
59
+ });
60
+ expect(result).toEqual({ status: 'updated' });
61
+ });
62
+ });
@@ -0,0 +1,180 @@
1
+ import { createHmac } from 'crypto';
2
+
3
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
4
+
5
+ import { PROCESS_RECALL_WEBHOOK_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER } from 'src/constants/process-recall-webhook-logic-function-universal-identifier';
6
+ import recallWebhookLogicFunction, {
7
+ recallWebhookRouteHandler,
8
+ } from 'src/logic-functions/recall-webhook';
9
+
10
+ const getApplicationVariableValueMock = vi.hoisted(() => vi.fn());
11
+
12
+ vi.mock(
13
+ 'src/logic-functions/utils/get-application-variable-value.util',
14
+ () => ({
15
+ getApplicationVariableValue: getApplicationVariableValueMock,
16
+ }),
17
+ );
18
+
19
+ const SECRET_BYTES = Buffer.from('entry-test-secret');
20
+ const SECRET = `whsec_${SECRET_BYTES.toString('base64')}`;
21
+ const WORKSPACE_ID = '123e4567-e89b-12d3-a456-426614174000';
22
+ const CALL_RECORDING_ID = 'call-recording-1';
23
+
24
+ type RecallWebhookRoutePayload = Parameters<
25
+ typeof recallWebhookRouteHandler
26
+ >[0];
27
+
28
+ const buildRoutePayload = (
29
+ overrides: Partial<RecallWebhookRoutePayload>,
30
+ ): RecallWebhookRoutePayload =>
31
+ ({
32
+ headers: {},
33
+ ...overrides,
34
+ }) as RecallWebhookRoutePayload;
35
+
36
+ const buildSignedHeaders = (rawBody: string): Record<string, string> => {
37
+ const webhookId = 'msg_entry_test';
38
+ const webhookTimestamp = Math.floor(Date.now() / 1000).toString();
39
+ const signature = createHmac('sha256', SECRET_BYTES)
40
+ .update(`${webhookId}.${webhookTimestamp}.${rawBody}`)
41
+ .digest('base64');
42
+
43
+ return {
44
+ 'webhook-id': webhookId,
45
+ 'webhook-timestamp': webhookTimestamp,
46
+ 'webhook-signature': `v1,${signature}`,
47
+ };
48
+ };
49
+
50
+ const buildRecordingDoneWebhookBody = () => ({
51
+ event: 'recording.done',
52
+ data: {
53
+ bot: {
54
+ id: 'recall-bot-1',
55
+ metadata: {
56
+ twentyWorkspaceId: WORKSPACE_ID,
57
+ twentyCallRecordingId: CALL_RECORDING_ID,
58
+ },
59
+ },
60
+ recording: {
61
+ id: 'recall-recording-1',
62
+ },
63
+ },
64
+ });
65
+
66
+ describe('recallWebhookRouteHandler', () => {
67
+ beforeEach(() => {
68
+ getApplicationVariableValueMock.mockReset();
69
+ getApplicationVariableValueMock.mockReturnValue(SECRET);
70
+ });
71
+
72
+ it('declares a server route trigger that forwards the webhook signature headers', () => {
73
+ expect(recallWebhookLogicFunction.success).toBe(true);
74
+ expect(
75
+ recallWebhookLogicFunction.config.httpRouteTriggerSettings,
76
+ ).toBeUndefined();
77
+ expect(
78
+ 'serverRouteTriggerSettings' in recallWebhookLogicFunction.config,
79
+ ).toBe(true);
80
+
81
+ if (!('serverRouteTriggerSettings' in recallWebhookLogicFunction.config)) {
82
+ throw new Error('Expected a server route trigger');
83
+ }
84
+
85
+ expect(
86
+ recallWebhookLogicFunction.config.serverRouteTriggerSettings,
87
+ ).toEqual({
88
+ forwardedRequestHeaders: [
89
+ 'webhook-id',
90
+ 'webhook-timestamp',
91
+ 'webhook-signature',
92
+ 'svix-id',
93
+ 'svix-timestamp',
94
+ 'svix-signature',
95
+ ],
96
+ });
97
+ });
98
+
99
+ it('throws when the webhook secret is not configured', () => {
100
+ getApplicationVariableValueMock.mockReturnValue(undefined);
101
+
102
+ expect(() =>
103
+ recallWebhookRouteHandler(buildRoutePayload({ rawBody: '{}', body: {} })),
104
+ ).toThrow('RECALL_WEBHOOK_SECRET');
105
+ });
106
+
107
+ it('throws when the raw body is not forwarded', () => {
108
+ expect(() =>
109
+ recallWebhookRouteHandler(buildRoutePayload({ body: {} })),
110
+ ).toThrow('Raw request body');
111
+ });
112
+
113
+ it('throws when the signature is invalid', () => {
114
+ expect(() =>
115
+ recallWebhookRouteHandler(
116
+ buildRoutePayload({
117
+ rawBody: '{}',
118
+ body: {},
119
+ headers: {
120
+ 'webhook-id': 'msg_entry_test',
121
+ 'webhook-timestamp': Math.floor(Date.now() / 1000).toString(),
122
+ 'webhook-signature': 'v1,not-a-real-signature',
123
+ },
124
+ }),
125
+ ),
126
+ ).toThrow('Invalid webhook signature');
127
+ });
128
+
129
+ it('throws when a correctly signed payload is empty', () => {
130
+ const rawBody = 'null';
131
+
132
+ expect(() =>
133
+ recallWebhookRouteHandler(
134
+ buildRoutePayload({
135
+ rawBody,
136
+ body: null,
137
+ headers: buildSignedHeaders(rawBody),
138
+ }),
139
+ ),
140
+ ).toThrow('Webhook payload was empty');
141
+ });
142
+
143
+ it('throws when the workspace id is missing from the bot metadata', () => {
144
+ const body = {
145
+ event: 'recording.done',
146
+ data: { bot: { id: 'recall-bot-1' } },
147
+ };
148
+ const rawBody = JSON.stringify(body);
149
+
150
+ expect(() =>
151
+ recallWebhookRouteHandler(
152
+ buildRoutePayload({
153
+ rawBody,
154
+ body,
155
+ headers: buildSignedHeaders(rawBody),
156
+ }),
157
+ ),
158
+ ).toThrow('workspace id');
159
+ });
160
+
161
+ it('resolves the target workspace for a correctly signed payload', () => {
162
+ const body = buildRecordingDoneWebhookBody();
163
+ const rawBody = JSON.stringify(body);
164
+
165
+ const result = recallWebhookRouteHandler(
166
+ buildRoutePayload({
167
+ rawBody,
168
+ body,
169
+ headers: buildSignedHeaders(rawBody),
170
+ }),
171
+ );
172
+
173
+ expect(result).toEqual({
174
+ workspaceId: WORKSPACE_ID,
175
+ targetLogicFunctionUniversalIdentifier:
176
+ PROCESS_RECALL_WEBHOOK_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER,
177
+ payload: body,
178
+ });
179
+ });
180
+ });
@@ -0,0 +1,2 @@
1
+ export const CALL_RECORDER_EVERYONE_LEFT_TIMEOUT_SECONDS_ENV_VAR_NAME =
2
+ 'CALL_RECORDER_EVERYONE_LEFT_TIMEOUT_SECONDS';
@@ -0,0 +1 @@
1
+ export const CALL_RECORDER_EVERYONE_LEFT_TIMEOUT_SECONDS = 2;
@@ -0,0 +1,2 @@
1
+ export const CALL_RECORDER_JOIN_EARLY_MINUTES_ENV_VAR_NAME =
2
+ 'CALL_RECORDER_JOIN_EARLY_MINUTES';
@@ -0,0 +1 @@
1
+ export const CALL_RECORDER_NAME_ENV_VAR_NAME = 'CALL_RECORDER_NAME';
@@ -0,0 +1,2 @@
1
+ export const CALL_RECORDER_NOONE_JOINED_TIMEOUT_SECONDS_ENV_VAR_NAME =
2
+ 'CALL_RECORDER_NOONE_JOINED_TIMEOUT_SECONDS';
@@ -0,0 +1 @@
1
+ export const CALL_RECORDER_NOONE_JOINED_TIMEOUT_SECONDS = 20 * 60;
@@ -0,0 +1,2 @@
1
+ export const CALL_RECORDER_RECORDING_RETENTION_HOURS_ENV_VAR_NAME =
2
+ 'CALL_RECORDER_RECORDING_RETENTION_HOURS';
@@ -0,0 +1,2 @@
1
+ export const CALL_RECORDER_WAITING_ROOM_TIMEOUT_SECONDS_ENV_VAR_NAME =
2
+ 'CALL_RECORDER_WAITING_ROOM_TIMEOUT_SECONDS';
@@ -0,0 +1 @@
1
+ export const CALL_RECORDER_WAITING_ROOM_TIMEOUT_SECONDS = 1200;
@@ -0,0 +1 @@
1
+ export const CALL_RECORDING_MICRO_CREDITS_PER_HOUR = 1_000_000;
@@ -0,0 +1,5 @@
1
+ // Mirrors the core select options; guarded by the schema integration test.
2
+ export enum CallRecordingRequestStatus {
3
+ REQUESTED = 'REQUESTED',
4
+ CANCELED = 'CANCELED',
5
+ }
@@ -0,0 +1,9 @@
1
+ // Mirrors the core select options; guarded by the schema integration test.
2
+ export enum CallRecordingStatus {
3
+ SCHEDULED = 'SCHEDULED',
4
+ JOINING = 'JOINING',
5
+ RECORDING = 'RECORDING',
6
+ PROCESSING = 'PROCESSING',
7
+ COMPLETED = 'COMPLETED',
8
+ FAILED = 'FAILED',
9
+ }
@@ -0,0 +1 @@
1
+ export const DEFAULT_CALL_RECORDER_JOIN_EARLY_MINUTES = 1;
@@ -0,0 +1 @@
1
+ export const DEFAULT_CALL_RECORDER_NAME = 'Twenty.com';
@@ -0,0 +1,2 @@
1
+ // Twenty stores ingested recording artifacts, so Recall.ai media is temporary. Keep the default below Recall.ai's 168-hour free storage window.
2
+ export const DEFAULT_CALL_RECORDER_RECORDING_RETENTION_HOURS = 166;
@@ -0,0 +1 @@
1
+ export const DEFAULT_RECALL_REGION = 'eu-central-1';
@@ -0,0 +1 @@
1
+ export const MILLISECONDS_PER_MINUTE = 60_000;
@@ -0,0 +1,8 @@
1
+ import { CallRecordingStatus } from 'src/logic-functions/constants/call-recording-status';
2
+
3
+ export const NON_TERMINAL_CALL_RECORDING_STATUSES = [
4
+ CallRecordingStatus.SCHEDULED,
5
+ CallRecordingStatus.JOINING,
6
+ CallRecordingStatus.RECORDING,
7
+ CallRecordingStatus.PROCESSING,
8
+ ] satisfies CallRecordingStatus[];
@@ -0,0 +1 @@
1
+ export const RECALL_API_KEY_ENV_VAR_NAME = 'RECALL_API_KEY';
@@ -0,0 +1 @@
1
+ export const RECALL_API_MAX_ATTEMPTS = 3;
@@ -0,0 +1 @@
1
+ export const RECALL_API_RETRY_DELAY_MS = 500;
@@ -0,0 +1,74 @@
1
+ import { isUndefined } from '@sniptt/guards';
2
+
3
+ import { CALL_RECORDER_EVERYONE_LEFT_TIMEOUT_SECONDS_ENV_VAR_NAME } from 'src/logic-functions/constants/call-recorder-everyone-left-timeout-seconds-env-var-name';
4
+ import { CALL_RECORDER_NOONE_JOINED_TIMEOUT_SECONDS_ENV_VAR_NAME } from 'src/logic-functions/constants/call-recorder-noone-joined-timeout-seconds-env-var-name';
5
+ import { CALL_RECORDER_WAITING_ROOM_TIMEOUT_SECONDS_ENV_VAR_NAME } from 'src/logic-functions/constants/call-recorder-waiting-room-timeout-seconds-env-var-name';
6
+ import { RECALL_BOT_EVERYONE_LEFT_MIN_ACTIVATE_AFTER_SECONDS } from 'src/logic-functions/constants/recall-bot-everyone-left-min-activate-after-seconds';
7
+ import { getApplicationVariableValue } from 'src/logic-functions/utils/get-application-variable-value.util';
8
+ import { isNonEmptyString } from 'src/logic-functions/utils/is-non-empty-string.util';
9
+
10
+ type RecallBotAutomaticLeave = {
11
+ waiting_room_timeout?: number;
12
+ noone_joined_timeout?: number;
13
+ everyone_left_timeout?: {
14
+ timeout: number;
15
+ activate_after: number;
16
+ };
17
+ };
18
+
19
+ export const getRecallBotAutomaticLeave = ():
20
+ | RecallBotAutomaticLeave
21
+ | undefined => {
22
+ const waitingRoomTimeoutSeconds = getOptionalPositiveIntegerVariable(
23
+ CALL_RECORDER_WAITING_ROOM_TIMEOUT_SECONDS_ENV_VAR_NAME,
24
+ );
25
+ const nooneJoinedTimeoutSeconds = getOptionalPositiveIntegerVariable(
26
+ CALL_RECORDER_NOONE_JOINED_TIMEOUT_SECONDS_ENV_VAR_NAME,
27
+ );
28
+ const everyoneLeftTimeoutSeconds = getOptionalPositiveIntegerVariable(
29
+ CALL_RECORDER_EVERYONE_LEFT_TIMEOUT_SECONDS_ENV_VAR_NAME,
30
+ );
31
+
32
+ const automaticLeave: RecallBotAutomaticLeave = {};
33
+
34
+ if (!isUndefined(waitingRoomTimeoutSeconds)) {
35
+ automaticLeave.waiting_room_timeout = waitingRoomTimeoutSeconds;
36
+ }
37
+
38
+ if (!isUndefined(nooneJoinedTimeoutSeconds)) {
39
+ automaticLeave.noone_joined_timeout = nooneJoinedTimeoutSeconds;
40
+ }
41
+
42
+ if (!isUndefined(everyoneLeftTimeoutSeconds)) {
43
+ automaticLeave.everyone_left_timeout = {
44
+ timeout: everyoneLeftTimeoutSeconds,
45
+ activate_after: RECALL_BOT_EVERYONE_LEFT_MIN_ACTIVATE_AFTER_SECONDS,
46
+ };
47
+ }
48
+
49
+ return Object.keys(automaticLeave).length === 0 ? undefined : automaticLeave;
50
+ };
51
+
52
+ const getOptionalPositiveIntegerVariable = (
53
+ variableName: string,
54
+ ): number | undefined => {
55
+ const rawValue = normalizeOptionalString(
56
+ getApplicationVariableValue(variableName),
57
+ );
58
+
59
+ if (isUndefined(rawValue)) {
60
+ return undefined;
61
+ }
62
+
63
+ const timeoutSeconds = Number(rawValue);
64
+
65
+ if (!Number.isInteger(timeoutSeconds) || timeoutSeconds <= 0) {
66
+ return undefined;
67
+ }
68
+
69
+ return timeoutSeconds;
70
+ };
71
+
72
+ const normalizeOptionalString = (
73
+ value: string | undefined,
74
+ ): string | undefined => (isNonEmptyString(value) ? value.trim() : undefined);
@@ -0,0 +1 @@
1
+ export const RECALL_BOT_EVERYONE_LEFT_MIN_ACTIVATE_AFTER_SECONDS = 1;
@@ -0,0 +1,34 @@
1
+ import { CALL_RECORDER_RECORDING_RETENTION_HOURS_ENV_VAR_NAME } from 'src/logic-functions/constants/call-recorder-recording-retention-hours-env-var-name';
2
+ import { DEFAULT_CALL_RECORDER_RECORDING_RETENTION_HOURS } from 'src/logic-functions/constants/default-call-recorder-recording-retention-hours';
3
+ import { getApplicationVariableValue } from 'src/logic-functions/utils/get-application-variable-value.util';
4
+ import { isNonEmptyString } from 'src/logic-functions/utils/is-non-empty-string.util';
5
+
6
+ type RecallBotRecordingConfig = {
7
+ video_mixed_mp4: Record<string, never>;
8
+ audio_mixed_mp3: Record<string, never>;
9
+ retention: { type: 'timed'; hours: number };
10
+ };
11
+
12
+ // Recall only produces artifacts declared at bot creation; both gate COMPLETED.
13
+ export const getRecallBotRecordingConfig = (): RecallBotRecordingConfig => {
14
+ const configuredRecordingRetentionHours = getApplicationVariableValue(
15
+ CALL_RECORDER_RECORDING_RETENTION_HOURS_ENV_VAR_NAME,
16
+ );
17
+
18
+ const recordingRetentionHours = isNonEmptyString(
19
+ configuredRecordingRetentionHours,
20
+ )
21
+ ? Number(configuredRecordingRetentionHours.trim())
22
+ : NaN;
23
+
24
+ const resolvedRecordingRetentionHours =
25
+ Number.isInteger(recordingRetentionHours) && recordingRetentionHours > 0
26
+ ? recordingRetentionHours
27
+ : DEFAULT_CALL_RECORDER_RECORDING_RETENTION_HOURS;
28
+
29
+ return {
30
+ video_mixed_mp4: {},
31
+ audio_mixed_mp3: {},
32
+ retention: { type: 'timed', hours: resolvedRecordingRetentionHours },
33
+ };
34
+ };
@@ -0,0 +1 @@
1
+ export const RECALL_REGION_ENV_VAR_NAME = 'RECALL_REGION';
@@ -0,0 +1 @@
1
+ export const RECALL_WEBHOOK_SECRET_ENV_VAR_NAME = 'RECALL_WEBHOOK_SECRET';
@@ -0,0 +1,3 @@
1
+ // Mirrors twenty-shared; calendar restrictions write it over title/description.
2
+ export const RESTRICTED_FIELD_PLACEHOLDER =
3
+ 'FIELD_RESTRICTED_ADDITIONAL_PERMISSIONS_REQUIRED';
@@ -0,0 +1 @@
1
+ export const STALE_BOT_STATE_CRON_PATTERN = '*/5 * * * *';
@@ -0,0 +1 @@
1
+ export const TWENTY_PAGE_SIZE = 100;