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..e291f2d8d6 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,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,6 +248,44 @@ 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.status} ${isOk(conversationMediaResult) ? JSON.stringify(conversationMediaResult.data) : JSON.stringify(conversationMediaResult.error)}`, + ); + } + } + const taskContext = client.taskrouter.v1.workspaces .get(twilioWorkspaceSid) .tasks.get(taskSid); 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/router.ts b/lambdas/account-scoped/src/router.ts index a93921818a..51de151051 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: [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..14bad2e6fa --- /dev/null +++ b/lambdas/account-scoped/src/voicemail/recordingCompleteCallback.ts @@ -0,0 +1,63 @@ +/** + * 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 { AccountScopedHandler, HttpError } from '../httpTypes'; +import { newOk, Result } from '../Result'; + +export type RecordingCompleteCallbackRequestBody = { + callFrom: string; +}; + +export const recordingCompleteCallback: AccountScopedHandler = async ( + { body }, + accountSid, +): Promise> => { + console.debug('recordingCompleteCallback body', JSON.stringify(body, null, 2)); + // const { callFrom } = body as RecordingCompleteCallbackRequestBody; + + // if (!callFrom) { + // return newErr({ + // message: 'callFrom parameter is missing', + // error: { statusCode: 400 }, + // }); + // } + + 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: JSON.stringify({ + ...(body.routingAttributes ? JSON.parse(body.routingAttributes) : {}), + isVoicemail: true, + callSid: body.callSid, + from: body.from, + name: body.from, + channelType: 'voicemail', + customChannelType: 'voicemail', + ignoreAgent: '', + transferTargetType: '', + }), + workflowSid: body.voicemailWorkflowSid, + // TODO: factor out channel types into an enum + taskChannel: 'voicemail', + }); + + return newOk({ voicemailTask }); +}; 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/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/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..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, + }); +};