diff --git a/lambdas/account-scoped/src/conversation/chatChannelJanitor.ts b/lambdas/account-scoped/src/conversation/chatChannelJanitor.ts index 267b75f68c..fe889674fe 100644 --- a/lambdas/account-scoped/src/conversation/chatChannelJanitor.ts +++ b/lambdas/account-scoped/src/conversation/chatChannelJanitor.ts @@ -97,12 +97,8 @@ const deactivateConversation = async ( const conversation = await client.conversations.v1.conversations .get(conversationSid) .fetch(); - const attributes = JSON.parse(conversation.attributes); if (conversation.state !== 'closed') { - if (attributes.proxySession) { - await deleteProxySession(accountSid, attributes.proxySession); - } console.info('Attempting to deactivate active conversation', conversationSid); const updated = await conversation.update({ state: 'closed', diff --git a/lambdas/account-scoped/src/conversation/createConversation.ts b/lambdas/account-scoped/src/conversation/createConversation.ts new file mode 100644 index 0000000000..2a4ddc5989 --- /dev/null +++ b/lambdas/account-scoped/src/conversation/createConversation.ts @@ -0,0 +1,107 @@ +/** + * 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/. + */ + +import { Twilio } from 'twilio'; +import { ConversationSID } from '@tech-matters/twilio-types'; +import { AseloCustomChannel } from '../customChannels/aseloCustomChannels'; + +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 + 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. + conversationFriendlyName: string; // A name for the Flex conversation (typically same as uniqueUserName) + twilioNumber: string; // The target Twilio number (usually have the shape :, e.g. telegram:1234567) + additionalConversationAttributes?: Record; // Any additional conversation attributes + testSessionId?: string; // A session identifier to identify the test run if this is part of an integration test. +}; +/** + * Creates a new Flex conversation in the provided Flex Flow and subscribes webhooks to it's events. + * Adds to the channel attributes the provided twilioNumber used for routing. + */ +export const createConversation = async ( + client: Twilio, + { + conversationFriendlyName, + channelType, + twilioNumber, + uniqueUserName, + senderScreenName, + onMessageAddedWebhookUrl, + studioFlowSid, + additionalConversationAttributes, + testSessionId, + }: CreateFlexConversationParams, +): Promise<{ conversationSid: ConversationSID; error?: Error }> => { + if (testSessionId) { + console.info( + 'testSessionId specified. All outgoing messages will be sent to the test API.', + ); + } + + const conversationInstance = await client.conversations.v1.conversations.create({ + xTwilioWebhookEnabled: 'true', + friendlyName: conversationFriendlyName, + uniqueName: `${channelType}/${uniqueUserName}/${Date.now()}`, + }); + const conversationSid = conversationInstance.sid as ConversationSID; + + try { + const conversationContext = + client.conversations.v1.conversations.get(conversationSid); + await conversationContext.participants.create({ + identity: uniqueUserName, + }); + const channelAttributes = JSON.parse((await conversationContext.fetch()).attributes); + + console.debug('channelAttributes prior to update', channelAttributes); + + await conversationContext.update({ + 'timers.closed': CONVERSATION_CLOSE_TIMEOUT, + state: 'active', + attributes: JSON.stringify({ + ...channelAttributes, + channel_type: channelType, + channelType, + senderScreenName, + twilioNumber, + testSessionId, + ...additionalConversationAttributes, + }), + }); + + await conversationContext.webhooks.create({ + target: 'studio', + 'configuration.flowSid': studioFlowSid, + 'configuration.filters': ['onMessageAdded'], + }); + if (onMessageAddedWebhookUrl) { + /* const onMessageAdded = */ + await conversationContext.webhooks.create({ + target: 'webhook', + 'configuration.method': 'POST', + 'configuration.url': onMessageAddedWebhookUrl, + 'configuration.filters': ['onMessageAdded'], + }); + } + } catch (err) { + return { conversationSid, error: err as Error }; + } + + return { conversationSid }; +}; diff --git a/lambdas/account-scoped/src/conversation/patchConversationAttributes.ts b/lambdas/account-scoped/src/conversation/patchConversationAttributes.ts new file mode 100644 index 0000000000..680079f5fe --- /dev/null +++ b/lambdas/account-scoped/src/conversation/patchConversationAttributes.ts @@ -0,0 +1,46 @@ +/** + * 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 { ConversationInstance } from 'twilio/lib/rest/conversations/v1/conversation'; +import { Twilio } from 'twilio'; +import { ConversationSID } from '@tech-matters/twilio-types'; + +// TODO: Add optimistic concurrency checks +/** + * + * @param client - Twilio client + * @param conversation - either the instance of the conversation which must have been fetched prior to calling, or the sid to fetch + * @param attributePatch - attributes to update. Will add these if they don't exist or overwrite them if they do. Setting attributes undefined will NOT remove them, there will be an attribute e + */ +export const patchConversationAttributes = async ( + client: Twilio, + conversation: ConversationInstance | ConversationSID, + attributePatch: Record, +) => { + let conversationInstance: ConversationInstance; + if (typeof conversation !== 'object') { + conversationInstance = await client.conversations.v1.conversations + .get(conversation) + .fetch(); + } else { + conversationInstance = conversation; + } + const conversationAttributes = JSON.parse(conversationInstance.attributes); + const patchedAttributes = { ...conversationAttributes, ...attributePatch }; + return client.conversations.v1.conversations + .get(conversationInstance.sid) + .update({ attributes: patchedAttributes }); +}; diff --git a/lambdas/account-scoped/src/customChannels/configuration.ts b/lambdas/account-scoped/src/customChannels/configuration.ts index 55f42a7c64..a0c50f9068 100644 --- a/lambdas/account-scoped/src/customChannels/configuration.ts +++ b/lambdas/account-scoped/src/customChannels/configuration.ts @@ -89,7 +89,7 @@ export const getFacebookAppSecret = (): Promise => export const getChannelStudioFlowSid = ( accountSid: AccountSID, - channelName: AseloCustomChannel, + channelName: AseloCustomChannel | 'web', ): 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 99ed9b8d10..7abfbd0ea5 100644 --- a/lambdas/account-scoped/src/customChannels/customChannelToFlex.ts +++ b/lambdas/account-scoped/src/customChannels/customChannelToFlex.ts @@ -17,9 +17,10 @@ import { AccountSID, ConversationSID } from '@tech-matters/twilio-types'; import { Twilio } from 'twilio'; import { getTwilioClient } from '@tech-matters/twilio-configuration'; -import { AseloCustomChannel } from './aseloCustomChannels'; - -const CONVERSATION_CLOSE_TIMEOUT = 'P3D'; // ISO 8601 duration format https://en.wikipedia.org/wiki/ISO_8601 +import { + createConversation, + CreateFlexConversationParams, +} from '../conversation/createConversation'; export const findExistingConversation = async ( client: Twilio, @@ -78,90 +79,6 @@ export const removeConversation = async ( export { AseloCustomChannel, isAseloCustomChannel } from './aseloCustomChannels'; -type CreateFlexConversationParams = { - studioFlowSid: string; - channelType: AseloCustomChannel; // 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) - onMessageSentWebhookUrl: string; // The url that must be used as the onMessageSent event webhook. - conversationFriendlyName: string; // A name for the Flex conversation (typically same as uniqueUserName) - testSessionId?: string; // A session identifier to identify the test run if this is part of an integration test. - twilioNumber: string; // The target Twilio number (usually have the shape :, e.g. telegram:1234567) -}; - -/** - * Creates a new Flex conversation in the provided Flex Flow and subscribes webhooks to it's events. - * Adds to the channel attributes the provided twilioNumber used for routing. - */ -const createConversation = async ( - client: Twilio, - { - conversationFriendlyName, - channelType, - twilioNumber, - uniqueUserName, - senderScreenName, - onMessageSentWebhookUrl, - studioFlowSid, - testSessionId, - }: CreateFlexConversationParams, -): Promise<{ conversationSid: ConversationSID; error?: Error }> => { - if (testSessionId) { - console.info( - 'testSessionId specified. All outgoing messages will be sent to the test API.', - ); - } - - const conversationInstance = await client.conversations.v1.conversations.create({ - xTwilioWebhookEnabled: 'true', - friendlyName: conversationFriendlyName, - uniqueName: `${channelType}/${uniqueUserName}/${Date.now()}`, - }); - const conversationSid = conversationInstance.sid as ConversationSID; - - try { - const conversationContext = - client.conversations.v1.conversations.get(conversationSid); - await conversationContext.participants.create({ - identity: uniqueUserName, - }); - const channelAttributes = JSON.parse((await conversationContext.fetch()).attributes); - - console.debug('channelAttributes prior to update', channelAttributes); - - await conversationContext.update({ - 'timers.closed': CONVERSATION_CLOSE_TIMEOUT, - state: 'active', - attributes: JSON.stringify({ - ...channelAttributes, - channel_type: channelType, - channelType, - senderScreenName, - twilioNumber, - testSessionId, - }), - }); - - await conversationContext.webhooks.create({ - target: 'studio', - 'configuration.flowSid': studioFlowSid, - 'configuration.filters': ['onMessageAdded'], - }); - - /* const onMessageAdded = */ - await conversationContext.webhooks.create({ - target: 'webhook', - 'configuration.method': 'POST', - 'configuration.url': onMessageSentWebhookUrl, - 'configuration.filters': ['onMessageAdded'], - }); - } catch (err) { - return { conversationSid, error: err as Error }; - } - - return { conversationSid }; -}; - type SendConversationMessageToFlexParams = Omit< CreateFlexConversationParams, 'twilioNumber' @@ -188,7 +105,7 @@ export const sendConversationMessageToFlex = async ( customTwilioNumber, uniqueUserName, senderScreenName, - onMessageSentWebhookUrl, + onMessageAddedWebhookUrl, messageText, messageAttributes = undefined, senderExternalId, @@ -216,7 +133,7 @@ export const sendConversationMessageToFlex = async ( twilioNumber, uniqueUserName, senderScreenName, - onMessageSentWebhookUrl, + onMessageAddedWebhookUrl, conversationFriendlyName, testSessionId, }, diff --git a/lambdas/account-scoped/src/customChannels/instagram/instagramToFlex.ts b/lambdas/account-scoped/src/customChannels/instagram/instagramToFlex.ts index cd9677a1bb..5231a99599 100644 --- a/lambdas/account-scoped/src/customChannels/instagram/instagramToFlex.ts +++ b/lambdas/account-scoped/src/customChannels/instagram/instagramToFlex.ts @@ -173,7 +173,7 @@ export const instagramToFlexHandler: AccountScopedHandler = async ( channelType, uniqueUserName, senderScreenName, - onMessageSentWebhookUrl, + onMessageAddedWebhookUrl: onMessageSentWebhookUrl, messageText, messageAttributes, senderExternalId, diff --git a/lambdas/account-scoped/src/customChannels/line/lineToFlex.ts b/lambdas/account-scoped/src/customChannels/line/lineToFlex.ts index 9269d531e0..a4cdc06871 100644 --- a/lambdas/account-scoped/src/customChannels/line/lineToFlex.ts +++ b/lambdas/account-scoped/src/customChannels/line/lineToFlex.ts @@ -151,7 +151,7 @@ export const lineToFlexHandler: AccountScopedHandler = async ( channelType, uniqueUserName, senderScreenName, - onMessageSentWebhookUrl, + onMessageAddedWebhookUrl: onMessageSentWebhookUrl, messageText, senderExternalId, customSubscribedExternalId: subscribedExternalId, diff --git a/lambdas/account-scoped/src/customChannels/modica/modicaToFlex.ts b/lambdas/account-scoped/src/customChannels/modica/modicaToFlex.ts index ee7f39476b..6721656caf 100644 --- a/lambdas/account-scoped/src/customChannels/modica/modicaToFlex.ts +++ b/lambdas/account-scoped/src/customChannels/modica/modicaToFlex.ts @@ -64,7 +64,7 @@ export const modicaToFlexHandler: AccountScopedHandler = async ( channelType, uniqueUserName, senderScreenName, - onMessageSentWebhookUrl, + onMessageAddedWebhookUrl: onMessageSentWebhookUrl, messageText, senderExternalId, customSubscribedExternalId: subscribedExternalId, diff --git a/lambdas/account-scoped/src/customChannels/telegram/telegramToFlex.ts b/lambdas/account-scoped/src/customChannels/telegram/telegramToFlex.ts index 6fab222242..19a18d0ee0 100644 --- a/lambdas/account-scoped/src/customChannels/telegram/telegramToFlex.ts +++ b/lambdas/account-scoped/src/customChannels/telegram/telegramToFlex.ts @@ -65,7 +65,7 @@ export const telegramToFlexHandler: AccountScopedHandler = async ( const chatFriendlyName = username || `${channelType}:${senderExternalId}`; const uniqueUserName = `${channelType}:${senderExternalId}`; const senderScreenName = firstName || username || 'child'; - const onMessageSentWebhookUrl = `${process.env.WEBHOOK_BASE_URL}/lambda/twilio/account-scoped/${accountSid}/customChannels/telegram/flexToTelegram?recipientId=${senderExternalId}`; + const onMessageAddedWebhookUrl = `${process.env.WEBHOOK_BASE_URL}/lambda/twilio/account-scoped/${accountSid}/customChannels/telegram/flexToTelegram?recipientId=${senderExternalId}`; const studioFlowSid = await getChannelStudioFlowSid( accountSid, AseloCustomChannel.Telegram, @@ -82,7 +82,7 @@ export const telegramToFlexHandler: AccountScopedHandler = async ( channelType, uniqueUserName, senderScreenName, - onMessageSentWebhookUrl, + onMessageAddedWebhookUrl, messageText, senderExternalId, conversationFriendlyName: chatFriendlyName, diff --git a/lambdas/account-scoped/src/hrm/sanitizeIdentifier.ts b/lambdas/account-scoped/src/hrm/sanitizeIdentifier.ts index 7b35f5bb13..6c222040df 100644 --- a/lambdas/account-scoped/src/hrm/sanitizeIdentifier.ts +++ b/lambdas/account-scoped/src/hrm/sanitizeIdentifier.ts @@ -155,14 +155,6 @@ export const sanitizeIdentifierFromTrigger = ({ }); } - if (channelType === 'web') { - console.error(`Channel type ${channelType} is not supported in conversations`); - return newErr({ - message: `Channel type ${channelType} is not supported in conversations`, - error: { type: 'unsupported-channel', channelType }, - }); - } - const transformed = applyTransformations({ channelType, identifier: trigger.conversation.Author, diff --git a/lambdas/account-scoped/src/webchatAuthentication/initWebchat.ts b/lambdas/account-scoped/src/webchatAuthentication/initWebchat.ts index a347595535..2de0ef55f9 100644 --- a/lambdas/account-scoped/src/webchatAuthentication/initWebchat.ts +++ b/lambdas/account-scoped/src/webchatAuthentication/initWebchat.ts @@ -20,38 +20,51 @@ import type { AccountSID, ConversationSID } from '@tech-matters/twilio-types'; import { isErr, newErr, newOk, Result } from '../Result'; import { createToken, TOKEN_TTL_IN_SECONDS } from './createToken'; +import { createConversation } from '../conversation/createConversation'; +import { getChannelStudioFlowSid } from '../customChannels/configuration'; const contactWebchatOrchestrator = async ({ - addressSid, + studioFlowSid, accountSid, formData, customerFriendlyName, + testSessionId, }: { accountSid: AccountSID; - addressSid: string; + studioFlowSid: string; formData: Record; customerFriendlyName: string; + testSessionId?: string; }): Promise< Result > => { - console.info('Calling Webchat Orchestrator'); + const senderId = `web:${crypto.randomUUID()}`; + console.info(`Creating new conversation via the API with sender ID: ${senderId}`); try { const client = await getTwilioClient(accountSid); - const orchestratorResponse = await client.flexApi.v2.webChannels.create({ - customerFriendlyName, - addressSid, - preEngagementData: JSON.stringify(formData), - uiVersion: process.env.WEBCHAT_VERSION || '1.0.0', - chatFriendlyName: 'Webchat widget', + const conversation = await createConversation(client, { + channelType: 'web', + conversationFriendlyName: customerFriendlyName, + senderScreenName: customerFriendlyName, + studioFlowSid, + testSessionId, + twilioNumber: `web:${accountSid}`, + uniqueUserName: senderId, + additionalConversationAttributes: { + pre_engagement_data: formData, + from: customerFriendlyName, + }, }); - console.info('Webchat Orchestrator successfully called', orchestratorResponse); - const { conversationSid, identity } = orchestratorResponse; + const { conversationSid } = conversation; + console.info( + `Created new conversation ${conversationSid} via the API with sender ID: ${senderId}`, + ); return newOk({ conversationSid: conversationSid as ConversationSID, - identity, + identity: senderId, }); } catch (err) { const bodyError = err instanceof Error ? err.message : String(err); @@ -106,7 +119,7 @@ export const initWebchatHandler: AccountScopedHandler = async (request, accountS const formData = JSON.parse(request.body?.PreEngagementData); const customerFriendlyName = - formData?.friendlyName || request.body?.CustomerFriendlyName || 'Customer'; + formData?.friendlyName || request.body?.CustomerFriendlyName || 'Anonymous'; let conversationSid: ConversationSID; let identity; @@ -114,7 +127,7 @@ export const initWebchatHandler: AccountScopedHandler = async (request, accountS // Hit Webchat Orchestration endpoint to generate conversation and get customer participant sid const result = await contactWebchatOrchestrator({ accountSid, - addressSid: 'IG1ba46f2d6828b42ddd363f5045138044', // Obvs needs to be SSM parameter + studioFlowSid: await getChannelStudioFlowSid(accountSid, 'web'), formData, customerFriendlyName, }); diff --git a/lambdas/account-scoped/tests/unit/conversation/chatChannelJanitor.test.ts b/lambdas/account-scoped/tests/unit/conversation/chatChannelJanitor.test.ts index 7375b6dfe5..65e966126a 100644 --- a/lambdas/account-scoped/tests/unit/conversation/chatChannelJanitor.test.ts +++ b/lambdas/account-scoped/tests/unit/conversation/chatChannelJanitor.test.ts @@ -99,7 +99,7 @@ describe('chatChannelJanitor', () => { }); describe('when conversationSid is provided', () => { - test('active conversation without proxy session - closes conversation', async () => { + test('active conversation - closes conversation', async () => { mockFetchConversation.mockResolvedValue({ state: 'active', attributes: JSON.stringify({}), @@ -117,28 +117,6 @@ describe('chatChannelJanitor', () => { expect(result.message).toContain(TEST_CONVERSATION_SID); }); - test('active conversation with proxy session - deletes proxy session then closes conversation', async () => { - mockFetchConversation.mockResolvedValue({ - state: 'active', - attributes: JSON.stringify({ proxySession: TEST_PROXY_SESSION }), - update: mockUpdateConversation, - }); - mockFetchProxySession.mockResolvedValue({ - remove: mockRemoveProxySession, - }); - - await chatChannelJanitor(TEST_ACCOUNT_SID, { - conversationSid: TEST_CONVERSATION_SID, - }); - - expect(mockFetchProxySession).toHaveBeenCalled(); - expect(mockRemoveProxySession).toHaveBeenCalled(); - expect(mockUpdateConversation).toHaveBeenCalledWith({ - state: 'closed', - xTwilioWebhookEnabled: 'true', - }); - }); - test('already closed conversation - skips update', async () => { mockFetchConversation.mockResolvedValue({ state: 'closed', diff --git a/lambdas/account-scoped/tests/unit/hrm/getProfileFlagsForIdentifier.test.ts b/lambdas/account-scoped/tests/unit/hrm/getProfileFlagsForIdentifier.test.ts index 7348ec7ebb..965b8b4fb9 100644 --- a/lambdas/account-scoped/tests/unit/hrm/getProfileFlagsForIdentifier.test.ts +++ b/lambdas/account-scoped/tests/unit/hrm/getProfileFlagsForIdentifier.test.ts @@ -336,7 +336,7 @@ describe('handleGetProfileFlagsForIdentifier', () => { expect(result.error.statusCode).toEqual(400); expect(mockGetFromInternalHrmEndpoint).not.toBeCalled(); }); - test('web conversations channel returns 400 error result', async () => { + test('web conversations channel returns 200 unmodified result', async () => { // Act const result = await handleGetProfileFlagsForIdentifier( newProfileFlagsForIdentifierRequest( @@ -345,14 +345,18 @@ describe('handleGetProfileFlagsForIdentifier', () => { TEST_ACCOUNT_SID, ); // Assert - if (isOk(result)) { + if (isErr(result)) { throw new AssertionError({ - message: 'Expected an error response', + message: 'Expected a successful response', actual: result, }); } - expect(result.error.statusCode).toEqual(400); - expect(mockGetFromInternalHrmEndpoint).not.toBeCalled(); + expect(result.data).toEqual({ flags: ['fish in microwave', 'too cheerful'] }); + expect(mockGetFromInternalHrmEndpoint).toHaveBeenCalledWith( + TEST_ACCOUNT_SID, + 'v1', + `profiles/identifier/speedy geraldine/flags`, + ); }); test('Invalid trigger returns 400 error result', async () => { diff --git a/lambdas/account-scoped/tests/unit/hrm/sanitizeIdentifier.test.ts b/lambdas/account-scoped/tests/unit/hrm/sanitizeIdentifier.test.ts index 10aacfb6c0..05a12e9767 100644 --- a/lambdas/account-scoped/tests/unit/hrm/sanitizeIdentifier.test.ts +++ b/lambdas/account-scoped/tests/unit/hrm/sanitizeIdentifier.test.ts @@ -456,11 +456,12 @@ describe('sanitizeIdentifierFromTrigger', () => { }, { channelType: 'web', - description: 'conversation - returns error (not supported)', + description: 'conversation - returns unmodified', trigger: { - conversation: { Author: 'not this!' }, + conversation: { Author: 'this!' }, }, - expectErr: true, + expected: 'this!', + expectErr: false, }, ]; each(testCases).test(