From d7ac2ae95a5ed55a16420d17b376edc351e9e1be Mon Sep 17 00:00:00 2001 From: Gianfranco Paoloni Date: Tue, 30 Jun 2026 23:56:14 -0300 Subject: [PATCH 01/31] chore: add recordingCompleteCallback scafold --- .../voicemail/recordingCompleteCallback.ts | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 lambdas/account-scoped/src/voicemail/recordingCompleteCallback.ts diff --git a/lambdas/account-scoped/src/voicemail/recordingCompleteCallback.ts b/lambdas/account-scoped/src/voicemail/recordingCompleteCallback.ts new file mode 100644 index 0000000000..8dd54850e5 --- /dev/null +++ b/lambdas/account-scoped/src/voicemail/recordingCompleteCallback.ts @@ -0,0 +1,38 @@ +/** + * Copyright (C) 2021-2023 Technology Matters + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +import { AccountScopedHandler, HttpError } from '../httpTypes'; +import { newOk, Result } from '../Result'; + +export type RecordingCompleteCallbackRequestBody = { + callFrom: string; +}; + +export const checkBlockListHandler: AccountScopedHandler = async ({ + body, +}): Promise> => { + console.debug('checkBlockListHandler body', JSON.stringify(body, null, 2)); + // const { callFrom } = body as RecordingCompleteCallbackRequestBody; + + // if (!callFrom) { + // return newErr({ + // message: 'callFrom parameter is missing', + // error: { statusCode: 400 }, + // }); + // } + + return newOk({}); +}; From 7ddc453200def8448579913a51eaed46e78a0315 Mon Sep 17 00:00:00 2001 From: Gianfranco Paoloni Date: Wed, 1 Jul 2026 00:01:31 -0300 Subject: [PATCH 02/31] chore: expose route --- lambdas/account-scoped/src/router.ts | 5 +++++ .../src/voicemail/recordingCompleteCallback.ts | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/lambdas/account-scoped/src/router.ts b/lambdas/account-scoped/src/router.ts index ae6b846509..4b7bf52ade 100644 --- a/lambdas/account-scoped/src/router.ts +++ b/lambdas/account-scoped/src/router.ts @@ -74,6 +74,7 @@ import { sendMessageAndRunJanitorHandler } from './conversation/sendMessageAndRu import { issueSyncTokenHandler } from './issueSyncToken'; import { getExternalRecordingS3LocationHandler } from './conversation/getExternalRecordingS3Location'; import { getMediaUrlHandler } from './conversation/getMediaUrl'; +import { recordingCompleteCallback } from './voicemail/recordingCompleteCallback'; /** * Super simple router sufficient for directly ported Twilio Serverless functions @@ -275,6 +276,10 @@ const ACCOUNTSID_ROUTES: Record< requestPipeline: [validateWebhookRequest], handler: sendMessageAndRunJanitorHandler, }, + 'voicemail/recordingCompleteCallback': { + requestPipeline: [validateWebhookRequest], + handler: recordingCompleteCallback, + }, issueSyncToken: { requestPipeline: [validateFlexTokenRequest({ tokenMode: 'agent' })], handler: issueSyncTokenHandler, diff --git a/lambdas/account-scoped/src/voicemail/recordingCompleteCallback.ts b/lambdas/account-scoped/src/voicemail/recordingCompleteCallback.ts index 8dd54850e5..30f1c8e016 100644 --- a/lambdas/account-scoped/src/voicemail/recordingCompleteCallback.ts +++ b/lambdas/account-scoped/src/voicemail/recordingCompleteCallback.ts @@ -21,7 +21,7 @@ export type RecordingCompleteCallbackRequestBody = { callFrom: string; }; -export const checkBlockListHandler: AccountScopedHandler = async ({ +export const recordingCompleteCallback: AccountScopedHandler = async ({ body, }): Promise> => { console.debug('checkBlockListHandler body', JSON.stringify(body, null, 2)); From dfc15e0309379ce68d2d6b160c0f5206a5116223 Mon Sep 17 00:00:00 2001 From: Gianfranco Paoloni Date: Thu, 2 Jul 2026 00:20:20 -0300 Subject: [PATCH 03/31] chore: create voicemail task --- .../voicemail/recordingCompleteCallback.ts | 26 +++++++++++++++---- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/lambdas/account-scoped/src/voicemail/recordingCompleteCallback.ts b/lambdas/account-scoped/src/voicemail/recordingCompleteCallback.ts index 30f1c8e016..0489516c48 100644 --- a/lambdas/account-scoped/src/voicemail/recordingCompleteCallback.ts +++ b/lambdas/account-scoped/src/voicemail/recordingCompleteCallback.ts @@ -14,6 +14,7 @@ * along with this program. If not, see https://www.gnu.org/licenses/. */ +import { getTwilioClient, getWorkspaceSid } from '@tech-matters/twilio-configuration'; import { AccountScopedHandler, HttpError } from '../httpTypes'; import { newOk, Result } from '../Result'; @@ -21,10 +22,11 @@ export type RecordingCompleteCallbackRequestBody = { callFrom: string; }; -export const recordingCompleteCallback: AccountScopedHandler = async ({ - body, -}): Promise> => { - console.debug('checkBlockListHandler body', JSON.stringify(body, null, 2)); +export const recordingCompleteCallback: AccountScopedHandler = async ( + { body }, + accountSid, +): Promise> => { + console.debug('recordingCompleteCallback body', JSON.stringify(body, null, 2)); // const { callFrom } = body as RecordingCompleteCallbackRequestBody; // if (!callFrom) { @@ -34,5 +36,19 @@ export const recordingCompleteCallback: AccountScopedHandler = async ({ // }); // } - return newOk({}); + const twilioClient = await getTwilioClient(accountSid); + + const workspaceSid = await getWorkspaceSid(accountSid); + const voicemailTask = await twilioClient.taskrouter.v1 + .workspaces(workspaceSid) + .tasks.create({ + timeout: 604800, // 7 days + attributes: { isVoicemail: true, ...(body.routingAttributes ?? {}) }, + taskQueueSid: body.voicemailQueueSid, + workflowSid: body.voicemailWorkflowSid, + // TODO: factor out channel types into an enum + taskChannel: 'voice', + }); + + return newOk({ voicemailTask }); }; From d7af7181cc46060558e3bc4b9a77f6637ce0d1d4 Mon Sep 17 00:00:00 2001 From: Gianfranco Paoloni Date: Thu, 2 Jul 2026 16:09:27 -0300 Subject: [PATCH 04/31] chore: use voicemail task channel --- .../account-scoped/src/voicemail/recordingCompleteCallback.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lambdas/account-scoped/src/voicemail/recordingCompleteCallback.ts b/lambdas/account-scoped/src/voicemail/recordingCompleteCallback.ts index 0489516c48..f95ddc5e60 100644 --- a/lambdas/account-scoped/src/voicemail/recordingCompleteCallback.ts +++ b/lambdas/account-scoped/src/voicemail/recordingCompleteCallback.ts @@ -47,7 +47,7 @@ export const recordingCompleteCallback: AccountScopedHandler = async ( taskQueueSid: body.voicemailQueueSid, workflowSid: body.voicemailWorkflowSid, // TODO: factor out channel types into an enum - taskChannel: 'voice', + taskChannel: 'voicemail', }); return newOk({ voicemailTask }); From ce94f7d3e1ac59316d14ed09c34e25076933cae5 Mon Sep 17 00:00:00 2001 From: Gianfranco Paoloni Date: Thu, 2 Jul 2026 16:40:45 -0300 Subject: [PATCH 05/31] fix: remove explicit task queue --- .../account-scoped/src/voicemail/recordingCompleteCallback.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/lambdas/account-scoped/src/voicemail/recordingCompleteCallback.ts b/lambdas/account-scoped/src/voicemail/recordingCompleteCallback.ts index f95ddc5e60..c6dee42ec6 100644 --- a/lambdas/account-scoped/src/voicemail/recordingCompleteCallback.ts +++ b/lambdas/account-scoped/src/voicemail/recordingCompleteCallback.ts @@ -44,7 +44,6 @@ export const recordingCompleteCallback: AccountScopedHandler = async ( .tasks.create({ timeout: 604800, // 7 days attributes: { isVoicemail: true, ...(body.routingAttributes ?? {}) }, - taskQueueSid: body.voicemailQueueSid, workflowSid: body.voicemailWorkflowSid, // TODO: factor out channel types into an enum taskChannel: 'voicemail', From c398609a3f3868dd863c15e27ceec8b71c92918e Mon Sep 17 00:00:00 2001 From: Gianfranco Paoloni Date: Thu, 2 Jul 2026 20:48:56 -0300 Subject: [PATCH 06/31] fix: stringify task attributes --- .../src/voicemail/recordingCompleteCallback.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/lambdas/account-scoped/src/voicemail/recordingCompleteCallback.ts b/lambdas/account-scoped/src/voicemail/recordingCompleteCallback.ts index c6dee42ec6..749fea8c69 100644 --- a/lambdas/account-scoped/src/voicemail/recordingCompleteCallback.ts +++ b/lambdas/account-scoped/src/voicemail/recordingCompleteCallback.ts @@ -43,7 +43,11 @@ export const recordingCompleteCallback: AccountScopedHandler = async ( .workspaces(workspaceSid) .tasks.create({ timeout: 604800, // 7 days - attributes: { isVoicemail: true, ...(body.routingAttributes ?? {}) }, + attributes: JSON.stringify({ + ...(body.routingAttributes ?? {}), + isVoicemail: true, + callSid: body.callSid, + }), workflowSid: body.voicemailWorkflowSid, // TODO: factor out channel types into an enum taskChannel: 'voicemail', From ce4107c1e188713fa3de01e9d57e3b7a3fa6b4a6 Mon Sep 17 00:00:00 2001 From: Gianfranco Paoloni Date: Wed, 8 Jul 2026 01:01:20 -0300 Subject: [PATCH 07/31] chore: attach conversation media on contact creation --- .../getExternalRecordingS3Location.ts | 30 ++++++++++++------- .../hrm/createHrmContactTaskRouterListener.ts | 24 +++++++++++++++ .../src/hrm/sanitizeIdentifier.ts | 1 + .../voicemail/recordingCompleteCallback.ts | 1 + lambdas/packages/hrm-types/src/index.ts | 1 + 5 files changed, 47 insertions(+), 10 deletions(-) diff --git a/lambdas/account-scoped/src/conversation/getExternalRecordingS3Location.ts b/lambdas/account-scoped/src/conversation/getExternalRecordingS3Location.ts index 9401d34854..7f949ece2b 100644 --- a/lambdas/account-scoped/src/conversation/getExternalRecordingS3Location.ts +++ b/lambdas/account-scoped/src/conversation/getExternalRecordingS3Location.ts @@ -20,16 +20,13 @@ import { newErr, newOk } from '../Result'; import { getTwilioClient, getDocsBucketName } from '@tech-matters/twilio-configuration'; import { newMissingParameterResult } from '../httpErrors'; -export const getExternalRecordingS3LocationHandler: FlexValidatedHandler = async ( - { body: event }, - accountSid: AccountSID, -) => { - const { callSid } = event as { callSid?: string }; - - if (!callSid) { - return newMissingParameterResult('callSid'); - } - +export const getExternalRecordingS3Location = async ({ + callSid, + accountSid, +}: { + callSid: string; + accountSid: AccountSID; +}) => { try { const client = await getTwilioClient(accountSid); const bucket = await getDocsBucketName(accountSid); @@ -56,3 +53,16 @@ export const getExternalRecordingS3LocationHandler: FlexValidatedHandler = async return newErr({ message: err.message, error: { statusCode: 500, cause: err } }); } }; + +export const getExternalRecordingS3LocationHandler: FlexValidatedHandler = async ( + { body: event }, + accountSid: AccountSID, +) => { + const { callSid } = event as { callSid?: string }; + + if (!callSid) { + return newMissingParameterResult('callSid'); + } + + return getExternalRecordingS3Location({ accountSid, callSid }); +}; diff --git a/lambdas/account-scoped/src/hrm/createHrmContactTaskRouterListener.ts b/lambdas/account-scoped/src/hrm/createHrmContactTaskRouterListener.ts index 610358194e..cea3fe0d4a 100644 --- a/lambdas/account-scoped/src/hrm/createHrmContactTaskRouterListener.ts +++ b/lambdas/account-scoped/src/hrm/createHrmContactTaskRouterListener.ts @@ -34,6 +34,7 @@ import { HrmContact } from '@tech-matters/hrm-types'; import { populateHrmContactFormFromTaskByMappings } from './populateHrmContactFormFromTaskByMappings'; import { parseISO } from 'date-fns/parseISO'; import { HttpClientError } from '../httpErrors'; +import { getExternalRecordingS3Location } from '../conversation/getExternalRecordingS3Location'; // Temporarily copied to this repo, will share the flex types when we move them into the same repo @@ -215,7 +216,30 @@ export const handleEvent = async ( timeOfContact: timeOfContactDate.toISOString(), number: identifier, }; + + if (channel === ('voicemail' as any)) { + const recordingResult = await getExternalRecordingS3Location({ + accountSid, + callSid: taskAttributes.callSid, + }); + if (isOk(recordingResult)) { + newContact.conversationMedia = [ + { + storeType: 'S3', + storeTypeSpecificData: { + type: 'recording', + location: { + bucket: recordingResult.data.bucket, + key: recordingResult.data.key, + }, + }, + }, + ]; + } + } + console.debug('Creating HRM contact with timeOfContact:', newContact.timeOfContact); + const prepopulate = usePrepopulateMappings ? populateHrmContactFormFromTaskByMappings : populateHrmContactFormFromTaskByKeys; diff --git a/lambdas/account-scoped/src/hrm/sanitizeIdentifier.ts b/lambdas/account-scoped/src/hrm/sanitizeIdentifier.ts index ee5f0073d8..72145c9be9 100644 --- a/lambdas/account-scoped/src/hrm/sanitizeIdentifier.ts +++ b/lambdas/account-scoped/src/hrm/sanitizeIdentifier.ts @@ -30,6 +30,7 @@ const aseloConnectorNormalization = (s: string) => s.match(/sip:([^@]+)/)?.[1] | type TransformIdentifierFunction = (c: string) => string; const channelTransformations: { [k: string]: TransformIdentifierFunction[] } = { voice: [aseloConnectorNormalization, phoneNumberStandardization], + voicemail: [aseloConnectorNormalization, phoneNumberStandardization], sms: [phoneNumberStandardization], whatsapp: [s => s.replace('whatsapp:', ''), phoneNumberStandardization], modica: [s => s.replace('modica:', ''), phoneNumberStandardization], diff --git a/lambdas/account-scoped/src/voicemail/recordingCompleteCallback.ts b/lambdas/account-scoped/src/voicemail/recordingCompleteCallback.ts index 749fea8c69..7bc21c6107 100644 --- a/lambdas/account-scoped/src/voicemail/recordingCompleteCallback.ts +++ b/lambdas/account-scoped/src/voicemail/recordingCompleteCallback.ts @@ -47,6 +47,7 @@ export const recordingCompleteCallback: AccountScopedHandler = async ( ...(body.routingAttributes ?? {}), isVoicemail: true, callSid: body.callSid, + from: body.from, }), workflowSid: body.voicemailWorkflowSid, // TODO: factor out channel types into an enum diff --git a/lambdas/packages/hrm-types/src/index.ts b/lambdas/packages/hrm-types/src/index.ts index 891397e96a..b134ff4f43 100644 --- a/lambdas/packages/hrm-types/src/index.ts +++ b/lambdas/packages/hrm-types/src/index.ts @@ -31,6 +31,7 @@ export type HangUpBy = export type ChannelTypes = | 'voice' + | 'voicemail' | 'sms' | 'facebook' | 'messenger' From 5943e68b8916a73729eaed86b2df1b4a12a4473f Mon Sep 17 00:00:00 2001 From: Gianfranco Paoloni Date: Wed, 8 Jul 2026 18:25:50 -0300 Subject: [PATCH 08/31] chore: debug --- .../src/hrm/createHrmContactTaskRouterListener.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/lambdas/account-scoped/src/hrm/createHrmContactTaskRouterListener.ts b/lambdas/account-scoped/src/hrm/createHrmContactTaskRouterListener.ts index cea3fe0d4a..5f8d2fac26 100644 --- a/lambdas/account-scoped/src/hrm/createHrmContactTaskRouterListener.ts +++ b/lambdas/account-scoped/src/hrm/createHrmContactTaskRouterListener.ts @@ -222,6 +222,9 @@ export const handleEvent = async ( accountSid, callSid: taskAttributes.callSid, }); + + console.log('>>>>>>', recordingResult); + if (isOk(recordingResult)) { newContact.conversationMedia = [ { From fe11565c9c4618701c1f67c7b3e09e95a34ada5c Mon Sep 17 00:00:00 2001 From: Gianfranco Paoloni Date: Wed, 8 Jul 2026 18:40:30 -0300 Subject: [PATCH 09/31] fix: add customChannelType task attr --- .../account-scoped/src/voicemail/recordingCompleteCallback.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lambdas/account-scoped/src/voicemail/recordingCompleteCallback.ts b/lambdas/account-scoped/src/voicemail/recordingCompleteCallback.ts index 7bc21c6107..e76586ccdf 100644 --- a/lambdas/account-scoped/src/voicemail/recordingCompleteCallback.ts +++ b/lambdas/account-scoped/src/voicemail/recordingCompleteCallback.ts @@ -48,6 +48,8 @@ export const recordingCompleteCallback: AccountScopedHandler = async ( isVoicemail: true, callSid: body.callSid, from: body.from, + name: body.from, + customChannelType: 'voicemail', }), workflowSid: body.voicemailWorkflowSid, // TODO: factor out channel types into an enum From 132bb1e9fc4706f5fcb05b5632e9a7c694926255 Mon Sep 17 00:00:00 2001 From: Gianfranco Paoloni Date: Wed, 8 Jul 2026 18:41:13 -0300 Subject: [PATCH 10/31] fix: add channelType task attr --- .../account-scoped/src/voicemail/recordingCompleteCallback.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/lambdas/account-scoped/src/voicemail/recordingCompleteCallback.ts b/lambdas/account-scoped/src/voicemail/recordingCompleteCallback.ts index e76586ccdf..9e6662c5ec 100644 --- a/lambdas/account-scoped/src/voicemail/recordingCompleteCallback.ts +++ b/lambdas/account-scoped/src/voicemail/recordingCompleteCallback.ts @@ -49,6 +49,7 @@ export const recordingCompleteCallback: AccountScopedHandler = async ( callSid: body.callSid, from: body.from, name: body.from, + channelType: 'voicemail', customChannelType: 'voicemail', }), workflowSid: body.voicemailWorkflowSid, From 44330aa0a928a464d3003a5fc322ff3d0d1310e1 Mon Sep 17 00:00:00 2001 From: Gianfranco Paoloni Date: Thu, 9 Jul 2026 00:21:40 -0300 Subject: [PATCH 11/31] chore: add conversation media using dedicated endpoint --- .../hrm/createHrmContactTaskRouterListener.ts | 60 +++++++++++-------- 1 file changed, 36 insertions(+), 24 deletions(-) diff --git a/lambdas/account-scoped/src/hrm/createHrmContactTaskRouterListener.ts b/lambdas/account-scoped/src/hrm/createHrmContactTaskRouterListener.ts index 5f8d2fac26..8024137c59 100644 --- a/lambdas/account-scoped/src/hrm/createHrmContactTaskRouterListener.ts +++ b/lambdas/account-scoped/src/hrm/createHrmContactTaskRouterListener.ts @@ -217,30 +217,6 @@ export const handleEvent = async ( number: identifier, }; - if (channel === ('voicemail' as any)) { - const recordingResult = await getExternalRecordingS3Location({ - accountSid, - callSid: taskAttributes.callSid, - }); - - console.log('>>>>>>', recordingResult); - - if (isOk(recordingResult)) { - newContact.conversationMedia = [ - { - storeType: 'S3', - storeTypeSpecificData: { - type: 'recording', - location: { - bucket: recordingResult.data.bucket, - key: recordingResult.data.key, - }, - }, - }, - ]; - } - } - console.debug('Creating HRM contact with timeOfContact:', newContact.timeOfContact); const prepopulate = usePrepopulateMappings @@ -272,6 +248,42 @@ export const handleEvent = async ( const savedTimeOfContactDate = parseISO(savedTimeOfContactString); console.info(`Created HRM contact with id ${id} for task ${taskSid}`); + if (channel === ('voicemail' as any)) { + console.info( + `Channel type is ${channel}, adding conversation media with call sid ${taskAttributes.callSid}`, + ); + const recordingResult = await getExternalRecordingS3Location({ + accountSid, + callSid: taskAttributes.callSid, + }); + + if (isOk(recordingResult)) { + const conversationMedia = [ + { + storeType: 'S3', + storeTypeSpecificData: { + type: 'recording', + location: { + bucket: recordingResult.data.bucket, + key: recordingResult.data.key, + }, + }, + }, + ]; + + const conversationMediaResult = await postToInternalHrmEndpoint< + HrmContact['conversationMedia'], + HrmContact + >( + hrmAccountId, + hrmApiVersion, + `/contacts/${id}/conversationMedia`, + conversationMedia, + ); + console.debug(`[SENSITIVE] Conversation media result ${conversationMediaResult}`); + } + } + const taskContext = client.taskrouter.v1.workspaces .get(twilioWorkspaceSid) .tasks.get(taskSid); From 212c57ec252f2704703265f1a057f12d785b8790 Mon Sep 17 00:00:00 2001 From: Gianfranco Paoloni Date: Thu, 9 Jul 2026 00:33:48 -0300 Subject: [PATCH 12/31] debug --- .../src/hrm/createHrmContactTaskRouterListener.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lambdas/account-scoped/src/hrm/createHrmContactTaskRouterListener.ts b/lambdas/account-scoped/src/hrm/createHrmContactTaskRouterListener.ts index 8024137c59..41471ea382 100644 --- a/lambdas/account-scoped/src/hrm/createHrmContactTaskRouterListener.ts +++ b/lambdas/account-scoped/src/hrm/createHrmContactTaskRouterListener.ts @@ -280,7 +280,9 @@ export const handleEvent = async ( `/contacts/${id}/conversationMedia`, conversationMedia, ); - console.debug(`[SENSITIVE] Conversation media result ${conversationMediaResult}`); + console.debug( + `[SENSITIVE] Conversation media result ${conversationMediaResult.status} ${isOk(conversationMediaResult) ? JSON.stringify(conversationMediaResult.data) : JSON.stringify(conversationMediaResult.error)}`, + ); } } From 70b3c44df472139abf8a9c264948a4ccbf8dea03 Mon Sep 17 00:00:00 2001 From: Gianfranco Paoloni Date: Thu, 9 Jul 2026 00:40:46 -0300 Subject: [PATCH 13/31] fix: path --- .../src/hrm/createHrmContactTaskRouterListener.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lambdas/account-scoped/src/hrm/createHrmContactTaskRouterListener.ts b/lambdas/account-scoped/src/hrm/createHrmContactTaskRouterListener.ts index 41471ea382..e291f2d8d6 100644 --- a/lambdas/account-scoped/src/hrm/createHrmContactTaskRouterListener.ts +++ b/lambdas/account-scoped/src/hrm/createHrmContactTaskRouterListener.ts @@ -277,7 +277,7 @@ export const handleEvent = async ( >( hrmAccountId, hrmApiVersion, - `/contacts/${id}/conversationMedia`, + `contacts/${id}/conversationMedia`, conversationMedia, ); console.debug( From e77ff0c2df0de8b990078b966a3f147317ca857a Mon Sep 17 00:00:00 2001 From: Gianfranco Paoloni Date: Fri, 10 Jul 2026 19:07:00 -0300 Subject: [PATCH 14/31] chore: add voicemail PoC UI --- plugin-hrm-form/src/HrmFormPlugin.tsx | 3 + plugin-hrm-form/src/channels/colors.ts | 2 + .../components/case/timeline/TimelineIcon.tsx | 3 + .../components/common/icons/VoicemailIcon.tsx | 40 +++++++++++ .../contact/MediaSection/RecordingSection.tsx | 23 +++++-- .../profile/IdentifierBanner/iconsFromTask.ts | 3 +- .../src/components/queuesStatus/QueueCard.tsx | 2 + .../src/components/queuesStatus/helpers.ts | 1 + plugin-hrm-form/src/states/DomainConstants.ts | 1 + plugin-hrm-form/src/translations/en.json | 6 +- plugin-hrm-form/src/utils/task.ts | 1 + .../src/voicemail/VoicemailTaskPanel.tsx | 69 +++++++++++++++++++ .../voicemail/setUpVoicemailComponents.tsx | 33 +++++++++ 13 files changed, 180 insertions(+), 7 deletions(-) create mode 100644 plugin-hrm-form/src/components/common/icons/VoicemailIcon.tsx create mode 100644 plugin-hrm-form/src/voicemail/VoicemailTaskPanel.tsx create mode 100644 plugin-hrm-form/src/voicemail/setUpVoicemailComponents.tsx diff --git a/plugin-hrm-form/src/HrmFormPlugin.tsx b/plugin-hrm-form/src/HrmFormPlugin.tsx index 1900171f4b..61580e68d8 100644 --- a/plugin-hrm-form/src/HrmFormPlugin.tsx +++ b/plugin-hrm-form/src/HrmFormPlugin.tsx @@ -48,6 +48,7 @@ import { FeatureFlags } from './types/FeatureFlags'; import { setUpFullStory } from './fullStory/setUp'; import { getPathFromUrl } from './states/routing/reducer'; import { setUpCustomSideLinks } from './components/customSideLinks/setUpCustomSideLinks'; +import { setUpVoicemailComponents } from './voicemail/setUpVoicemailComponents'; const PLUGIN_NAME = 'HrmFormPlugin'; @@ -145,6 +146,8 @@ const setUpComponents = (featureFlags: FeatureFlags, setupObject: ReturnType { return ; case channelTypes.line: return ; + case channelTypes.voicemail: + return ; case 'note': return ; case 'referral': diff --git a/plugin-hrm-form/src/components/common/icons/VoicemailIcon.tsx b/plugin-hrm-form/src/components/common/icons/VoicemailIcon.tsx new file mode 100644 index 0000000000..27f4748b66 --- /dev/null +++ b/plugin-hrm-form/src/components/common/icons/VoicemailIcon.tsx @@ -0,0 +1,40 @@ +/** + * Copyright (C) 2021-2023 Technology Matters + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +/* eslint-disable react/prop-types */ +import React from 'react'; + +type Props = { + width: string; + height: string; + color?: string; +}; +/* eslint-disable react/prop-types */ +const VoicemailIcon: React.FC = ({ width, height, color }) => { + return ( + // + + + + + ); +}; + +VoicemailIcon.displayName = 'VoicemailIcon'; +VoicemailIcon.defaultProps = { + color: '#00C300', +}; +export default VoicemailIcon; diff --git a/plugin-hrm-form/src/components/contact/MediaSection/RecordingSection.tsx b/plugin-hrm-form/src/components/contact/MediaSection/RecordingSection.tsx index e8d5e7c124..d12223cddb 100644 --- a/plugin-hrm-form/src/components/contact/MediaSection/RecordingSection.tsx +++ b/plugin-hrm-form/src/components/contact/MediaSection/RecordingSection.tsx @@ -13,7 +13,7 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see https://www.gnu.org/licenses/. */ -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { Template } from '@twilio/flex-ui'; import CircularProgress from '@material-ui/core/CircularProgress'; @@ -24,13 +24,19 @@ import { fetchHrmApi, generateSignedURLPath } from '../../../services/fetchHrmAp type OwnProps = { contactId: string; externalStoredRecording?: S3StoredRecording; - loadConversationIntoOverlay: () => Promise; + loadConversationIntoOverlay?: () => Promise; + autoLoad?: boolean; }; -const RecordingSection: React.FC = ({ contactId, externalStoredRecording, loadConversationIntoOverlay }) => { +const RecordingSection: React.FC = ({ + contactId, + externalStoredRecording, + loadConversationIntoOverlay, + autoLoad = false, +}) => { const [voiceRecording, setVoiceRecording] = useState(null); - const [loading, setLoading] = useState(false); - const [showButton, setShowButton] = useState(true); + const [loading, setLoading] = useState(autoLoad || false); + const [showButton, setShowButton] = useState(!autoLoad); const [errorMessage, setErrorMessage] = useState(null); const fetchAndLoadRecording = async () => { @@ -72,6 +78,13 @@ const RecordingSection: React.FC = ({ contactId, externalStoredRecordi setLoading(false); }; + useEffect(() => { + if (autoLoad) { + fetchAndLoadRecording(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + if (errorMessage) { return ( diff --git a/plugin-hrm-form/src/components/profile/IdentifierBanner/iconsFromTask.ts b/plugin-hrm-form/src/components/profile/IdentifierBanner/iconsFromTask.ts index d4717356ee..59caa48496 100644 --- a/plugin-hrm-form/src/components/profile/IdentifierBanner/iconsFromTask.ts +++ b/plugin-hrm-form/src/components/profile/IdentifierBanner/iconsFromTask.ts @@ -15,7 +15,7 @@ */ import { getIcon } from '../../case/timeline/TimelineIcon'; -import { CoreChannelTypes, coreChannelTypes } from '../../../states/DomainConstants'; +import { channelTypes, CoreChannelTypes, coreChannelTypes } from '../../../states/DomainConstants'; import { customSmsChannelTypes, customFacebookChannelTypes } from '../../../utils/groupedChannels'; type ExtendedChannelTypes = @@ -38,4 +38,5 @@ export const iconsFromTask: { [channelType in ExtendedChannelTypes]: JSX.Element }, [customSmsChannelTypes.modica]: getIcon(customSmsChannelTypes.modica, iconSize), [customFacebookChannelTypes.messenger]: getIcon(customFacebookChannelTypes.messenger, iconSize), + [channelTypes.voicemail]: getIcon(channelTypes.voicemail, iconSize), }; diff --git a/plugin-hrm-form/src/components/queuesStatus/QueueCard.tsx b/plugin-hrm-form/src/components/queuesStatus/QueueCard.tsx index 86874da9d4..5f4177796c 100644 --- a/plugin-hrm-form/src/components/queuesStatus/QueueCard.tsx +++ b/plugin-hrm-form/src/components/queuesStatus/QueueCard.tsx @@ -69,6 +69,8 @@ const renderChannel = ({ return getChannelUI('IG', channelColor, contactsWaiting, true); case coreChannelTypes.line: return getChannelUI('LN', channelColor, contactsWaiting, true); + case coreChannelTypes.voicemail: + return getChannelUI('VM', channelColor, contactsWaiting, true); default: return null; } diff --git a/plugin-hrm-form/src/components/queuesStatus/helpers.ts b/plugin-hrm-form/src/components/queuesStatus/helpers.ts index 5dfd3591f2..902f6fb9ac 100644 --- a/plugin-hrm-form/src/components/queuesStatus/helpers.ts +++ b/plugin-hrm-form/src/components/queuesStatus/helpers.ts @@ -29,6 +29,7 @@ export const newQueueEntry: QueueEntry = { telegram: 0, instagram: 0, line: 0, + voicemail: 0, longestWaitingDate: null, isChatPending: false, }; diff --git a/plugin-hrm-form/src/states/DomainConstants.ts b/plugin-hrm-form/src/states/DomainConstants.ts index ed3325f916..50b2b890fe 100644 --- a/plugin-hrm-form/src/states/DomainConstants.ts +++ b/plugin-hrm-form/src/states/DomainConstants.ts @@ -28,6 +28,7 @@ const customChannelTypes = { telegram: 'telegram', instagram: 'instagram', line: 'line', + voicemail: 'voicemail', } as const; /** diff --git a/plugin-hrm-form/src/translations/en.json b/plugin-hrm-form/src/translations/en.json index 2a65a41e22..443a251421 100644 --- a/plugin-hrm-form/src/translations/en.json +++ b/plugin-hrm-form/src/translations/en.json @@ -669,5 +669,9 @@ "BrowserNotification-ChatMessage-MaskedTitle": "New message", "AsV1-ChildInformationTab-FirstName-Title": "First Name description", - "AsV1-ChildInformationTab-FirstName-Content": "Lorem \n ipsum dolor \n sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum." + "AsV1-ChildInformationTab-FirstName-Content": "Lorem \n ipsum dolor \n sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", + + "VoicemailTaskPanel-CallBack": "Call Back", + "VoicemailTaskPanel-RetryLater": "Retry Later" + } diff --git a/plugin-hrm-form/src/utils/task.ts b/plugin-hrm-form/src/utils/task.ts index ae7c2ab2be..b691ec4b68 100644 --- a/plugin-hrm-form/src/utils/task.ts +++ b/plugin-hrm-form/src/utils/task.ts @@ -35,6 +35,7 @@ const phoneNumberStandardization = (s: string) => [trimSpaces, trimHyphens].redu type TransformIdentifierFunction = (c: string) => string; const channelTransformations: { [k in ChannelTypes]: TransformIdentifierFunction[] } = { voice: [phoneNumberStandardization], + voicemail: [phoneNumberStandardization], sms: [phoneNumberStandardization], whatsapp: [s => s.replace('whatsapp:', ''), phoneNumberStandardization], modica: [s => s.replace('modica:', ''), phoneNumberStandardization], diff --git a/plugin-hrm-form/src/voicemail/VoicemailTaskPanel.tsx b/plugin-hrm-form/src/voicemail/VoicemailTaskPanel.tsx new file mode 100644 index 0000000000..72641162c5 --- /dev/null +++ b/plugin-hrm-form/src/voicemail/VoicemailTaskPanel.tsx @@ -0,0 +1,69 @@ +/** + * Copyright (C) 2021-2023 Technology Matters + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +import * as Flex from '@twilio/flex-ui'; +import React from 'react'; +import { useSelector } from 'react-redux'; + +import { channelTypes } from '../states/DomainConstants'; +import { PrimaryButton } from '../styles/buttons'; +import { Flex as FlexBox } from '../styles'; +import { RecordingSection } from '../components/contact/MediaSection'; +import { RootState } from '../states'; +import selectContactByTaskSid from '../states/contacts/selectContactByTaskSid'; +import { isS3StoredRecording } from '../types/types'; + +type Props = {} & Flex.TaskContextProps; + +const VoicemailTaskPanel: React.FC = ({ task }) => { + const contact = useSelector((state: RootState) => selectContactByTaskSid(state, task.taskSid)); + + if (!task || !contact?.savedContact) { + return null; + } + + const onClickCallBack = () => { + Flex.Actions.invokeAction('StartOutboundCall', { + destination: task.attributes.from, + // taskAttributes: { ... custom attributes } + }); + }; + + const onClickRetryLater = () => { + window.alert('Not implemented :P'); + }; + + const externalStoredRecording = contact.savedContact.conversationMedia?.find(isS3StoredRecording); + return ( +
+ + + + + + + + + +
+ ); +}; + +export default Flex.withTaskContext(VoicemailTaskPanel); diff --git a/plugin-hrm-form/src/voicemail/setUpVoicemailComponents.tsx b/plugin-hrm-form/src/voicemail/setUpVoicemailComponents.tsx new file mode 100644 index 0000000000..f12627165a --- /dev/null +++ b/plugin-hrm-form/src/voicemail/setUpVoicemailComponents.tsx @@ -0,0 +1,33 @@ +/** + * Copyright (C) 2021-2023 Technology Matters + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +import * as Flex from '@twilio/flex-ui'; +import React from 'react'; + +import { channelTypes } from '../states/DomainConstants'; +import VoicemailTaskPanel from './VoicemailTaskPanel'; + +export const setUpVoicemailComponents = () => { + // TODO: localize + // Flex.TaskCanvasTabs.Content.add(, { + // sortOrder: -1, + // if: props => props.task.channelType === channelTypes.voicemail, + // }); + + Flex.TaskInfoPanel.Content.add(, { + if: props => props.task.channelType === channelTypes.voicemail, + }); +}; From 20a6b7661e89da23900ab578f445c5a6249903f4 Mon Sep 17 00:00:00 2001 From: Gianfranco Paoloni Date: Mon, 13 Jul 2026 18:55:32 -0300 Subject: [PATCH 15/31] fix: add attribute needed for task assignment --- .../account-scoped/src/voicemail/recordingCompleteCallback.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/lambdas/account-scoped/src/voicemail/recordingCompleteCallback.ts b/lambdas/account-scoped/src/voicemail/recordingCompleteCallback.ts index 9e6662c5ec..58e2b1923d 100644 --- a/lambdas/account-scoped/src/voicemail/recordingCompleteCallback.ts +++ b/lambdas/account-scoped/src/voicemail/recordingCompleteCallback.ts @@ -51,6 +51,7 @@ export const recordingCompleteCallback: AccountScopedHandler = async ( name: body.from, channelType: 'voicemail', customChannelType: 'voicemail', + transferTargetType: '', }), workflowSid: body.voicemailWorkflowSid, // TODO: factor out channel types into an enum From 5701a7bb817d773b787b96d28b38cfbbe1bbdba0 Mon Sep 17 00:00:00 2001 From: Gianfranco Paoloni Date: Tue, 14 Jul 2026 01:17:09 -0300 Subject: [PATCH 16/31] fix: add attribute needed for task assignment --- .../account-scoped/src/voicemail/recordingCompleteCallback.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/lambdas/account-scoped/src/voicemail/recordingCompleteCallback.ts b/lambdas/account-scoped/src/voicemail/recordingCompleteCallback.ts index 58e2b1923d..ab52e7f9c5 100644 --- a/lambdas/account-scoped/src/voicemail/recordingCompleteCallback.ts +++ b/lambdas/account-scoped/src/voicemail/recordingCompleteCallback.ts @@ -51,6 +51,7 @@ export const recordingCompleteCallback: AccountScopedHandler = async ( name: body.from, channelType: 'voicemail', customChannelType: 'voicemail', + ignoreAgent: '', transferTargetType: '', }), workflowSid: body.voicemailWorkflowSid, From cf8cbf1619c042ea3895b9264d9a27b103d4231f Mon Sep 17 00:00:00 2001 From: Gianfranco Paoloni Date: Tue, 14 Jul 2026 01:33:43 -0300 Subject: [PATCH 17/31] fix: canTransferConference only check for voice tasks --- plugin-hrm-form/src/transfer/transferTaskState.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/plugin-hrm-form/src/transfer/transferTaskState.ts b/plugin-hrm-form/src/transfer/transferTaskState.ts index 667752e927..f9c84ece17 100644 --- a/plugin-hrm-form/src/transfer/transferTaskState.ts +++ b/plugin-hrm-form/src/transfer/transferTaskState.ts @@ -183,8 +183,7 @@ export const closeCallSelf = async (task: ITask): Promise => { }; export const canTransferConference = (task: ITask) => { - const isChatTask = TaskHelper.isChatBasedTask(task); - if (isChatTask) { + if (!TaskHelper.isVoiceTask(task)) { return true; } From 43c9fc528e7ed04b3996c53fda076d72f4b8509a Mon Sep 17 00:00:00 2001 From: Gianfranco Paoloni Date: Tue, 14 Jul 2026 02:09:41 -0300 Subject: [PATCH 18/31] chore: routing attr --- .../account-scoped/src/voicemail/recordingCompleteCallback.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lambdas/account-scoped/src/voicemail/recordingCompleteCallback.ts b/lambdas/account-scoped/src/voicemail/recordingCompleteCallback.ts index ab52e7f9c5..14bad2e6fa 100644 --- a/lambdas/account-scoped/src/voicemail/recordingCompleteCallback.ts +++ b/lambdas/account-scoped/src/voicemail/recordingCompleteCallback.ts @@ -44,7 +44,7 @@ export const recordingCompleteCallback: AccountScopedHandler = async ( .tasks.create({ timeout: 604800, // 7 days attributes: JSON.stringify({ - ...(body.routingAttributes ?? {}), + ...(body.routingAttributes ? JSON.parse(body.routingAttributes) : {}), isVoicemail: true, callSid: body.callSid, from: body.from, From 2da86275a3bea534e386f090f039243d5ab74800 Mon Sep 17 00:00:00 2001 From: Stephen Hand Date: Mon, 3 Aug 2026 16:57:52 +0100 Subject: [PATCH 19/31] channel types enum --- .../channelCapture/channelCaptureHandlers.ts | 7 +-- .../src/channelCapture/postSurveyListener.ts | 4 +- .../src/conversation/createConversation.ts | 10 ++-- .../conversation/janitorTaskRouterListener.ts | 11 +++-- .../src/customChannels/aseloCustomChannels.ts | 25 ---------- .../src/customChannels/configuration.ts | 9 ++-- .../src/customChannels/customChannelToFlex.ts | 2 - .../instagram/instagramToFlex.ts | 6 +-- .../src/customChannels/line/lineToFlex.ts | 7 ++- .../src/customChannels/modica/modicaToFlex.ts | 7 ++- .../customChannels/telegram/telegramToFlex.ts | 11 ++--- .../src/hrm/sanitizeIdentifier.ts | 3 +- .../voicemail/recordingCompleteCallback.ts | 20 +++----- .../hrm/getProfileFlagsForIdentifier.test.ts | 1 - .../janitorTaskRouterListener.test.ts | 22 ++++----- .../hrm/getProfileFlagsForIdentifier.test.ts | 49 ++++++++++--------- .../packages/twilio-types/src/channelType.ts | 41 ++++++++++++++++ lambdas/packages/twilio-types/src/index.ts | 1 + 18 files changed, 129 insertions(+), 107 deletions(-) delete mode 100644 lambdas/account-scoped/src/customChannels/aseloCustomChannels.ts create mode 100644 lambdas/packages/twilio-types/src/channelType.ts diff --git a/lambdas/account-scoped/src/channelCapture/channelCaptureHandlers.ts b/lambdas/account-scoped/src/channelCapture/channelCaptureHandlers.ts index 45d6ac5535..62887c248b 100644 --- a/lambdas/account-scoped/src/channelCapture/channelCaptureHandlers.ts +++ b/lambdas/account-scoped/src/channelCapture/channelCaptureHandlers.ts @@ -23,6 +23,7 @@ import { Twilio } from 'twilio'; import { ROUTE_PREFIX } from '../router'; import { AccountSID } from '@tech-matters/twilio-types'; import { savePostSurvey } from '../hrm/savePostSurvey'; +import { ChannelType } from '@tech-matters/twilio-types'; const triggerTypes = ['withUserMessage', 'withNextMessage'] as const; export type TriggerTypes = (typeof triggerTypes)[number]; @@ -43,7 +44,7 @@ export type CapturedChannelAttributes = { releaseFlag?: string; chatbotCallbackWebhookSid: string; isConversation: boolean; - channelType: string; + channelType: ChannelType; }; export const isChatCaptureControlTask = (taskAttributes: { @@ -154,7 +155,7 @@ type CaptureChannelOptions = { memoryAttribute?: string; // where in the task attributes we want to save the bot's memory (allows compatibility for multiple bots) releaseFlag?: string; // the flag we want to set true when the channel is released isConversation: boolean; - channelType: string; + channelType: ChannelType; webhookBaseUrl: string; }; @@ -357,7 +358,7 @@ export type HandleChannelCaptureParams = ( releaseFlag?: string; // The flag we want to set true in the channel attributes when the channel is released additionControlTaskAttributes?: string; // Optional attributes to include in the control task, in the string representation of a JSON controlTaskTTL?: number; - channelType: string; + channelType: ChannelType; twilioWorkspaceSid: string; chatServiceSid: string; surveyWorkflowSid: string; diff --git a/lambdas/account-scoped/src/channelCapture/postSurveyListener.ts b/lambdas/account-scoped/src/channelCapture/postSurveyListener.ts index e0fde4709d..9fc5bdd751 100644 --- a/lambdas/account-scoped/src/channelCapture/postSurveyListener.ts +++ b/lambdas/account-scoped/src/channelCapture/postSurveyListener.ts @@ -18,7 +18,7 @@ import { registerTaskRouterEventHandler, TaskRouterEventHandler, } from '../taskrouter/taskrouterEventHandler'; -import { AccountSID } from '@tech-matters/twilio-types'; +import { AccountSID, ChannelType } from '@tech-matters/twilio-types'; import { Twilio } from 'twilio'; import { EventType, TASK_WRAPUP } from '../taskrouter/eventTypes'; import { EventFields } from '../taskrouter'; @@ -96,7 +96,7 @@ const postSurveyInitHandler = async ({ client: Twilio; taskSid: string; taskLanguage: string; - channelType: string; + channelType: ChannelType; environment: string; webhookBaseUrl: string; chatServiceSid: string; diff --git a/lambdas/account-scoped/src/conversation/createConversation.ts b/lambdas/account-scoped/src/conversation/createConversation.ts index d67c15c19c..8a3aab440d 100644 --- a/lambdas/account-scoped/src/conversation/createConversation.ts +++ b/lambdas/account-scoped/src/conversation/createConversation.ts @@ -15,14 +15,18 @@ */ import { Twilio } from 'twilio'; -import { ConversationSID } from '@tech-matters/twilio-types'; -import { AseloCustomChannel } from '../customChannels/aseloCustomChannels'; +import { + AseloCustomChannelType, + ChannelType, + channelTypes, + ConversationSID, +} from '@tech-matters/twilio-types'; import { newErr, newOk, Result } from '../Result'; const CONVERSATION_CLOSE_TIMEOUT = 'P3D'; // ISO 8601 duration format https://en.wikipedia.org/wiki/ISO_8601 export type CreateFlexConversationParams = { studioFlowSid: string; - channelType: AseloCustomChannel | 'web'; // The chat channel being used + channelType: ChannelType & (AseloCustomChannelType | typeof channelTypes.WEB); // The chat channel being used uniqueUserName: string; // Unique identifier for this user senderScreenName: string; // Friendly info to show in the Flex UI (like Telegram handle) onMessageAddedWebhookUrl?: string; // The url that must be used as the onMessageSent event webhook. diff --git a/lambdas/account-scoped/src/conversation/janitorTaskRouterListener.ts b/lambdas/account-scoped/src/conversation/janitorTaskRouterListener.ts index 40a953172c..7856c96d04 100644 --- a/lambdas/account-scoped/src/conversation/janitorTaskRouterListener.ts +++ b/lambdas/account-scoped/src/conversation/janitorTaskRouterListener.ts @@ -33,10 +33,13 @@ import { retrieveFeatureFlags } from '../configuration/aseloConfiguration'; import { chatChannelJanitor } from './chatChannelJanitor'; import { hasTaskControl } from '../transfer/hasTaskControl'; import { isChatCaptureControlTask } from '../channelCapture/channelCaptureHandlers'; -import { isAseloCustomChannel } from '../customChannels/aseloCustomChannels'; import { getWorkspaceSid } from '@tech-matters/twilio-configuration'; import { ChatChannelSID, ConversationSID } from '@tech-matters/twilio-types'; import { getCurrentDefinitionVersion } from '../hrm/formDefinitionsCache'; +import { + ChannelType, + isAseloCustomChannelType, +} from '@tech-matters/twilio-types/src/channelType'; const isCleanupBotCapture = ( eventType: EventType, @@ -71,8 +74,8 @@ const isCleanupCustomChannel = async ( workspaceSid: string, taskSid: string, taskAttributes: { - channelType?: string; - customChannelType?: string; + channelType?: ChannelType; + customChannelType?: ChannelType; isChatCaptureControl?: boolean; }, ) => { @@ -84,7 +87,7 @@ const isCleanupCustomChannel = async ( return false; } - return isAseloCustomChannel( + return isAseloCustomChannelType( taskAttributes.customChannelType || taskAttributes.channelType, ); }; diff --git a/lambdas/account-scoped/src/customChannels/aseloCustomChannels.ts b/lambdas/account-scoped/src/customChannels/aseloCustomChannels.ts deleted file mode 100644 index bf54813c99..0000000000 --- a/lambdas/account-scoped/src/customChannels/aseloCustomChannels.ts +++ /dev/null @@ -1,25 +0,0 @@ -/** - * Copyright (C) 2021-2023 Technology Matters - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published - * by the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see https://www.gnu.org/licenses/. - */ - -export enum AseloCustomChannel { - Instagram = 'instagram', - Line = 'line', - Modica = 'modica', - Telegram = 'telegram', -} - -export const isAseloCustomChannel = (channelType?: string): boolean => - Object.values(AseloCustomChannel).includes(channelType as AseloCustomChannel); diff --git a/lambdas/account-scoped/src/customChannels/configuration.ts b/lambdas/account-scoped/src/customChannels/configuration.ts index b69003e274..5777371b39 100644 --- a/lambdas/account-scoped/src/customChannels/configuration.ts +++ b/lambdas/account-scoped/src/customChannels/configuration.ts @@ -14,9 +14,12 @@ * along with this program. If not, see https://www.gnu.org/licenses/. */ -import { AccountSID } from '@tech-matters/twilio-types'; +import { + AccountSID, + AseloCustomChannelType, + ChannelType, +} from '@tech-matters/twilio-types'; import { getSsmParameter, SsmParameterNotFound } from '@tech-matters/ssm-cache'; -import { AseloCustomChannel } from './customChannelToFlex'; import { retrieveServiceConfigurationAttributes } from '../configuration/aseloConfiguration'; import { getTwilioClient } from '@tech-matters/twilio-configuration'; @@ -89,7 +92,7 @@ export const getFacebookAppSecret = (): Promise => export const getChannelStudioFlowSid = ( accountSid: AccountSID, - channelName: AseloCustomChannel | 'chat', + channelName: ChannelType & (AseloCustomChannelType | 'chat'), ): Promise => getSsmParameter( `/${process.env.NODE_ENV}/twilio/${accountSid}/${channelName}_studio_flow_sid`, diff --git a/lambdas/account-scoped/src/customChannels/customChannelToFlex.ts b/lambdas/account-scoped/src/customChannels/customChannelToFlex.ts index c07b0e7b4a..5b2d02f598 100644 --- a/lambdas/account-scoped/src/customChannels/customChannelToFlex.ts +++ b/lambdas/account-scoped/src/customChannels/customChannelToFlex.ts @@ -78,8 +78,6 @@ export const removeConversation = async ( }, ) => client.conversations.v1.conversations.get(conversationSid).remove(); -export { AseloCustomChannel, isAseloCustomChannel } from './aseloCustomChannels'; - type SendConversationMessageToFlexParams = Omit< CreateFlexConversationParams, 'twilioNumber' diff --git a/lambdas/account-scoped/src/customChannels/instagram/instagramToFlex.ts b/lambdas/account-scoped/src/customChannels/instagram/instagramToFlex.ts index c9c5a7e397..d1b3432f70 100644 --- a/lambdas/account-scoped/src/customChannels/instagram/instagramToFlex.ts +++ b/lambdas/account-scoped/src/customChannels/instagram/instagramToFlex.ts @@ -19,13 +19,13 @@ import '@twilio-labs/serverless-runtime-types'; import crypto from 'crypto'; import { - AseloCustomChannel, findExistingConversation, sendConversationMessageToFlex, } from '../customChannelToFlex'; import { AccountScopedHandler, HttpRequest } from '../../httpTypes'; import { AccountSID, + aseloCustomChannelTypes, ConversationSID, InstagramMessageEvent, InstagramMessageObject, @@ -117,7 +117,7 @@ export const instagramToFlexHandler: AccountScopedHandler = async ( const senderExternalId = sender.id; const messageExternalId = message.mid; const subscribedExternalId = event.entry[0].id; - const channelType = AseloCustomChannel.Instagram; + const channelType = aseloCustomChannelTypes.INSTAGRAM; const chatFriendlyName = `${channelType}:${senderExternalId}`; const uniqueUserName = `${channelType}:${senderExternalId}`; const senderScreenName = senderExternalId; // TODO: see if we can use ig handle somehow @@ -125,7 +125,7 @@ export const instagramToFlexHandler: AccountScopedHandler = async ( const onMessageSentWebhookUrl = `${process.env.WEBHOOK_BASE_URL}/lambda/twilio/account-scoped/${accountSid}/customChannels/instagram/flexToInstagram?recipientId=${senderExternalId}`; const studioFlowSid = await getChannelStudioFlowSid( accountSid, - AseloCustomChannel.Instagram, + aseloCustomChannelTypes.INSTAGRAM, ); let result; if (isInstagramStoryReply(message)) { diff --git a/lambdas/account-scoped/src/customChannels/line/lineToFlex.ts b/lambdas/account-scoped/src/customChannels/line/lineToFlex.ts index 37aaa6d5b9..2fcd1c92d1 100644 --- a/lambdas/account-scoped/src/customChannels/line/lineToFlex.ts +++ b/lambdas/account-scoped/src/customChannels/line/lineToFlex.ts @@ -15,9 +15,8 @@ */ import crypto from 'crypto'; -import { AccountSID } from '@tech-matters/twilio-types'; +import { AccountSID, aseloCustomChannelTypes } from '@tech-matters/twilio-types'; import { - AseloCustomChannel, sendConversationMessageToFlex, } from '../customChannelToFlex'; import { AccountScopedHandler, HttpRequest } from '../../httpTypes'; @@ -125,13 +124,13 @@ export const lineToFlexHandler: AccountScopedHandler = async ( const studioFlowSid = await getChannelStudioFlowSid( accountSid, - AseloCustomChannel.Line, + aseloCustomChannelTypes.LINE, ); const responses: any[] = []; for (const messageEvent of messageEvents) { const messageText = messageEvent.message.text; - const channelType = AseloCustomChannel.Line; + const channelType = aseloCustomChannelTypes.LINE; const subscribedExternalId = destination; // AseloChat ID on Line const senderExternalId = messageEvent.source.userId; // The child ID on Line const chatFriendlyName = `${channelType}:${senderExternalId}`; diff --git a/lambdas/account-scoped/src/customChannels/modica/modicaToFlex.ts b/lambdas/account-scoped/src/customChannels/modica/modicaToFlex.ts index 8db9599512..bfab5ce47c 100644 --- a/lambdas/account-scoped/src/customChannels/modica/modicaToFlex.ts +++ b/lambdas/account-scoped/src/customChannels/modica/modicaToFlex.ts @@ -14,9 +14,8 @@ * along with this program. If not, see https://www.gnu.org/licenses/. */ -import { AccountSID } from '@tech-matters/twilio-types'; +import { AccountSID, aseloCustomChannelTypes } from '@tech-matters/twilio-types'; import { - AseloCustomChannel, sendConversationMessageToFlex, } from '../customChannelToFlex'; import { AccountScopedHandler, HttpRequest } from '../../httpTypes'; @@ -41,7 +40,7 @@ export const modicaToFlexHandler: AccountScopedHandler = async ( const { source, destination, content } = event; const messageText = content; - const channelType = AseloCustomChannel.Modica; + const channelType = aseloCustomChannelTypes.MODICA; const subscribedExternalId = destination; // The helpline short code const senderExternalId = source; // The child phone number const chatFriendlyName = senderExternalId; @@ -50,7 +49,7 @@ export const modicaToFlexHandler: AccountScopedHandler = async ( const onMessageSentWebhookUrl = `${process.env.WEBHOOK_BASE_URL}/lambda/twilio/account-scoped/${accountSid}/customChannels/modica/flexToModica?recipientId=${senderExternalId}`; const studioFlowSid = await getChannelStudioFlowSid( accountSid, - AseloCustomChannel.Modica, + aseloCustomChannelTypes.MODICA, ); console.debug( 'ModicaToFlex: sending message from', diff --git a/lambdas/account-scoped/src/customChannels/telegram/telegramToFlex.ts b/lambdas/account-scoped/src/customChannels/telegram/telegramToFlex.ts index 19a18d0ee0..8b2dbf5b2e 100644 --- a/lambdas/account-scoped/src/customChannels/telegram/telegramToFlex.ts +++ b/lambdas/account-scoped/src/customChannels/telegram/telegramToFlex.ts @@ -14,11 +14,8 @@ * along with this program. If not, see https://www.gnu.org/licenses/. */ -import { AccountSID } from '@tech-matters/twilio-types'; -import { - AseloCustomChannel, - sendConversationMessageToFlex, -} from '../customChannelToFlex'; +import { AccountSID, aseloCustomChannelTypes } from '@tech-matters/twilio-types'; +import { sendConversationMessageToFlex } from '../customChannelToFlex'; import { AccountScopedHandler, HttpRequest } from '../../httpTypes'; import { newErr, newOk } from '../../Result'; import { getChannelStudioFlowSid, getTelegramBotApiSecretToken } from '../configuration'; @@ -61,14 +58,14 @@ export const telegramToFlexHandler: AccountScopedHandler = async ( chat: { id: senderExternalId, username, first_name: firstName }, } = event.message; - const channelType = AseloCustomChannel.Telegram; + const channelType = aseloCustomChannelTypes.TELEGRAM; const chatFriendlyName = username || `${channelType}:${senderExternalId}`; const uniqueUserName = `${channelType}:${senderExternalId}`; const senderScreenName = firstName || username || 'child'; const onMessageAddedWebhookUrl = `${process.env.WEBHOOK_BASE_URL}/lambda/twilio/account-scoped/${accountSid}/customChannels/telegram/flexToTelegram?recipientId=${senderExternalId}`; const studioFlowSid = await getChannelStudioFlowSid( accountSid, - AseloCustomChannel.Telegram, + aseloCustomChannelTypes.TELEGRAM, ); console.debug( 'TelegramToFlex: sending message from', diff --git a/lambdas/account-scoped/src/hrm/sanitizeIdentifier.ts b/lambdas/account-scoped/src/hrm/sanitizeIdentifier.ts index 72145c9be9..724ffccd46 100644 --- a/lambdas/account-scoped/src/hrm/sanitizeIdentifier.ts +++ b/lambdas/account-scoped/src/hrm/sanitizeIdentifier.ts @@ -16,6 +16,7 @@ import { HrmContact } from '@tech-matters/hrm-types'; import { newErr, newOk, Result } from '../Result'; +import { ChannelType } from '@tech-matters/twilio-types'; /** * IMPORTANT: keep up to date with flex-plugins/plugin-hrm-form/src/utils/task @@ -68,7 +69,7 @@ const isVoiceTrigger = (obj: any): obj is VoiceTrigger => export type TriggerEvent = { trigger: ChatTrigger | VoiceTrigger | ConversationTrigger; request: { cookies: {}; headers: {} }; - channelType?: string; + channelType?: ChannelType; }; type ConversationTrigger = { diff --git a/lambdas/account-scoped/src/voicemail/recordingCompleteCallback.ts b/lambdas/account-scoped/src/voicemail/recordingCompleteCallback.ts index 14bad2e6fa..1111e6d839 100644 --- a/lambdas/account-scoped/src/voicemail/recordingCompleteCallback.ts +++ b/lambdas/account-scoped/src/voicemail/recordingCompleteCallback.ts @@ -17,6 +17,7 @@ import { getTwilioClient, getWorkspaceSid } from '@tech-matters/twilio-configuration'; import { AccountScopedHandler, HttpError } from '../httpTypes'; import { newOk, Result } from '../Result'; +import {channelTypes} from "@tech-matters/twilio-types/src/channelType"; export type RecordingCompleteCallbackRequestBody = { callFrom: string; @@ -26,15 +27,10 @@ export const recordingCompleteCallback: AccountScopedHandler = async ( { body }, accountSid, ): Promise> => { - console.debug('recordingCompleteCallback body', JSON.stringify(body, null, 2)); - // const { callFrom } = body as RecordingCompleteCallbackRequestBody; - - // if (!callFrom) { - // return newErr({ - // message: 'callFrom parameter is missing', - // error: { statusCode: 400 }, - // }); - // } + console.debug( + '[SENSITIVE] recordingCompleteCallback body', + JSON.stringify(body, null, 2), + ); const twilioClient = await getTwilioClient(accountSid); @@ -49,14 +45,14 @@ export const recordingCompleteCallback: AccountScopedHandler = async ( callSid: body.callSid, from: body.from, name: body.from, - channelType: 'voicemail', - customChannelType: 'voicemail', + channelType: channelTypes.VOICEMAIL, + customChannelType: channelTypes.VOICEMAIL, ignoreAgent: '', transferTargetType: '', }), workflowSid: body.voicemailWorkflowSid, // TODO: factor out channel types into an enum - taskChannel: 'voicemail', + taskChannel: channelTypes.VOICEMAIL, }); return newOk({ voicemailTask }); diff --git a/lambdas/account-scoped/tests/service/hrm/getProfileFlagsForIdentifier.test.ts b/lambdas/account-scoped/tests/service/hrm/getProfileFlagsForIdentifier.test.ts index 83eacd34be..484cda0d20 100644 --- a/lambdas/account-scoped/tests/service/hrm/getProfileFlagsForIdentifier.test.ts +++ b/lambdas/account-scoped/tests/service/hrm/getProfileFlagsForIdentifier.test.ts @@ -49,7 +49,6 @@ describe('getProfileFlagsForIdentifier endpoint', () => { const MOCK_IDENTIFIER = '123456789'; const MOCK_VOICE_EVENT: Omit = { - channelType: '', trigger: { call: { From: '1234-56 789', diff --git a/lambdas/account-scoped/tests/unit/conversation/janitorTaskRouterListener.test.ts b/lambdas/account-scoped/tests/unit/conversation/janitorTaskRouterListener.test.ts index 6e28a0332d..4c692b41a8 100644 --- a/lambdas/account-scoped/tests/unit/conversation/janitorTaskRouterListener.test.ts +++ b/lambdas/account-scoped/tests/unit/conversation/janitorTaskRouterListener.test.ts @@ -19,7 +19,6 @@ import { handleEvent } from '../../../src/conversation/janitorTaskRouterListener import { chatChannelJanitor } from '../../../src/conversation/chatChannelJanitor'; import { hasTaskControl } from '../../../src/transfer/hasTaskControl'; import { isChatCaptureControlTask } from '../../../src/channelCapture/channelCaptureHandlers'; -import { isAseloCustomChannel } from '../../../src/customChannels/aseloCustomChannels'; import { EventFields } from '../../../src/taskrouter'; import { TEST_ACCOUNT_SID, @@ -35,6 +34,7 @@ import { TASK_WRAPUP, } from '../../../src/taskrouter/eventTypes'; import { getCurrentDefinitionVersion } from '../../../src/hrm/formDefinitionsCache'; +import { isAseloCustomChannelType } from '@tech-matters/twilio-types'; jest.mock('../../../src/conversation/chatChannelJanitor', () => ({ chatChannelJanitor: jest.fn(), @@ -55,11 +55,11 @@ const mockIsChatCaptureControlTask = isChatCaptureControlTask as jest.MockedFunc typeof isChatCaptureControlTask >; -jest.mock('../../../src/customChannels/aseloCustomChannels', () => ({ - isAseloCustomChannel: jest.fn(), +jest.mock('@tech-matters/twilio-types', () => ({ + isAseloCustomChannelType: jest.fn(), })); -const mockIsAseloCustomChannel = isAseloCustomChannel as jest.MockedFunction< - typeof isAseloCustomChannel +const mockIsAseloCustomChannelType = isAseloCustomChannelType as jest.MockedFunction< + typeof isAseloCustomChannelType >; jest.mock('@tech-matters/twilio-configuration', () => ({ @@ -104,7 +104,7 @@ describe('janitorTaskRouterListener', () => { }); mockHasTaskControl.mockResolvedValue(true); mockIsChatCaptureControlTask.mockReturnValue(false); - mockIsAseloCustomChannel.mockReturnValue(false); + mockIsAseloCustomChannelType.mockReturnValue(false); mockGetCurrentDefinitionVersion.mockResolvedValue({} as any); }); @@ -154,7 +154,7 @@ describe('janitorTaskRouterListener', () => { test('custom channel task on TASK_DELETED - calls chatChannelJanitor for channelSid', async () => { mockIsChatCaptureControlTask.mockReturnValue(false); mockHasTaskControl.mockResolvedValue(true); - mockIsAseloCustomChannel.mockReturnValue(true); + mockIsAseloCustomChannelType.mockReturnValue(true); await handleEvent( newEventFields('chat', TASK_DELETED, { channelType: 'instagram' }), @@ -170,7 +170,7 @@ describe('janitorTaskRouterListener', () => { test('custom channel task but not in task control - skips chatChannelJanitor', async () => { mockIsChatCaptureControlTask.mockReturnValue(false); mockHasTaskControl.mockResolvedValue(false); - mockIsAseloCustomChannel.mockReturnValue(true); + mockIsAseloCustomChannelType.mockReturnValue(true); await handleEvent( newEventFields('chat', TASK_DELETED, { channelType: 'instagram' }), @@ -187,7 +187,7 @@ describe('janitorTaskRouterListener', () => { }); mockIsChatCaptureControlTask.mockReturnValue(false); mockHasTaskControl.mockResolvedValue(true); - mockIsAseloCustomChannel.mockReturnValue(false); + mockIsAseloCustomChannelType.mockReturnValue(false); await handleEvent(newEventFields('chat', TASK_WRAPUP), TEST_ACCOUNT_SID, client); @@ -203,7 +203,7 @@ describe('janitorTaskRouterListener', () => { }); mockIsChatCaptureControlTask.mockReturnValue(false); mockHasTaskControl.mockResolvedValue(true); - mockIsAseloCustomChannel.mockReturnValue(false); + mockIsAseloCustomChannelType.mockReturnValue(false); mockGetCurrentDefinitionVersion.mockResolvedValue({ insights: { postSurveySpecs: [{ taskChannelUniqueName: 'survey' }] }, } as any); @@ -219,7 +219,7 @@ describe('janitorTaskRouterListener', () => { }); mockIsChatCaptureControlTask.mockReturnValue(false); mockHasTaskControl.mockResolvedValue(true); - mockIsAseloCustomChannel.mockReturnValue(false); + mockIsAseloCustomChannelType.mockReturnValue(false); mockGetCurrentDefinitionVersion.mockResolvedValue({} as any); await handleEvent(newEventFields('chat', TASK_WRAPUP), TEST_ACCOUNT_SID, client); diff --git a/lambdas/account-scoped/tests/unit/hrm/getProfileFlagsForIdentifier.test.ts b/lambdas/account-scoped/tests/unit/hrm/getProfileFlagsForIdentifier.test.ts index 6c0c224d6f..796ebffff5 100644 --- a/lambdas/account-scoped/tests/unit/hrm/getProfileFlagsForIdentifier.test.ts +++ b/lambdas/account-scoped/tests/unit/hrm/getProfileFlagsForIdentifier.test.ts @@ -28,6 +28,7 @@ import { isErr, isOk, newErr, newOk } from '../../../src/Result'; import { HttpRequest } from '../../../src/httpTypes'; import { AssertionError } from 'node:assert'; import each from 'jest-each'; +import { ChannelType, channelTypes } from '@tech-matters/twilio-types'; jest.mock('twilio', () => () => ({})); @@ -75,7 +76,7 @@ const newWebchatEvent = (from: string): TriggerEvent => ({ request: { cookies: {}, headers: {} }, }); -const newConversationEvent = (channelType: string, from: string): TriggerEvent => ({ +const newConversationEvent = (channelType: ChannelType, from: string): TriggerEvent => ({ trigger: { conversation: { Author: from, @@ -238,12 +239,14 @@ describe('handleGetProfileFlagsForIdentifier', () => { }, }, }, - channelType: 'web', + channelType: channelTypes.WEB, request: { cookies: {}, headers: {} }, }, expectedIdentifier: '', }, - ...['telegram', 'instagram', 'messenger'].flatMap(channelType => [ + ...( + [channelTypes.TELEGRAM, channelTypes.INSTAGRAM, channelTypes.MESSENGER] as const + ).flatMap((channelType: ChannelType) => [ { description: `Conversation with ${channelType} prefixed channel identifier '${channelType}:lornas-address'`, inputEvent: newConversationEvent(channelType, `${channelType}:lornas-address`), @@ -255,25 +258,27 @@ describe('handleGetProfileFlagsForIdentifier', () => { expectedIdentifier: `lornas-address`, }, ]), - ...['whatsapp', 'modica'].flatMap(channelType => [ - { - description: `Conversation with ${channelType} prefixed channel identifier '${channelType}:123456789'`, - inputEvent: newConversationEvent(channelType, `${channelType}:123456789`), - expectedIdentifier: `123456789`, - }, - { - description: `Conversation with ${channelType} non prefixed channel identifier '123456789'`, - inputEvent: newConversationEvent(channelType, `123456789`), - expectedIdentifier: `123456789`, - }, - { - description: `Conversation with ${channelType} prefixed channel identifier with spaces and dashes '${channelType}:+123 456-789'`, - inputEvent: newConversationEvent(channelType, `${channelType}:+123456789`), - expectedIdentifier: `+123456789`, - }, - ]), + ...([channelTypes.WHATSAPP, channelTypes.MODICA] as const).flatMap( + (channelType: ChannelType) => [ + { + description: `Conversation with ${channelType} prefixed channel identifier '${channelType}:123456789'`, + inputEvent: newConversationEvent(channelType, `${channelType}:123456789`), + expectedIdentifier: `123456789`, + }, + { + description: `Conversation with ${channelType} non prefixed channel identifier '123456789'`, + inputEvent: newConversationEvent(channelType, `123456789`), + expectedIdentifier: `123456789`, + }, + { + description: `Conversation with ${channelType} prefixed channel identifier with spaces and dashes '${channelType}:+123 456-789'`, + inputEvent: newConversationEvent(channelType, `${channelType}:+123456789`), + expectedIdentifier: `+123456789`, + }, + ], + ), // ...['line', 'web'].flatMap(channelType => [ - ...['line'].flatMap(channelType => [ + ...([channelTypes.LINE] as const).flatMap((channelType: ChannelType) => [ { description: `Conversation with ${channelType} prefixed channel identifier '${channelType}:lornas-address'`, inputEvent: newConversationEvent(channelType, `${channelType}:lornas-address`), @@ -323,7 +328,7 @@ describe('handleGetProfileFlagsForIdentifier', () => { // Act const result = await handleGetProfileFlagsForIdentifier( newProfileFlagsForIdentifierRequest( - newConversationEvent('carrier pigeon', 'speedy geraldine'), + newConversationEvent('carrier pigeon' as ChannelType, 'speedy geraldine'), ), TEST_ACCOUNT_SID, ); diff --git a/lambdas/packages/twilio-types/src/channelType.ts b/lambdas/packages/twilio-types/src/channelType.ts new file mode 100644 index 0000000000..ffbcc2d2e7 --- /dev/null +++ b/lambdas/packages/twilio-types/src/channelType.ts @@ -0,0 +1,41 @@ +/** + * Copyright (C) 2021-2026 Technology Matters + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ +export const aseloCustomChannelTypes = { + INSTAGRAM: 'instagram', + LINE: 'line', + MODICA: 'modica', + TELEGRAM: 'telegram', +} as const; + +export type AseloCustomChannelType = + (typeof aseloCustomChannelTypes)[keyof typeof aseloCustomChannelTypes]; + +export const isAseloCustomChannelType = (channelType?: string): boolean => + Object.values(aseloCustomChannelTypes).includes(channelType as AseloCustomChannelType); + +export const channelTypes = { + VOICEMAIL: 'voicemail', + SMS: 'sms', + VOICE: 'voice', + WEB: 'web', + CHAT: 'chat', + WHATSAPP: 'whatsapp', + MESSENGER: 'messenger', + DEFAULT: 'default', + ...aseloCustomChannelTypes, +} as const; + +export type ChannelType = (typeof channelTypes)[keyof typeof channelTypes]; diff --git a/lambdas/packages/twilio-types/src/index.ts b/lambdas/packages/twilio-types/src/index.ts index e50e462bde..d5c19cd7c2 100644 --- a/lambdas/packages/twilio-types/src/index.ts +++ b/lambdas/packages/twilio-types/src/index.ts @@ -40,4 +40,5 @@ export const isAccountSID = (value: string): value is AccountSID => // This regex could be stricter if we only wanted to catch 'real' account SIDs, but our test account sids have non hexadecimal characters /^AC[0-9a-zA-Z_]+$/.test(value); +export * from './channelType'; export * from './instagram'; From fbb53752aa853fd187252f0d4b00b35977e0b33d Mon Sep 17 00:00:00 2001 From: Stephen Hand Date: Tue, 4 Aug 2026 14:47:47 +0100 Subject: [PATCH 20/31] Add extra info to voicemail callback --- .../src/customChannels/line/lineToFlex.ts | 4 +- .../src/customChannels/modica/modicaToFlex.ts | 4 +- .../voicemail/recordingCompleteCallback.ts | 47 +++++++++++++++---- lambdas/packages/twilio-types/src/index.ts | 1 + 4 files changed, 40 insertions(+), 16 deletions(-) diff --git a/lambdas/account-scoped/src/customChannels/line/lineToFlex.ts b/lambdas/account-scoped/src/customChannels/line/lineToFlex.ts index 2fcd1c92d1..69b21e7b48 100644 --- a/lambdas/account-scoped/src/customChannels/line/lineToFlex.ts +++ b/lambdas/account-scoped/src/customChannels/line/lineToFlex.ts @@ -16,9 +16,7 @@ import crypto from 'crypto'; import { AccountSID, aseloCustomChannelTypes } from '@tech-matters/twilio-types'; -import { - sendConversationMessageToFlex, -} from '../customChannelToFlex'; +import { sendConversationMessageToFlex } from '../customChannelToFlex'; import { AccountScopedHandler, HttpRequest } from '../../httpTypes'; import { newErr, newOk } from '../../Result'; import { getChannelStudioFlowSid, getLineChannelSecret } from '../configuration'; diff --git a/lambdas/account-scoped/src/customChannels/modica/modicaToFlex.ts b/lambdas/account-scoped/src/customChannels/modica/modicaToFlex.ts index bfab5ce47c..dd136c22f3 100644 --- a/lambdas/account-scoped/src/customChannels/modica/modicaToFlex.ts +++ b/lambdas/account-scoped/src/customChannels/modica/modicaToFlex.ts @@ -15,9 +15,7 @@ */ import { AccountSID, aseloCustomChannelTypes } from '@tech-matters/twilio-types'; -import { - sendConversationMessageToFlex, -} from '../customChannelToFlex'; +import { sendConversationMessageToFlex } from '../customChannelToFlex'; import { AccountScopedHandler, HttpRequest } from '../../httpTypes'; import { newErr, newOk } from '../../Result'; import { getChannelStudioFlowSid } from '../configuration'; diff --git a/lambdas/account-scoped/src/voicemail/recordingCompleteCallback.ts b/lambdas/account-scoped/src/voicemail/recordingCompleteCallback.ts index 1111e6d839..965bdf3aca 100644 --- a/lambdas/account-scoped/src/voicemail/recordingCompleteCallback.ts +++ b/lambdas/account-scoped/src/voicemail/recordingCompleteCallback.ts @@ -17,22 +17,47 @@ import { getTwilioClient, getWorkspaceSid } from '@tech-matters/twilio-configuration'; import { AccountScopedHandler, HttpError } from '../httpTypes'; import { newOk, Result } from '../Result'; -import {channelTypes} from "@tech-matters/twilio-types/src/channelType"; +import { channelTypes } from '@tech-matters/twilio-types/src/channelType'; +import type { CallSid, RecordingSid } from '@tech-matters/twilio-types'; + +const DEFAULT_MAX_CALLBACK_ATTEMPTS = 3; export type RecordingCompleteCallbackRequestBody = { - callFrom: string; + from: string; + callSid: CallSid; + recordingSid: RecordingSid; + maxCallbackAttempts: number; }; export const recordingCompleteCallback: AccountScopedHandler = async ( { body }, accountSid, ): Promise> => { - console.debug( - '[SENSITIVE] recordingCompleteCallback body', - JSON.stringify(body, null, 2), - ); + console.debug('recordingCompleteCallback body', JSON.stringify(body, null, 2)); + const { from, callSid, recordingSid, maxCallbackAttempts } = + body as RecordingCompleteCallbackRequestBody; const twilioClient = await getTwilioClient(accountSid); + let receivedTime: Date; + try { + const recordingInstance = await twilioClient.recordings.get(recordingSid).fetch(); + receivedTime = recordingInstance.startTime; + } catch (recordingError) { + try { + console.warn( + `[${accountSid}] Error finding start time for recordingSid: ${recordingSid}, callSid: ${callSid} to use as received time for voicemail - falling back to call start time`, + recordingError, + ); + const callInstance = await twilioClient.calls.get(callSid).fetch(); + receivedTime = callInstance.startTime; + } catch (callError) { + console.warn( + `[${accountSid}] Error finding fallback start time for callSid: ${callSid} to use as received time for voicemail - falling back to current time`, + recordingError, + ); + receivedTime = new Date(); + } + } const workspaceSid = await getWorkspaceSid(accountSid); const voicemailTask = await twilioClient.taskrouter.v1 @@ -41,10 +66,12 @@ export const recordingCompleteCallback: AccountScopedHandler = async ( timeout: 604800, // 7 days attributes: JSON.stringify({ ...(body.routingAttributes ? JSON.parse(body.routingAttributes) : {}), - isVoicemail: true, - callSid: body.callSid, - from: body.from, - name: body.from, + receivedTime: receivedTime.toISOString(), + callbackAttemptsMade: 0, + maxCallbackAttempts: maxCallbackAttempts ?? DEFAULT_MAX_CALLBACK_ATTEMPTS, + callSid, + from, + name: from, channelType: channelTypes.VOICEMAIL, customChannelType: channelTypes.VOICEMAIL, ignoreAgent: '', diff --git a/lambdas/packages/twilio-types/src/index.ts b/lambdas/packages/twilio-types/src/index.ts index d5c19cd7c2..38fe96aa43 100644 --- a/lambdas/packages/twilio-types/src/index.ts +++ b/lambdas/packages/twilio-types/src/index.ts @@ -35,6 +35,7 @@ export type ChatChannelSID = ConversationSID; // Voice SIDs export type CallSid = `CA${string}`; export type ConferenceSid = `CF${string}`; +export type RecordingSid = `RE${string}`; export const isAccountSID = (value: string): value is AccountSID => // This regex could be stricter if we only wanted to catch 'real' account SIDs, but our test account sids have non hexadecimal characters From 72d687b3e454d9cc50127538a1672936731e4285 Mon Sep 17 00:00:00 2001 From: Stephen Hand Date: Tue, 4 Aug 2026 14:56:41 +0100 Subject: [PATCH 21/31] Add parameter checks --- .../src/voicemail/recordingCompleteCallback.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/lambdas/account-scoped/src/voicemail/recordingCompleteCallback.ts b/lambdas/account-scoped/src/voicemail/recordingCompleteCallback.ts index 965bdf3aca..87a1ca8093 100644 --- a/lambdas/account-scoped/src/voicemail/recordingCompleteCallback.ts +++ b/lambdas/account-scoped/src/voicemail/recordingCompleteCallback.ts @@ -19,6 +19,7 @@ import { AccountScopedHandler, HttpError } from '../httpTypes'; import { newOk, Result } from '../Result'; import { channelTypes } from '@tech-matters/twilio-types/src/channelType'; import type { CallSid, RecordingSid } from '@tech-matters/twilio-types'; +import { newMissingParameterResult } from '../httpErrors'; const DEFAULT_MAX_CALLBACK_ATTEMPTS = 3; @@ -37,6 +38,18 @@ export const recordingCompleteCallback: AccountScopedHandler = async ( const { from, callSid, recordingSid, maxCallbackAttempts } = body as RecordingCompleteCallbackRequestBody; + if (!callSid) { + return newMissingParameterResult('callSid'); + } + if (!from) { + return newMissingParameterResult('from'); + } + if (!recordingSid) { + console.warn( + `[${accountSid}] Recording SID not set in voicemail recording callback handler for call: ${callSid}, cannot set an accurate received time for the voicemail`, + ); + } + const twilioClient = await getTwilioClient(accountSid); let receivedTime: Date; try { From 5c8a4502f2903a6fe864f66755cfe54744ad9c95 Mon Sep 17 00:00:00 2001 From: Stephen Hand Date: Tue, 4 Aug 2026 15:55:09 +0100 Subject: [PATCH 22/31] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../account-scoped/src/voicemail/recordingCompleteCallback.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lambdas/account-scoped/src/voicemail/recordingCompleteCallback.ts b/lambdas/account-scoped/src/voicemail/recordingCompleteCallback.ts index 965bdf3aca..2b7a8ff134 100644 --- a/lambdas/account-scoped/src/voicemail/recordingCompleteCallback.ts +++ b/lambdas/account-scoped/src/voicemail/recordingCompleteCallback.ts @@ -26,7 +26,9 @@ export type RecordingCompleteCallbackRequestBody = { from: string; callSid: CallSid; recordingSid: RecordingSid; - maxCallbackAttempts: number; + voicemailWorkflowSid: string; + routingAttributes?: string; + maxCallbackAttempts?: number; }; export const recordingCompleteCallback: AccountScopedHandler = async ( From d6ec85e1bc6955869d42cf50d64d4414f1066dd8 Mon Sep 17 00:00:00 2001 From: Stephen Hand Date: Tue, 4 Aug 2026 15:55:37 +0100 Subject: [PATCH 23/31] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../src/voicemail/recordingCompleteCallback.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/lambdas/account-scoped/src/voicemail/recordingCompleteCallback.ts b/lambdas/account-scoped/src/voicemail/recordingCompleteCallback.ts index 2b7a8ff134..a4c21f4839 100644 --- a/lambdas/account-scoped/src/voicemail/recordingCompleteCallback.ts +++ b/lambdas/account-scoped/src/voicemail/recordingCompleteCallback.ts @@ -15,10 +15,11 @@ */ import { getTwilioClient, getWorkspaceSid } from '@tech-matters/twilio-configuration'; +import type { CallSid, RecordingSid } from '@tech-matters/twilio-types'; +import { channelTypes } from '@tech-matters/twilio-types'; import { AccountScopedHandler, HttpError } from '../httpTypes'; import { newOk, Result } from '../Result'; -import { channelTypes } from '@tech-matters/twilio-types/src/channelType'; -import type { CallSid, RecordingSid } from '@tech-matters/twilio-types'; +import { newMissingParameterResult } from '../httpErrors'; const DEFAULT_MAX_CALLBACK_ATTEMPTS = 3; From 29eb71fd4b62ec12352b6d57402b852409caae74 Mon Sep 17 00:00:00 2001 From: Stephen Hand Date: Tue, 4 Aug 2026 15:55:48 +0100 Subject: [PATCH 24/31] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- lambdas/account-scoped/src/router.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lambdas/account-scoped/src/router.ts b/lambdas/account-scoped/src/router.ts index 51de151051..31583afadc 100644 --- a/lambdas/account-scoped/src/router.ts +++ b/lambdas/account-scoped/src/router.ts @@ -392,7 +392,7 @@ const ACCOUNTSID_ROUTES: Record< handler: sendMessageAndRunJanitorHandler, }), 'voicemail/recordingCompleteCallback': newRoute({ - requestPipeline: [validateWebhookRequest], + requestPipeline: [validateRequestMethod('POST'), validateWebhookRequest], handler: recordingCompleteCallback, }), issueSyncToken: newRoute({ From 0d6430f570fd3ace308f0567b090d9766cafea8c Mon Sep 17 00:00:00 2001 From: Stephen Hand Date: Tue, 4 Aug 2026 17:49:32 +0100 Subject: [PATCH 25/31] Move create contact code around to be more resilient --- .../hrm/createHrmContactTaskRouterListener.ts | 28 ++++++++----------- 1 file changed, 11 insertions(+), 17 deletions(-) diff --git a/lambdas/account-scoped/src/hrm/createHrmContactTaskRouterListener.ts b/lambdas/account-scoped/src/hrm/createHrmContactTaskRouterListener.ts index e291f2d8d6..51f5cf729f 100644 --- a/lambdas/account-scoped/src/hrm/createHrmContactTaskRouterListener.ts +++ b/lambdas/account-scoped/src/hrm/createHrmContactTaskRouterListener.ts @@ -35,6 +35,7 @@ import { populateHrmContactFormFromTaskByMappings } from './populateHrmContactFo import { parseISO } from 'date-fns/parseISO'; import { HttpClientError } from '../httpErrors'; import { getExternalRecordingS3Location } from '../conversation/getExternalRecordingS3Location'; +import { patchTaskAttributes } from '../task/patchTaskAttributes'; // Temporarily copied to this repo, will share the flex types when we move them into the same repo @@ -248,6 +249,15 @@ export const handleEvent = async ( const savedTimeOfContactDate = parseISO(savedTimeOfContactString); console.info(`Created HRM contact with id ${id} for task ${taskSid}`); + await patchTaskAttributes(accountSid, taskSid, currentTaskAttributes => ({ + ...currentTaskAttributes, + contactId: id.toString(), + outboundVoiceTaskStartMillis: isOutboundVoiceTask + ? timeOfContactDate.getTime() + : null, + timeOfContactMillis: savedTimeOfContactDate.getTime(), + })); + if (channel === ('voicemail' as any)) { console.info( `Channel type is ${channel}, adding conversation media with call sid ${taskAttributes.callSid}`, @@ -281,26 +291,10 @@ export const handleEvent = async ( conversationMedia, ); console.debug( - `[SENSITIVE] Conversation media result ${conversationMediaResult.status} ${isOk(conversationMediaResult) ? JSON.stringify(conversationMediaResult.data) : JSON.stringify(conversationMediaResult.error)}`, + `Conversation media result ${conversationMediaResult.status} ${isOk(conversationMediaResult) ? JSON.stringify(conversationMediaResult.data) : JSON.stringify(conversationMediaResult.error)}`, ); } } - - const taskContext = client.taskrouter.v1.workspaces - .get(twilioWorkspaceSid) - .tasks.get(taskSid); - const currentTaskAttributes = (await taskContext.fetch()).attributes; // Less chance of race conditions if we fetch the task attributes again, still not the best... - const updatedAttributes = { - ...JSON.parse(currentTaskAttributes), - contactId: id.toString(), - outboundVoiceTaskStartMillis: isOutboundVoiceTask - ? timeOfContactDate.getTime() - : null, - timeOfContactMillis: savedTimeOfContactDate.getTime(), - }; - await taskContext.update({ attributes: JSON.stringify(updatedAttributes) }); - console.info(`Set task ${taskSid} attributes`); - console.debug(`[SENSITIVE] Updated ${taskSid} attributes:`, updatedAttributes); }; registerTaskRouterEventHandler([RESERVATION_ACCEPTED], handleEvent); From d73232be67713510ae466b05017a4e5e2b42f5d6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 4 Aug 2026 17:48:52 +0000 Subject: [PATCH 26/31] Fix failing tests and linter issues Co-authored-by: stephenhand <1694716+stephenhand@users.noreply.github.com> --- .../src/conversation/janitorTaskRouterListener.ts | 2 +- .../src/hrm/createHrmContactTaskRouterListener.ts | 4 ++-- .../___tests__/transfer/transferTaskState.test.ts | 14 +++++++------- .../src/voicemail/VoicemailTaskPanel.tsx | 1 - 4 files changed, 10 insertions(+), 11 deletions(-) diff --git a/lambdas/account-scoped/src/conversation/janitorTaskRouterListener.ts b/lambdas/account-scoped/src/conversation/janitorTaskRouterListener.ts index 7856c96d04..207e6b628e 100644 --- a/lambdas/account-scoped/src/conversation/janitorTaskRouterListener.ts +++ b/lambdas/account-scoped/src/conversation/janitorTaskRouterListener.ts @@ -39,7 +39,7 @@ import { getCurrentDefinitionVersion } from '../hrm/formDefinitionsCache'; import { ChannelType, isAseloCustomChannelType, -} from '@tech-matters/twilio-types/src/channelType'; +} from '@tech-matters/twilio-types'; const isCleanupBotCapture = ( eventType: EventType, diff --git a/lambdas/account-scoped/src/hrm/createHrmContactTaskRouterListener.ts b/lambdas/account-scoped/src/hrm/createHrmContactTaskRouterListener.ts index 51f5cf729f..c6488ef6ec 100644 --- a/lambdas/account-scoped/src/hrm/createHrmContactTaskRouterListener.ts +++ b/lambdas/account-scoped/src/hrm/createHrmContactTaskRouterListener.ts @@ -21,7 +21,7 @@ import { registerTaskRouterEventHandler } from '../taskrouter/taskrouterEventHan import { RESERVATION_ACCEPTED } from '../taskrouter/eventTypes'; import type { EventFields } from '../taskrouter'; import twilio from 'twilio'; -import { AccountSID, TaskSID, WorkerSID } from '@tech-matters/twilio-types'; +import { AccountSID, TaskSID, WorkerSID, channelTypes } from '@tech-matters/twilio-types'; import { getWorkspaceSid } from '@tech-matters/twilio-configuration'; import { patchOnInternalHrmEndpoint, @@ -258,7 +258,7 @@ export const handleEvent = async ( timeOfContactMillis: savedTimeOfContactDate.getTime(), })); - if (channel === ('voicemail' as any)) { + if (channel === channelTypes.VOICEMAIL) { console.info( `Channel type is ${channel}, adding conversation media with call sid ${taskAttributes.callSid}`, ); diff --git a/plugin-hrm-form/src/___tests__/transfer/transferTaskState.test.ts b/plugin-hrm-form/src/___tests__/transfer/transferTaskState.test.ts index 5bed9dfcce..f9e8756729 100644 --- a/plugin-hrm-form/src/___tests__/transfer/transferTaskState.test.ts +++ b/plugin-hrm-form/src/___tests__/transfer/transferTaskState.test.ts @@ -432,7 +432,7 @@ describe('Conference Transfer', () => { ...jest.requireActual('@twilio/flex-ui'), TaskHelper: { isLiveCall: jest.fn(), - isChatBasedTask: jest.fn(), + isVoiceTask: jest.fn(), }, Manager: { getInstance: jest.fn() }, })); @@ -446,7 +446,7 @@ describe('Conference Transfer', () => { }, }; - const mockIsChatBasedTask = (value: boolean) => jest.spyOn(Flex.TaskHelper, 'isChatBasedTask').mockReturnValue(value); + const mockIsVoiceTask = (value: boolean) => jest.spyOn(Flex.TaskHelper, 'isVoiceTask').mockReturnValue(value); const mockIsLiveCall = (value: boolean) => jest.spyOn(Flex.TaskHelper, 'isLiveCall').mockReturnValue(value); const mockInstance = (task: Required<{ taskSid: string }>) => { const instance = { @@ -469,7 +469,7 @@ describe('Conference Transfer', () => { jest.spyOn(callStatus, 'isCallStatusLoading').mockReturnValue(value); test('Cannot transfer if is not live call', () => { - mockIsChatBasedTask(false); + mockIsVoiceTask(true); mockIsLiveCall(false); mockInstance(task); mockIsCallStatusLoading(task, false); @@ -478,7 +478,7 @@ describe('Conference Transfer', () => { }); test('Cannot transfer while isLoading', () => { - mockIsChatBasedTask(false); + mockIsVoiceTask(true); mockIsLiveCall(true); mockInstance(task); mockIsCallStatusLoading(task, true); @@ -490,7 +490,7 @@ describe('Conference Transfer', () => { const threeParticipantsTask = { ...task, conference: { ...task.conference } }; threeParticipantsTask.conference.liveParticipantCount = 3; - mockIsChatBasedTask(false); + mockIsVoiceTask(true); mockIsLiveCall(true); mockInstance(task); mockIsCallStatusLoading(threeParticipantsTask, false); @@ -499,7 +499,7 @@ describe('Conference Transfer', () => { }); test('Should be able to transfer', () => { - mockIsChatBasedTask(false); + mockIsVoiceTask(true); mockIsLiveCall(true); mockInstance(task); mockIsCallStatusLoading(task, false); @@ -508,7 +508,7 @@ describe('Conference Transfer', () => { }); test('Should be able to transfer when chat task', () => { - mockIsChatBasedTask(true); + mockIsVoiceTask(false); mockIsLiveCall(false); mockInstance(task); mockIsCallStatusLoading(task, false); diff --git a/plugin-hrm-form/src/voicemail/VoicemailTaskPanel.tsx b/plugin-hrm-form/src/voicemail/VoicemailTaskPanel.tsx index 72641162c5..5216e0f7ef 100644 --- a/plugin-hrm-form/src/voicemail/VoicemailTaskPanel.tsx +++ b/plugin-hrm-form/src/voicemail/VoicemailTaskPanel.tsx @@ -18,7 +18,6 @@ import * as Flex from '@twilio/flex-ui'; import React from 'react'; import { useSelector } from 'react-redux'; -import { channelTypes } from '../states/DomainConstants'; import { PrimaryButton } from '../styles/buttons'; import { Flex as FlexBox } from '../styles'; import { RecordingSection } from '../components/contact/MediaSection'; From 663d0769b6f33b7c24b5c3c01346e41f68b90cba Mon Sep 17 00:00:00 2001 From: Stephen Hand Date: Tue, 4 Aug 2026 19:36:32 +0100 Subject: [PATCH 27/31] Fix test --- .../components/queuesStatus/QueuesStatus.test.tsx | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/plugin-hrm-form/src/___tests__/components/queuesStatus/QueuesStatus.test.tsx b/plugin-hrm-form/src/___tests__/components/queuesStatus/QueuesStatus.test.tsx index 3cac79ba2a..950aab7eae 100644 --- a/plugin-hrm-form/src/___tests__/components/queuesStatus/QueuesStatus.test.tsx +++ b/plugin-hrm-form/src/___tests__/components/queuesStatus/QueuesStatus.test.tsx @@ -113,6 +113,7 @@ test('Test after update', () => { telegram: 0, instagram: 0, line: 0, + voicemail: 0, longestWaitingDate: secondsAgo.toISOString(), }, Q2: { @@ -124,6 +125,7 @@ test('Test after update', () => { telegram: 0, instagram: 0, line: 0, + voicemail: 0, longestWaitingDate: oneMinuteAgo.toISOString(), }, Q3: { @@ -135,6 +137,7 @@ test('Test after update', () => { telegram: 0, instagram: 0, line: 0, + voicemail: 0, longestWaitingDate: twoMinutesAgo.toISOString(), }, Q4: { @@ -146,6 +149,7 @@ test('Test after update', () => { telegram: 0, instagram: 0, line: 0, + voicemail: 0, longestWaitingDate: null, }, Admin: { @@ -157,6 +161,7 @@ test('Test after update', () => { telegram: 0, instagram: 0, line: 0, + voicemail: 0, longestWaitingDate: null, }, }, @@ -177,7 +182,7 @@ test('Test after update', () => { expect(screen.getAllByText('QueueCard-Name')).toHaveLength(5); - const expectAllChannelsRendersInQ = ([qName, qStatus]) => + const expectAllChannelsRendersInQ = ([qName, _qStatus]) => Object.values(coreChannelTypes).forEach(c => { // Check that the UI renders for this channel const channelInQ = screen.getByTestId(`${qName}-${c}`); @@ -227,6 +232,7 @@ each([ telegram: 0, instagram: 0, line: 0, + voicemail: 0, longestWaitingDate: secondsAgo.toISOString(), }, }, @@ -248,7 +254,7 @@ each([ expect(screen.getAllByText('QueueCard-Name')).toHaveLength(1); expect(screen.getAllByTestId('channel-box-inner-value')).toHaveLength(expectedChannelsCount); - const expectAllChannelsRendersInQ = ([qName, qStatus]) => + const expectAllChannelsRendersInQ = ([qName, _qStatus]) => Object.values(coreChannelTypes).forEach(c => { if (ownProps.contactsWaitingChannels.includes(c as any)) { // Check that the UI renders for this channel (if it's defined in contactsWaitingChannels) @@ -320,8 +326,6 @@ test('Test after error', () => { test('a11y', async () => { const secondsAgo = new Date(); - const oneMinuteAgo = new Date(secondsAgo.getTime() - 60 * 1000); - const twoMinutesAgo = new Date(oneMinuteAgo.getTime() - 60 * 1000); const ownProps = { colors, From ed645bd7564f33f384fc8a9fd62ef9a66bf87535 Mon Sep 17 00:00:00 2001 From: Stephen Hand Date: Wed, 5 Aug 2026 09:45:42 +0100 Subject: [PATCH 28/31] Tidy up --- .../src/hrm/createHrmContactTaskRouterListener.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/lambdas/account-scoped/src/hrm/createHrmContactTaskRouterListener.ts b/lambdas/account-scoped/src/hrm/createHrmContactTaskRouterListener.ts index c6488ef6ec..19eb246af4 100644 --- a/lambdas/account-scoped/src/hrm/createHrmContactTaskRouterListener.ts +++ b/lambdas/account-scoped/src/hrm/createHrmContactTaskRouterListener.ts @@ -176,8 +176,6 @@ export const handleEvent = async ( return; } - const twilioWorkspaceSid = await getWorkspaceSid(accountSid); - console.debug('Creating HRM contact for task', taskSid, 'Hrm Account:', hrmAccountId); const isOutboundVoiceTask = direction === 'outbound' && Boolean(conference); From 30225b089a6b5fd056fb8bc663202f180e3b9bfb Mon Sep 17 00:00:00 2001 From: Stephen Hand Date: Wed, 5 Aug 2026 09:51:05 +0100 Subject: [PATCH 29/31] Linter --- .../src/conversation/janitorTaskRouterListener.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/lambdas/account-scoped/src/conversation/janitorTaskRouterListener.ts b/lambdas/account-scoped/src/conversation/janitorTaskRouterListener.ts index 207e6b628e..dde1d803de 100644 --- a/lambdas/account-scoped/src/conversation/janitorTaskRouterListener.ts +++ b/lambdas/account-scoped/src/conversation/janitorTaskRouterListener.ts @@ -36,10 +36,7 @@ import { isChatCaptureControlTask } from '../channelCapture/channelCaptureHandle import { getWorkspaceSid } from '@tech-matters/twilio-configuration'; import { ChatChannelSID, ConversationSID } from '@tech-matters/twilio-types'; import { getCurrentDefinitionVersion } from '../hrm/formDefinitionsCache'; -import { - ChannelType, - isAseloCustomChannelType, -} from '@tech-matters/twilio-types'; +import { ChannelType, isAseloCustomChannelType } from '@tech-matters/twilio-types'; const isCleanupBotCapture = ( eventType: EventType, From 34dd58148eb44cebc0e14553614691151d80e349 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 5 Aug 2026 09:06:24 +0000 Subject: [PATCH 30/31] Fix failing createHrmContactTaskRouterListener test by mocking patchTaskAttributes Co-authored-by: stephenhand <1694716+stephenhand@users.noreply.github.com> --- ...createHrmContactTaskRouterListener.test.ts | 22 ++++++++++++++++++- lambdas/package-lock.json | 2 +- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/lambdas/account-scoped/tests/unit/hrm/createHrmContactTaskRouterListener.test.ts b/lambdas/account-scoped/tests/unit/hrm/createHrmContactTaskRouterListener.test.ts index 7f6864d43b..aafa93384b 100644 --- a/lambdas/account-scoped/tests/unit/hrm/createHrmContactTaskRouterListener.test.ts +++ b/lambdas/account-scoped/tests/unit/hrm/createHrmContactTaskRouterListener.test.ts @@ -23,6 +23,7 @@ import { EventFields } from '../../../src/taskrouter'; import { getSsmParameter } from '@tech-matters/ssm-cache'; import { handleEvent } from '../../../src/hrm/createHrmContactTaskRouterListener'; import { populateHrmContactFormFromTaskByKeys } from '../../../src/hrm/populateHrmContactFormFromTaskByKeys'; +import { patchTaskAttributes } from '../../../src/task/patchTaskAttributes'; import { TEST_ACCOUNT_SID, TEST_CONTACT_ID, @@ -52,6 +53,13 @@ const mockPopulateHrmContactFormFromTask = typeof populateHrmContactFormFromTaskByKeys >; +jest.mock('../../../src/task/patchTaskAttributes', () => ({ + patchTaskAttributes: jest.fn(), +})); +const mockPatchTaskAttributes = patchTaskAttributes as jest.MockedFunction< + typeof patchTaskAttributes +>; + const newEventFields = ( attributes: Record = {}, ): EventFields => @@ -144,6 +152,7 @@ describe('handleEvent', () => { id: TEST_CONTACT_ID, }), ); + mockPatchTaskAttributes.mockResolvedValue(newOk(undefined)); }); test('offline contact task - does nothing', async () => { @@ -178,6 +187,17 @@ describe('handleEvent', () => { setTaskReturnedByFetch(eventFields); await handleEvent(eventFields, TEST_ACCOUNT_SID, twilioClient); expect(mockFetch).toHaveBeenCalled(); - expect(mockUpdateTask).toHaveBeenCalled(); + expect(mockPatchTaskAttributes).toHaveBeenCalledWith( + TEST_ACCOUNT_SID, + TEST_TASK_SID, + expect.any(Function), + ); + const attributesGenerator = mockPatchTaskAttributes.mock.calls[0][2]; + const originalAttributes = JSON.parse(eventFields.TaskAttributes); + const patchedAttributes = attributesGenerator(originalAttributes); + expect(patchedAttributes).toMatchObject({ + ...originalAttributes, + contactId: TEST_CONTACT_ID.toString(), + }); }); }); diff --git a/lambdas/package-lock.json b/lambdas/package-lock.json index ad20bbb3ce..bde0f257bb 100644 --- a/lambdas/package-lock.json +++ b/lambdas/package-lock.json @@ -22287,7 +22287,7 @@ "ts-jest": "^29.4.6", "ts-node": "^10.1.0", "typescript": "^4.3.5", - "uuid": "14.0.1" + "uuid": "^14.0.1" }, "dependencies": { "@babel/code-frame": { From c16b2eccf081b4408e42c9940b61f5e343c9b4ae Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 5 Aug 2026 11:13:25 +0000 Subject: [PATCH 31/31] Add comprehensive unit tests for new voicemail lambda code Co-authored-by: stephenhand <1694716+stephenhand@users.noreply.github.com> --- ...createHrmContactTaskRouterListener.test.ts | 72 +++++- .../recordingCompleteCallback.test.ts | 241 ++++++++++++++++++ 2 files changed, 312 insertions(+), 1 deletion(-) create mode 100644 lambdas/account-scoped/tests/unit/voicemail/recordingCompleteCallback.test.ts diff --git a/lambdas/account-scoped/tests/unit/hrm/createHrmContactTaskRouterListener.test.ts b/lambdas/account-scoped/tests/unit/hrm/createHrmContactTaskRouterListener.test.ts index aafa93384b..bda8a84deb 100644 --- a/lambdas/account-scoped/tests/unit/hrm/createHrmContactTaskRouterListener.test.ts +++ b/lambdas/account-scoped/tests/unit/hrm/createHrmContactTaskRouterListener.test.ts @@ -24,6 +24,7 @@ import { getSsmParameter } from '@tech-matters/ssm-cache'; import { handleEvent } from '../../../src/hrm/createHrmContactTaskRouterListener'; import { populateHrmContactFormFromTaskByKeys } from '../../../src/hrm/populateHrmContactFormFromTaskByKeys'; import { patchTaskAttributes } from '../../../src/task/patchTaskAttributes'; +import { getExternalRecordingS3Location } from '../../../src/conversation/getExternalRecordingS3Location'; import { TEST_ACCOUNT_SID, TEST_CONTACT_ID, @@ -33,7 +34,7 @@ import { TEST_WORKSPACE_SID, } from '../../testTwilioValues'; import { setConfigurationAttributes } from '../mockServiceConfiguration'; -import { newOk } from '../../../src/Result'; +import { newErr, newOk } from '../../../src/Result'; const mockFetch: jest.MockedFunction = jest.fn(); global.fetch = mockFetch; @@ -60,6 +61,14 @@ const mockPatchTaskAttributes = patchTaskAttributes as jest.MockedFunction< typeof patchTaskAttributes >; +jest.mock('../../../src/conversation/getExternalRecordingS3Location', () => ({ + getExternalRecordingS3Location: jest.fn(), +})); +const mockGetExternalRecordingS3Location = + getExternalRecordingS3Location as jest.MockedFunction< + typeof getExternalRecordingS3Location + >; + const newEventFields = ( attributes: Record = {}, ): EventFields => @@ -153,6 +162,9 @@ describe('handleEvent', () => { }), ); mockPatchTaskAttributes.mockResolvedValue(newOk(undefined)); + mockGetExternalRecordingS3Location.mockResolvedValue( + newOk({ recordingSid: 'REtest', key: 'voice-recordings/ACut/REtest', bucket: 'test-bucket' }), + ); }); test('offline contact task - does nothing', async () => { @@ -200,4 +212,62 @@ describe('handleEvent', () => { contactId: TEST_CONTACT_ID.toString(), }); }); + + test('voicemail task - creates contact and posts conversationMedia with S3 recording location', async () => { + const eventFields: EventFields = { + ...newEventFields({ channelType: 'voicemail', customChannelType: 'voicemail', callSid: 'CAtest456' }), + } as EventFields; + setTaskReturnedByFetch(eventFields); + + await handleEvent(eventFields, TEST_ACCOUNT_SID, twilioClient); + + expect(mockGetExternalRecordingS3Location).toHaveBeenCalledWith({ + accountSid: TEST_ACCOUNT_SID, + callSid: 'CAtest456', + }); + + // Should have called fetch twice: once for the contact creation, once for conversationMedia + expect(mockFetch).toHaveBeenCalledTimes(2); + const conversationMediaCall = mockFetch.mock.calls[1]; + const conversationMediaBody = JSON.parse((conversationMediaCall[1] as RequestInit).body as string); + expect(conversationMediaBody).toEqual([ + { + storeType: 'S3', + storeTypeSpecificData: { + type: 'recording', + location: { + bucket: 'test-bucket', + key: 'voice-recordings/ACut/REtest', + }, + }, + }, + ]); + }); + + test('voicemail task - does not post conversationMedia when recording lookup fails', async () => { + mockGetExternalRecordingS3Location.mockResolvedValue( + newErr({ message: 'No recording found', error: { statusCode: 404 } }), + ); + + const eventFields: EventFields = { + ...newEventFields({ channelType: 'voicemail', customChannelType: 'voicemail', callSid: 'CAtest456' }), + } as EventFields; + setTaskReturnedByFetch(eventFields); + + await handleEvent(eventFields, TEST_ACCOUNT_SID, twilioClient); + + expect(mockGetExternalRecordingS3Location).toHaveBeenCalled(); + // Only 1 fetch call for contact creation, no conversationMedia call + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + test('non-voicemail task - does not look up recording or post conversationMedia', async () => { + const eventFields = newEventFields({ channelType: 'voice', customChannelType: 'voice' }); + setTaskReturnedByFetch(eventFields); + + await handleEvent(eventFields, TEST_ACCOUNT_SID, twilioClient); + + expect(mockGetExternalRecordingS3Location).not.toHaveBeenCalled(); + expect(mockFetch).toHaveBeenCalledTimes(1); + }); }); diff --git a/lambdas/account-scoped/tests/unit/voicemail/recordingCompleteCallback.test.ts b/lambdas/account-scoped/tests/unit/voicemail/recordingCompleteCallback.test.ts new file mode 100644 index 0000000000..b1c32a044f --- /dev/null +++ b/lambdas/account-scoped/tests/unit/voicemail/recordingCompleteCallback.test.ts @@ -0,0 +1,241 @@ +/** + * Copyright (C) 2021-2023 Technology Matters + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +import { getTwilioClient, getWorkspaceSid } from '@tech-matters/twilio-configuration'; +import { channelTypes } from '@tech-matters/twilio-types'; +import { recordingCompleteCallback } from '../../../src/voicemail/recordingCompleteCallback'; +import { isErr, isOk } from '../../../src/Result'; +import type { HttpRequest } from '../../../src/httpTypes'; +import { TEST_ACCOUNT_SID, TEST_WORKSPACE_SID } from '../../testTwilioValues'; + +jest.mock('@tech-matters/twilio-configuration', () => ({ + getTwilioClient: jest.fn(), + getWorkspaceSid: jest.fn(), +})); + +const mockGetTwilioClient = getTwilioClient as jest.MockedFunction; +const mockGetWorkspaceSid = getWorkspaceSid as jest.MockedFunction; + +const TEST_CALL_SID = 'CAtest123'; +const TEST_RECORDING_SID = 'REtest123'; +const TEST_FROM = '+12025551234'; +const TEST_WORKFLOW_SID = 'WWtest123'; +const TEST_TASK_SID = 'WTtest123'; +const RECORDING_START_TIME = new Date('2023-01-01T10:00:00Z'); +const CALL_START_TIME = new Date('2023-01-01T09:55:00Z'); + +const createRequest = (body: Record): HttpRequest => ({ + method: 'POST', + headers: {}, + path: '/voicemail/recordingCompleteCallback', + query: {}, + body, +}); + +const mockTasksCreate = jest.fn(); +const mockRecordingFetch = jest.fn(); +const mockCallFetch = jest.fn(); + +const createMockClient = () => ({ + recordings: { + get: jest.fn().mockReturnValue({ fetch: mockRecordingFetch }), + }, + calls: { + get: jest.fn().mockReturnValue({ fetch: mockCallFetch }), + }, + taskrouter: { + v1: { + workspaces: jest.fn().mockReturnValue({ + tasks: { + create: mockTasksCreate, + }, + }), + }, + }, +}); + +describe('recordingCompleteCallback', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockGetWorkspaceSid.mockResolvedValue(TEST_WORKSPACE_SID); + mockRecordingFetch.mockResolvedValue({ startTime: RECORDING_START_TIME }); + mockCallFetch.mockResolvedValue({ startTime: CALL_START_TIME }); + mockTasksCreate.mockResolvedValue({ sid: TEST_TASK_SID }); + mockGetTwilioClient.mockResolvedValue(createMockClient() as any); + }); + + test('returns missing parameter error when callSid is absent', async () => { + const request = createRequest({ from: TEST_FROM, recordingSid: TEST_RECORDING_SID }); + const result = await recordingCompleteCallback(request, TEST_ACCOUNT_SID); + expect(isErr(result)).toBe(true); + if (isErr(result)) { + expect(result.error.statusCode).toBe(400); + } + }); + + test('returns missing parameter error when from is absent', async () => { + const request = createRequest({ callSid: TEST_CALL_SID, recordingSid: TEST_RECORDING_SID }); + const result = await recordingCompleteCallback(request, TEST_ACCOUNT_SID); + expect(isErr(result)).toBe(true); + if (isErr(result)) { + expect(result.error.statusCode).toBe(400); + } + }); + + test('creates a voicemail task with recording start time when recording is found', async () => { + const request = createRequest({ + from: TEST_FROM, + callSid: TEST_CALL_SID, + recordingSid: TEST_RECORDING_SID, + voicemailWorkflowSid: TEST_WORKFLOW_SID, + }); + + const result = await recordingCompleteCallback(request, TEST_ACCOUNT_SID); + + expect(isOk(result)).toBe(true); + expect(mockTasksCreate).toHaveBeenCalledWith( + expect.objectContaining({ + workflowSid: TEST_WORKFLOW_SID, + taskChannel: channelTypes.VOICEMAIL, + timeout: 604800, + attributes: expect.stringContaining( + `"receivedTime":"${RECORDING_START_TIME.toISOString()}"`, + ), + }), + ); + + const createdAttributes = JSON.parse(mockTasksCreate.mock.calls[0][0].attributes); + expect(createdAttributes).toMatchObject({ + from: TEST_FROM, + callSid: TEST_CALL_SID, + channelType: channelTypes.VOICEMAIL, + customChannelType: channelTypes.VOICEMAIL, + callbackAttemptsMade: 0, + maxCallbackAttempts: 3, + receivedTime: RECORDING_START_TIME.toISOString(), + }); + }); + + test('falls back to call start time when recording fetch fails', async () => { + mockRecordingFetch.mockRejectedValue(new Error('Recording not found')); + + const request = createRequest({ + from: TEST_FROM, + callSid: TEST_CALL_SID, + recordingSid: TEST_RECORDING_SID, + voicemailWorkflowSid: TEST_WORKFLOW_SID, + }); + + const result = await recordingCompleteCallback(request, TEST_ACCOUNT_SID); + + expect(isOk(result)).toBe(true); + const createdAttributes = JSON.parse(mockTasksCreate.mock.calls[0][0].attributes); + expect(createdAttributes.receivedTime).toBe(CALL_START_TIME.toISOString()); + }); + + test('falls back to current time when both recording and call fetch fail', async () => { + mockRecordingFetch.mockRejectedValue(new Error('Recording not found')); + mockCallFetch.mockRejectedValue(new Error('Call not found')); + + const beforeTime = Date.now(); + const request = createRequest({ + from: TEST_FROM, + callSid: TEST_CALL_SID, + recordingSid: TEST_RECORDING_SID, + voicemailWorkflowSid: TEST_WORKFLOW_SID, + }); + + const result = await recordingCompleteCallback(request, TEST_ACCOUNT_SID); + const afterTime = Date.now(); + + expect(isOk(result)).toBe(true); + const createdAttributes = JSON.parse(mockTasksCreate.mock.calls[0][0].attributes); + const receivedTimeMs = new Date(createdAttributes.receivedTime).getTime(); + expect(receivedTimeMs).toBeGreaterThanOrEqual(beforeTime); + expect(receivedTimeMs).toBeLessThanOrEqual(afterTime); + }); + + test('merges routingAttributes into task attributes', async () => { + const routingAttributes = { queueName: 'voicemail-queue', priority: 10 }; + const request = createRequest({ + from: TEST_FROM, + callSid: TEST_CALL_SID, + recordingSid: TEST_RECORDING_SID, + voicemailWorkflowSid: TEST_WORKFLOW_SID, + routingAttributes: JSON.stringify(routingAttributes), + }); + + await recordingCompleteCallback(request, TEST_ACCOUNT_SID); + + const createdAttributes = JSON.parse(mockTasksCreate.mock.calls[0][0].attributes); + expect(createdAttributes).toMatchObject({ + queueName: 'voicemail-queue', + priority: 10, + from: TEST_FROM, + channelType: channelTypes.VOICEMAIL, + }); + }); + + test('uses provided maxCallbackAttempts instead of default', async () => { + const request = createRequest({ + from: TEST_FROM, + callSid: TEST_CALL_SID, + recordingSid: TEST_RECORDING_SID, + voicemailWorkflowSid: TEST_WORKFLOW_SID, + maxCallbackAttempts: 5, + }); + + await recordingCompleteCallback(request, TEST_ACCOUNT_SID); + + const createdAttributes = JSON.parse(mockTasksCreate.mock.calls[0][0].attributes); + expect(createdAttributes.maxCallbackAttempts).toBe(5); + }); + + test('proceeds without recording start time when recordingSid is absent', async () => { + const request = createRequest({ + from: TEST_FROM, + callSid: TEST_CALL_SID, + voicemailWorkflowSid: TEST_WORKFLOW_SID, + }); + + // With no recordingSid, the recording fetch will be called with undefined and may fail + mockRecordingFetch.mockRejectedValue(new Error('No recording SID')); + + const result = await recordingCompleteCallback(request, TEST_ACCOUNT_SID); + + // Should still succeed, falling back to call or current time + expect(isOk(result)).toBe(true); + }); + + test('returns the created task in the ok result', async () => { + const createdTask = { sid: TEST_TASK_SID, attributes: '{}' }; + mockTasksCreate.mockResolvedValue(createdTask); + + const request = createRequest({ + from: TEST_FROM, + callSid: TEST_CALL_SID, + recordingSid: TEST_RECORDING_SID, + voicemailWorkflowSid: TEST_WORKFLOW_SID, + }); + + const result = await recordingCompleteCallback(request, TEST_ACCOUNT_SID); + + expect(isOk(result)).toBe(true); + if (isOk(result)) { + expect(result.data.voicemailTask).toBe(createdTask); + } + }); +});