From d7ac2ae95a5ed55a16420d17b376edc351e9e1be Mon Sep 17 00:00:00 2001 From: Gianfranco Paoloni Date: Tue, 30 Jun 2026 23:56:14 -0300 Subject: [PATCH 01/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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,