diff --git a/lambdas/account-scoped/src/conversation/deleteClosedConversationHandler.ts b/lambdas/account-scoped/src/conversation/deleteClosedConversationHandler.ts new file mode 100644 index 0000000000..fca4485b19 --- /dev/null +++ b/lambdas/account-scoped/src/conversation/deleteClosedConversationHandler.ts @@ -0,0 +1,95 @@ +/** + * Copyright (C) 2021-2025 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 { + registerServiceScopedConversationEventHandler, + ServiceScopedConversationEventHandler, +} from './serviceScopedConversationEventHandler'; +import type { AccountSID } from '@tech-matters/twilio-types'; +import { Twilio } from 'twilio'; +import { + CHANNEL_UPDATED, + ChannelUpdatedEvent, + ConversationStateUpdatedEvent, +} from './eventTypes'; +import { retrieveServiceConfigurationAttributes } from '../configuration/aseloConfiguration'; +import { getChatServiceSid } from '@tech-matters/twilio-configuration'; + +const deleteClosedConversationEventHandler: ServiceScopedConversationEventHandler = + async ( + { + StateTo: stateTo, + StateFrom: stateFrom, + ConversationSid: conversationSid, + }: ConversationStateUpdatedEvent, + accountSid: AccountSID, + client: Twilio, + ): Promise => { + const serviceConfigAttributes = await retrieveServiceConfigurationAttributes(client); + + const zeroTranscriptRetention = Boolean( + serviceConfigAttributes.enforceZeroTranscriptRetention || 'false', + ); + if (zeroTranscriptRetention && stateTo === 'closed') { + console.debug( + `Conversation state for ${conversationSid} updated from ${stateFrom} to ${stateTo} and account ${accountSid} has a zero transcript retention policy, deleting conversation`, + ); + await client.conversations.v1.conversations + .get(conversationSid) + .remove({ xTwilioWebhookEnabled: 'true' }); + console.debug( + `Deleted ${conversationSid} when it's state was updated from ${stateFrom} to ${stateTo} because account ${accountSid} has a zero transcript retention policy.`, + ); + } + }; + +registerServiceScopedConversationEventHandler( + ['onConversationStateUpdated'], + deleteClosedConversationEventHandler, +); + +const deleteInactiveChatChannelEventHandler: ServiceScopedConversationEventHandler = + async ( + { Attributes: attributesJson, ChannelSid: channelSid }: ChannelUpdatedEvent, + accountSid: AccountSID, + client: Twilio, + ): Promise => { + const serviceConfigAttributes = await retrieveServiceConfigurationAttributes(client); + + const zeroTranscriptRetention = Boolean( + serviceConfigAttributes.enforceZeroTranscriptRetention || 'false', + ); + if (zeroTranscriptRetention) { + const { status } = JSON.parse(attributesJson || '{}'); + if (status === 'INACTIVE') { + console.debug( + `Status attribute for ${channelSid} set to INACTIVE and account ${accountSid} has a zero transcript retention policy, deleting conversation`, + ); + await client.chat.v2.services + .get(await getChatServiceSid(accountSid)) + .channels.get(channelSid) + .remove({ xTwilioWebhookEnabled: 'true' }); + console.debug( + `Deleted ${channelSid} when it's status attribute was set to INACTIVE because account ${accountSid} has a zero transcript retention policy.`, + ); + } + } + }; + +registerServiceScopedConversationEventHandler( + [CHANNEL_UPDATED], + deleteInactiveChatChannelEventHandler, +); diff --git a/lambdas/account-scoped/src/conversation/eventTypes.ts b/lambdas/account-scoped/src/conversation/eventTypes.ts new file mode 100644 index 0000000000..4162009d61 --- /dev/null +++ b/lambdas/account-scoped/src/conversation/eventTypes.ts @@ -0,0 +1,134 @@ +/** + * Copyright (C) 2021-2025 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 { ChatChannelSID, ConversationSID } from '@tech-matters/twilio-types'; + +/** + * Conversation Event Types: https://www.twilio.com/docs/taskrouter/api/event/reference#event-types + */ + +// Conversations +export const CONVERSATION_ADD = 'onConversationAdd'; // Fires when a new conversation is created. +export const CONVERSATION_REMOVE = 'onConversationRemove'; // Fires when a conversation is removed from the Service. +export const CONVERSATION_UPDATE = 'onConversationUpdate'; // Fires when any attribute of a conversation is changed. + +export const MESSAGE_ADD = 'onMessageAdd'; // Fires when a new message is posted to a conversation. +export const MESSAGE_REMOVE = 'onMessageRemove'; // Fires when a message is deleted from a conversation. +export const MESSAGE_UPDATE = 'onMessageUpdate'; // Fires when a posted message's body or any attribute is changed. + +export const PARTICIPANT_ADD = 'onParticipantAdd'; // Fires when a Participant has joined a Conversation as a Member. +export const PARTICIPANT_REMOVE = 'onParticipantRemove'; // Fires when a User is removed from the set of Conversation Members. +export const PARTICIPANT_UPDATE = 'onParticipantUpdate'; // Fires when any configurable attribute of a User is changed. Will not be fired for reachability events. + +export const CONVERSATION_ADDED = 'onConversationAdded'; // Fires when a new conversation is created. +export const CONVERSATION_REMOVED = 'onConversationRemoved'; // Fires when a conversation is removed from the Service. +export const CONVERSATION_UPDATED = 'onConversationUpdated'; // Fires when any attribute of a conversation is changed. +export const CONVERSATION_STATE_UPDATED = 'onConversationStateUpdated'; // Fires when the state of a Conversation is updated, e.g., from "active" to "inactive" + +export const MESSAGE_ADDED = 'onMessageAdded'; // Fires when a new message is posted to a conversation. +export const MESSAGE_REMOVED = 'onMessageRemoved'; // Fires when a message is deleted from a conversation. +export const MESSAGE_UPDATED = 'onMessageUpdated'; // Fires when a posted message's body or any attribute is changed. + +export const PARTICIPANT_ADDED = 'onParticipantAdded'; // Fires when a Participant has joined a Conversation as a Member. +export const PARTICIPANT_REMOVED = 'onParticipantRemoved'; // Fires when a User is removed from the set of Conversation Members. +export const PARTICIPANT_UPDATED = 'onParticipantUpdated'; // Fires when any configurable attribute of a User is changed. Will not be fired for reachability events. + +export const DELIVERY_UPDATED = 'onDeliveryUpdated'; // Fires when delivery receipt status is updated + +// Programmable Chat +export const MESSAGE_SEND = 'onMessageSend'; // Sending a Message +// export const MESSAGE_UPDATE = 'onMessageUpdate'; // Editing Message Body / Attributes +// export const MESSAGE_REMOVE = 'onMessageRemove'; // Deleting Message +export const MEDIA_MESSAGE_SEND = 'onMediaMessageSend'; // Sending a Media Message +export const CHANNEL_ADD = 'onChannelAdd'; // Creating a Channel +export const CHANNEL_UPDATE = 'onChannelUpdate'; // Edit Channel Properties +export const CHANNEL_UPDATED = 'onChannelUpdated'; // Edited Channel Properties +export const CHANNEL_DESTROY = 'onChannelDestroy'; // Deleting Channel / Destroying Channel +export const MEMBER_ADD = 'onMemberAdd'; // Joining Channel / Channel Member being Added +export const MEMBER_UPDATE = 'onMemberUpdate'; // Channel Member to be Updated +export const MEMBER_REMOVE = 'onMemberRemove'; // Channel Member to be Removed / Channel Member Leaving +export const USER_UPDATE = 'onUserUpdate'; // User to be Updated + +export const conversationEventTypes = { + CONVERSATION_ADD, + CONVERSATION_REMOVE, + CONVERSATION_UPDATE, + + MESSAGE_ADD, + MESSAGE_REMOVE, + MESSAGE_UPDATE, + + PARTICIPANT_ADD, + PARTICIPANT_REMOVE, + PARTICIPANT_UPDATE, + + CONVERSATION_ADDED, + CONVERSATION_REMOVED, + CONVERSATION_UPDATED, + CONVERSATION_STATE_UPDATED, + + MESSAGE_ADDED, + MESSAGE_REMOVED, + MESSAGE_UPDATED, + + PARTICIPANT_ADDED, + PARTICIPANT_REMOVED, + PARTICIPANT_UPDATED, + + DELIVERY_UPDATED, +} as const; + +export type ConversationEvent = + (typeof conversationEventTypes)[keyof typeof conversationEventTypes]; + +export const programmableChatEventTypes = { + MESSAGE_SEND, + MESSAGE_UPDATE, + MESSAGE_REMOVE, + MEDIA_MESSAGE_SEND, + CHANNEL_ADD, + CHANNEL_UPDATE, + CHANNEL_UPDATED, + CHANNEL_DESTROY, + MEMBER_ADD, + MEMBER_UPDATE, + MEMBER_REMOVE, + USER_UPDATE, +} as const; + +export type ProgrammableChatEvent = + (typeof programmableChatEventTypes)[keyof typeof programmableChatEventTypes]; + +export type ConversationStateUpdatedEvent = { + EventType: 'onConversationStateUpdated'; + ChatServiceSid: string; + StateUpdated: string; // ISO8601 time Modification date of the state + StateFrom: 'active' | 'inactive' | 'closed'; + StateTo: 'active' | 'inactive' | 'closed'; + ConversationSid: ConversationSID; + Reason: 'API' | 'TIMER' | 'EVENT'; + MessagingServiceSid: string; +}; + +export type ChannelUpdatedEvent = { + EventType: 'onChannelUpdate'; + ChannelSid: ChatChannelSID; + Attributes?: string; + DateCreated: string; // The date of creation of the channel + CreatedBy: string; + FriendlyName?: string; + UniqueName?: string; +}; diff --git a/lambdas/account-scoped/src/conversation/index.ts b/lambdas/account-scoped/src/conversation/index.ts new file mode 100644 index 0000000000..1b043489bb --- /dev/null +++ b/lambdas/account-scoped/src/conversation/index.ts @@ -0,0 +1,20 @@ +/** + * Copyright (C) 2021-2025 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 './unsupportedMediaErrorConversationHandler'; +import './deleteClosedConversationHandler'; + +export { handleConversationEvent } from './serviceScopedConversationEventHandler'; +export { conversationEventTypes, ConversationEvent } from './eventTypes'; diff --git a/lambdas/account-scoped/src/conversation/serviceScopedConversationEventHandler.ts b/lambdas/account-scoped/src/conversation/serviceScopedConversationEventHandler.ts new file mode 100644 index 0000000000..60498fe61c --- /dev/null +++ b/lambdas/account-scoped/src/conversation/serviceScopedConversationEventHandler.ts @@ -0,0 +1,63 @@ +/** + * Copyright (C) 2021-2025 Technology Matters + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +import { AccountScopedHandler, HttpRequest } from '../httpTypes'; +import { AccountSID } from '@tech-matters/twilio-types'; +import twilio from 'twilio'; +import { getTwilioClient } from '@tech-matters/twilio-configuration'; +import { newOk } from '../Result'; +import { ConversationEvent, ProgrammableChatEvent } from './eventTypes'; + +export type ServiceScopedConversationEventHandler = ( + event: any, + accountSid: AccountSID, + twilioClient: twilio.Twilio, +) => Promise; + +const eventHandlers: Record = {}; + +export const registerServiceScopedConversationEventHandler = ( + eventTypes: (ConversationEvent | ProgrammableChatEvent)[], + handler: ServiceScopedConversationEventHandler, +) => { + for (const eventType of eventTypes) { + if (!eventHandlers[eventType]) { + eventHandlers[eventType] = []; + } + eventHandlers[eventType].push(handler); + } +}; + +export const handleConversationEvent: AccountScopedHandler = async ( + { body: event }: HttpRequest, + accountSid: AccountSID, +) => { + console.info(`===== Service Conversation Listener (event: ${event.EventType})=====`); + + const handlers = eventHandlers[event.EventType] ?? []; + console.info( + `Handling conversation / programmable chat event: ${event.EventType} for account: ${accountSid} - executing ${handlers.length} registered handlers.`, + ); + await Promise.all( + handlers.map(async handler => + handler(event, accountSid, await getTwilioClient(accountSid)), + ), + ); + console.debug( + `Successfully executed ${handlers.length} registered handlers for conversation / programmable chat event: ${event.EventType} for account: ${accountSid}.`, + ); + return newOk({}); +}; diff --git a/lambdas/account-scoped/src/conversation/unsupportedMediaErrorConversationHandler.ts b/lambdas/account-scoped/src/conversation/unsupportedMediaErrorConversationHandler.ts new file mode 100644 index 0000000000..c16fb6cfcc --- /dev/null +++ b/lambdas/account-scoped/src/conversation/unsupportedMediaErrorConversationHandler.ts @@ -0,0 +1,94 @@ +/** + * Copyright (C) 2021-2025 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 { + registerServiceScopedConversationEventHandler, + ServiceScopedConversationEventHandler, +} from './serviceScopedConversationEventHandler'; +import type { AccountSID, ConversationSID } from '@tech-matters/twilio-types'; +import { Twilio } from 'twilio'; +import { getServerlessBaseUrl } from '@tech-matters/twilio-configuration'; +import { MESSAGE_ADDED } from './eventTypes'; +import { retrieveServiceConfigurationAttributes } from '../configuration/aseloConfiguration'; + +type OnMessageAddedEvent = { + EventType: 'onMessageAdded'; + Body?: string; + ConversationSid: ConversationSID; + Media?: Record; + DateCreated: Date; +}; + +export type Event = OnMessageAddedEvent; + +const FALLBACK_ERROR_MESSAGE = 'Unsupported message type.'; +const ERROR_MESSAGE_TRANSLATION_KEY = 'UnsupportedMediaErrorMsg'; + +const unsupportedMediaErrorConversationHandler: ServiceScopedConversationEventHandler = + async ( + event: OnMessageAddedEvent, + accountSid: AccountSID, + client: Twilio, + ): Promise => { + const { Body: body, Media: media, ConversationSid: conversationSid } = event; + + /* Valid message will have either a body/media. A message with no + body or media implies that there was an error sending such message + */ + if (!body && !media) { + console.debug('Message has no text body or media, sending error.', conversationSid); + let messageText = FALLBACK_ERROR_MESSAGE; + + const serviceConfigAttributes = + await retrieveServiceConfigurationAttributes(client); + const helplineLanguage = serviceConfigAttributes.helplineLanguage ?? 'en-US'; + + console.debug( + 'Helpline language to send error message: ', + helplineLanguage, + conversationSid, + ); + if (helplineLanguage) { + try { + const response = await fetch( + `https://${await getServerlessBaseUrl(accountSid)}/translations/${helplineLanguage}/messages.json`, + ); + const translation = (await response.json()) as Record; + const { [ERROR_MESSAGE_TRANSLATION_KEY]: translatedMessage } = translation; + + console.debug('Translated error message: ', translatedMessage, conversationSid); + messageText = translatedMessage || messageText; + } catch { + console.warn( + `Couldn't retrieve ${ERROR_MESSAGE_TRANSLATION_KEY} message translation for ${helplineLanguage}`, + conversationSid, + ); + } + } + await client.conversations.v1.conversations.get(conversationSid).messages.create({ + body: messageText, + author: 'Bot', + xTwilioWebhookEnabled: 'true', + }); + + console.info('Sent error message: ', messageText, conversationSid); + } + }; + +registerServiceScopedConversationEventHandler( + [MESSAGE_ADDED], + unsupportedMediaErrorConversationHandler, +); diff --git a/lambdas/account-scoped/src/router.ts b/lambdas/account-scoped/src/router.ts index c7b7490792..74656f2cc2 100644 --- a/lambdas/account-scoped/src/router.ts +++ b/lambdas/account-scoped/src/router.ts @@ -39,6 +39,7 @@ import { conferenceStatusCallbackHandler } from './conference/conferenceStatusCa import './conference/stopRecordingWhenLastAgentLeaves'; import { instagramToFlexHandler } from './customChannels/instagram/instagramToFlex'; import { flexToInstagramHandler } from './customChannels/instagram/flexToInstagram'; +import { handleConversationEvent } from './conversation'; /** * Super simple router sufficient for directly ported Twilio Serverless functions @@ -97,6 +98,10 @@ const ROUTES: Record = { requestPipeline: [validateWebhookRequest], handler: participantStatusCallbackHandler, }, + 'conversations/serviceScopedConversationEventHandler': { + requestPipeline: [validateWebhookRequest], + handler: handleConversationEvent, + }, 'customChannels/instagram/instagramToFlex': { requestPipeline: [], handler: instagramToFlexHandler, diff --git a/plugin-hrm-form/src/___tests__/services/ContactService.test.ts b/plugin-hrm-form/src/___tests__/services/ContactService.test.ts index 2f2385997e..2bcbf94658 100644 --- a/plugin-hrm-form/src/___tests__/services/ContactService.test.ts +++ b/plugin-hrm-form/src/___tests__/services/ContactService.test.ts @@ -20,7 +20,12 @@ import { TaskHelper } from '@twilio/flex-ui'; import { mockLocalFetchDefinitions } from '../mockFetchDefinitions'; import { baseMockConfig as mockBaseConfig, mockGetDefinitionsResponse } from '../mockGetConfig'; -import { finalizeContact, handleTwilioTask, saveContact, updateContactInHrm } from '../../services/ContactService'; +import { + finalizeContact, + determineConversationMedia, + saveContact, + updateContactInHrm, +} from '../../services/ContactService'; import { channelTypes } from '../../states/DomainConstants'; import { getDefinitionVersions, getHrmConfig } from '../../hrmConfig'; import { VALID_EMPTY_CONTACT, VALID_EMPTY_METADATA } from '../testContacts'; @@ -377,7 +382,7 @@ describe('handleTwilioTask() (externalRecording)', () => { }, }; - const result = await handleTwilioTask(task); + const result = await determineConversationMedia(task); expect(result).toStrictEqual({ conversationMedia: [ { @@ -413,7 +418,7 @@ describe('handleTwilioTask() (externalRecording)', () => { attributes: {}, }; - const returnData = await handleTwilioTask(task); + const returnData = await determineConversationMedia(task); expect(returnData).toStrictEqual({ conversationMedia: [{ storeType: 'twilio', storeTypeSpecificData: { reservationSid: undefined } }], }); diff --git a/plugin-hrm-form/src/hrmConfig.ts b/plugin-hrm-form/src/hrmConfig.ts index e736e4bc46..ee7d72931b 100644 --- a/plugin-hrm-form/src/hrmConfig.ts +++ b/plugin-hrm-form/src/hrmConfig.ts @@ -81,6 +81,7 @@ const readConfig = () => { enableExternalRecordings, enableUnmaskingCalls, hideAddToNewCaseButton, + enforceZeroTranscriptRetention, } = { // Deprecated, remove when service configurations changes have applied 2025-09-30 ...manager.serviceConfiguration.attributes.config_flags, @@ -140,6 +141,7 @@ const readConfig = () => { permissionConfig, contactsWaitingChannels, externalRecordingsEnabled, + enforceZeroTranscriptRetention, helplineCode, environment, docsBucket, diff --git a/plugin-hrm-form/src/services/ContactService.ts b/plugin-hrm-form/src/services/ContactService.ts index e4ecf825c9..7971357ead 100644 --- a/plugin-hrm-form/src/services/ContactService.ts +++ b/plugin-hrm-form/src/services/ContactService.ts @@ -21,14 +21,7 @@ import { isNonDataCallType } from '../states/validationRules'; import { getQueryParams } from './PaginationParams'; import { fetchHrmApi } from './fetchHrmApi'; import { getAseloFeatureFlags, getDefinitionVersions, getHrmConfig } from '../hrmConfig'; -import { - Contact, - ConversationMedia, - isOfflineContact, - isOfflineContactTask, - isTwilioTask, - OfflineContactTask, -} from '../types/types'; +import { Contact, ConversationMedia, isOfflineContactTask, isTwilioTask, OfflineContactTask } from '../types/types'; import { saveContactToExternalBackend } from '../dualWrite'; import { getNumberFromTask } from '../utils/task'; import { @@ -81,7 +74,7 @@ type HandleTwilioTaskResponse = { externalRecordingInfo?: ExternalRecordingInfoSuccess; }; -export const handleTwilioTask = async ( +export const determineConversationMedia = async ( task, contact?: Contact, reservationSid?: string | undefined, @@ -93,14 +86,14 @@ export const handleTwilioTask = async ( if (!isTwilioTask(task)) { return returnData; } - + const { enforceZeroTranscriptRetention } = getHrmConfig(); const finalReservationSid = reservationSid ?? task.sid; const isChatTask = TaskHelper.isChatBasedTask(task) || (contact && isChatChannel(contact.channel)); const isVoiceTask = TaskHelper.isCallTask(task) || (contact && isVoiceChannel(contact.channel)); - const isCallOrChatTask = isChatTask || isVoiceTask; + const isCallOrRetainedChatTask = (isChatTask && !enforceZeroTranscriptRetention) || isVoiceTask; try { - if (isChatTask) { + if (isChatTask && !enforceZeroTranscriptRetention) { returnData.conversationMedia.push({ storeType: 'S3', storeTypeSpecificData: { @@ -111,7 +104,7 @@ export const handleTwilioTask = async ( } // Store reservation sid to use Twilio insights overlay (recordings/transcript) - if (isCallOrChatTask) { + if (isCallOrRetainedChatTask) { returnData.conversationMedia.push({ storeType: 'twilio', storeTypeSpecificData: { @@ -265,7 +258,7 @@ export const finalizeContact = async ( reservationSid?: string | undefined, ): Promise => { try { - const twilioTaskResult = await handleTwilioTask(task, contact, reservationSid); + const twilioTaskResult = await determineConversationMedia(task, contact, reservationSid); await saveConversationMedia(contact.id, twilioTaskResult.conversationMedia); return await updateContactInHrm(contact.id, {}, true); } catch (error) { diff --git a/twilio-iac/helplines/usch/staging.hcl b/twilio-iac/helplines/usch/staging.hcl index 5cde94fce5..908b6c247f 100644 --- a/twilio-iac/helplines/usch/staging.hcl +++ b/twilio-iac/helplines/usch/staging.hcl @@ -122,5 +122,7 @@ locals { forward_number = "+123" recording_url = "https://.mp3" } + + hrm_transcript_retention_days_override = 7 } } \ No newline at end of file