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/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/conversation/janitorTaskRouterListener.ts b/lambdas/account-scoped/src/conversation/janitorTaskRouterListener.ts index 40a953172c..dde1d803de 100644 --- a/lambdas/account-scoped/src/conversation/janitorTaskRouterListener.ts +++ b/lambdas/account-scoped/src/conversation/janitorTaskRouterListener.ts @@ -33,10 +33,10 @@ 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'; const isCleanupBotCapture = ( eventType: EventType, @@ -71,8 +71,8 @@ const isCleanupCustomChannel = async ( workspaceSid: string, taskSid: string, taskAttributes: { - channelType?: string; - customChannelType?: string; + channelType?: ChannelType; + customChannelType?: ChannelType; isChatCaptureControl?: boolean; }, ) => { @@ -84,7 +84,7 @@ const isCleanupCustomChannel = async ( return false; } - return isAseloCustomChannel( + return isAseloCustomChannelType( taskAttributes.customChannelType || taskAttributes.channelType, ); }; 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..69b21e7b48 100644 --- a/lambdas/account-scoped/src/customChannels/line/lineToFlex.ts +++ b/lambdas/account-scoped/src/customChannels/line/lineToFlex.ts @@ -15,11 +15,8 @@ */ import crypto from 'crypto'; -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, getLineChannelSecret } from '../configuration'; @@ -125,13 +122,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..dd136c22f3 100644 --- a/lambdas/account-scoped/src/customChannels/modica/modicaToFlex.ts +++ b/lambdas/account-scoped/src/customChannels/modica/modicaToFlex.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 } from '../configuration'; @@ -41,7 +38,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 +47,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/createHrmContactTaskRouterListener.ts b/lambdas/account-scoped/src/hrm/createHrmContactTaskRouterListener.ts index 610358194e..19eb246af4 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, @@ -34,6 +34,8 @@ 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'; +import { patchTaskAttributes } from '../task/patchTaskAttributes'; // Temporarily copied to this repo, will share the flex types when we move them into the same repo @@ -174,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); @@ -215,7 +215,9 @@ export const handleEvent = async ( timeOfContact: timeOfContactDate.toISOString(), number: identifier, }; + console.debug('Creating HRM contact with timeOfContact:', newContact.timeOfContact); + const prepopulate = usePrepopulateMappings ? populateHrmContactFormFromTaskByMappings : populateHrmContactFormFromTaskByKeys; @@ -245,21 +247,52 @@ export const handleEvent = async ( const savedTimeOfContactDate = parseISO(savedTimeOfContactString); console.info(`Created HRM contact with id ${id} for task ${taskSid}`); - 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), + await patchTaskAttributes(accountSid, taskSid, currentTaskAttributes => ({ + ...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); + })); + + if (channel === channelTypes.VOICEMAIL) { + 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( + `Conversation media result ${conversationMediaResult.status} ${isOk(conversationMediaResult) ? JSON.stringify(conversationMediaResult.data) : JSON.stringify(conversationMediaResult.error)}`, + ); + } + } }; registerTaskRouterEventHandler([RESERVATION_ACCEPTED], handleEvent); diff --git a/lambdas/account-scoped/src/hrm/sanitizeIdentifier.ts b/lambdas/account-scoped/src/hrm/sanitizeIdentifier.ts index ee5f0073d8..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 @@ -30,6 +31,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], @@ -67,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/router.ts b/lambdas/account-scoped/src/router.ts index a93921818a..31583afadc 100644 --- a/lambdas/account-scoped/src/router.ts +++ b/lambdas/account-scoped/src/router.ts @@ -84,6 +84,7 @@ import { triggerPostStudioFlowHandler } from './studioFlow/postStudioFlowTaskRou import { randomOptionSelectorHandler } from './randomOptionSelector'; import { isSkilledWorkerAvailableHandler } from './worker/isSkilledWorkerAvailable'; import { filterCountryOrVoIPHandler } from './voice/filterCountryOrVoIP'; +import { recordingCompleteCallback } from './voicemail/recordingCompleteCallback'; /** * Super simple router sufficient for directly ported Twilio Serverless functions @@ -390,6 +391,10 @@ const ACCOUNTSID_ROUTES: Record< requestPipeline: [validateRequestMethod('POST'), validateWebhookRequest], handler: sendMessageAndRunJanitorHandler, }), + 'voicemail/recordingCompleteCallback': newRoute({ + requestPipeline: [validateRequestMethod('POST'), validateWebhookRequest], + handler: recordingCompleteCallback, + }), issueSyncToken: newRoute({ requestPipeline: [ validateRequestMethod('POST'), diff --git a/lambdas/account-scoped/src/voicemail/recordingCompleteCallback.ts b/lambdas/account-scoped/src/voicemail/recordingCompleteCallback.ts new file mode 100644 index 0000000000..644ce2e526 --- /dev/null +++ b/lambdas/account-scoped/src/voicemail/recordingCompleteCallback.ts @@ -0,0 +1,101 @@ +/** + * 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 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 { newMissingParameterResult } from '../httpErrors'; + +const DEFAULT_MAX_CALLBACK_ATTEMPTS = 3; + +export type RecordingCompleteCallbackRequestBody = { + from: string; + callSid: CallSid; + recordingSid: RecordingSid; + voicemailWorkflowSid: string; + routingAttributes?: string; + maxCallbackAttempts?: number; +}; + +export const recordingCompleteCallback: AccountScopedHandler = async ( + { body }, + accountSid, +): Promise> => { + console.debug('recordingCompleteCallback body', JSON.stringify(body, null, 2)); + 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 { + 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 + .workspaces(workspaceSid) + .tasks.create({ + timeout: 604800, // 7 days + attributes: JSON.stringify({ + ...(body.routingAttributes ? JSON.parse(body.routingAttributes) : {}), + receivedTime: receivedTime.toISOString(), + callbackAttemptsMade: 0, + maxCallbackAttempts: maxCallbackAttempts ?? DEFAULT_MAX_CALLBACK_ATTEMPTS, + callSid, + from, + name: from, + channelType: channelTypes.VOICEMAIL, + customChannelType: channelTypes.VOICEMAIL, + ignoreAgent: '', + transferTargetType: '', + }), + workflowSid: body.voicemailWorkflowSid, + // TODO: factor out channel types into an enum + 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/createHrmContactTaskRouterListener.test.ts b/lambdas/account-scoped/tests/unit/hrm/createHrmContactTaskRouterListener.test.ts index 7f6864d43b..bda8a84deb 100644 --- a/lambdas/account-scoped/tests/unit/hrm/createHrmContactTaskRouterListener.test.ts +++ b/lambdas/account-scoped/tests/unit/hrm/createHrmContactTaskRouterListener.test.ts @@ -23,6 +23,8 @@ 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 { getExternalRecordingS3Location } from '../../../src/conversation/getExternalRecordingS3Location'; import { TEST_ACCOUNT_SID, TEST_CONTACT_ID, @@ -32,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; @@ -52,6 +54,21 @@ const mockPopulateHrmContactFormFromTask = typeof populateHrmContactFormFromTaskByKeys >; +jest.mock('../../../src/task/patchTaskAttributes', () => ({ + patchTaskAttributes: jest.fn(), +})); +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 => @@ -144,6 +161,10 @@ describe('handleEvent', () => { id: TEST_CONTACT_ID, }), ); + mockPatchTaskAttributes.mockResolvedValue(newOk(undefined)); + mockGetExternalRecordingS3Location.mockResolvedValue( + newOk({ recordingSid: 'REtest', key: 'voice-recordings/ACut/REtest', bucket: 'test-bucket' }), + ); }); test('offline contact task - does nothing', async () => { @@ -178,6 +199,75 @@ 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(), + }); + }); + + 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/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/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); + } + }); +}); 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": { 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' 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..38fe96aa43 100644 --- a/lambdas/packages/twilio-types/src/index.ts +++ b/lambdas/packages/twilio-types/src/index.ts @@ -35,9 +35,11 @@ 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 /^AC[0-9a-zA-Z_]+$/.test(value); +export * from './channelType'; export * from './instagram'; 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 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, 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/channels/colors.ts b/plugin-hrm-form/src/channels/colors.ts index d597f2c5f1..4ba2be7282 100644 --- a/plugin-hrm-form/src/channels/colors.ts +++ b/plugin-hrm-form/src/channels/colors.ts @@ -43,6 +43,7 @@ const whatsappColor = mainChannelColor(DefaultTaskChannels.ChatWhatsApp); const telegramColor = '#1DA1F2'; const instagramColor = '#833AB4'; const lineColor = '#00C300'; +const voicemailColor = voiceColor; export const colors: ChannelColors = { voice: voiceColor, @@ -53,4 +54,5 @@ export const colors: ChannelColors = { telegram: telegramColor, instagram: instagramColor, line: lineColor, + voicemail: voicemailColor, }; diff --git a/plugin-hrm-form/src/components/case/timeline/TimelineIcon.tsx b/plugin-hrm-form/src/components/case/timeline/TimelineIcon.tsx index 1c2527a046..82d7221859 100644 --- a/plugin-hrm-form/src/components/case/timeline/TimelineIcon.tsx +++ b/plugin-hrm-form/src/components/case/timeline/TimelineIcon.tsx @@ -31,6 +31,7 @@ import WhatsappIcon from '../../common/icons/WhatsappIcon'; import FacebookIcon from '../../common/icons/FacebookIcon'; import SmsIcon from '../../common/icons/SmsIcon'; import CallIcon from '../../common/icons/CallIcon'; +import VoicemailIcon from '../../common/icons/VoicemailIcon'; import { colors } from '../../../channels/colors'; export type IconType = ChannelTypes | 'note' | 'referral'; @@ -55,6 +56,8 @@ export const getIcon = (type: IconType, size: string = '24px') => { 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/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; } 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..5216e0f7ef --- /dev/null +++ b/plugin-hrm-form/src/voicemail/VoicemailTaskPanel.tsx @@ -0,0 +1,68 @@ +/** + * 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 { 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/lambdas/account-scoped/src/customChannels/aseloCustomChannels.ts b/plugin-hrm-form/src/voicemail/setUpVoicemailComponents.tsx similarity index 54% rename from lambdas/account-scoped/src/customChannels/aseloCustomChannels.ts rename to plugin-hrm-form/src/voicemail/setUpVoicemailComponents.tsx index bf54813c99..f12627165a 100644 --- a/lambdas/account-scoped/src/customChannels/aseloCustomChannels.ts +++ b/plugin-hrm-form/src/voicemail/setUpVoicemailComponents.tsx @@ -14,12 +14,20 @@ * along with this program. If not, see https://www.gnu.org/licenses/. */ -export enum AseloCustomChannel { - Instagram = 'instagram', - Line = 'line', - Modica = 'modica', - Telegram = 'telegram', -} +import * as Flex from '@twilio/flex-ui'; +import React from 'react'; -export const isAseloCustomChannel = (channelType?: string): boolean => - Object.values(AseloCustomChannel).includes(channelType as AseloCustomChannel); +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, + }); +};