From dc009dd494f9751e7fa1210e6d16c98e03b908f0 Mon Sep 17 00:00:00 2001 From: Stephen Hand Date: Wed, 15 Jul 2026 14:37:20 +0100 Subject: [PATCH 01/53] WIP add config and basic SMS test methods --- e2e-tests/config.ts | 40 ++++++++++++++++++++++++++ e2e-tests/twilio/channels.ts | 9 ++++-- e2e-tests/twilio/sms.ts | 55 ++++++++++++++++++++++++++++++++++++ e2e-tests/twilio/worker.ts | 7 ----- 4 files changed, 101 insertions(+), 10 deletions(-) create mode 100644 e2e-tests/twilio/sms.ts diff --git a/e2e-tests/config.ts b/e2e-tests/config.ts index 6831eb2cee..d7ac704d4a 100644 --- a/e2e-tests/config.ts +++ b/e2e-tests/config.ts @@ -41,6 +41,8 @@ export type Config = { }; const helplineShortCode = process.env.HL?.toLocaleLowerCase() || 'e2e'; +// Account to initiate calls into the helpline under test from, for testing voice & SMS +const clientHelplineShortCode = process.env.HL?.toLocaleLowerCase() || helplineShortCode; const helplineEnv = process.env.HL_ENV?.toLocaleLowerCase() || 'local'; const shouldLoadFromSsm = process.env.LOAD_SSM_CONFIG && process.env.LOAD_SSM_CONFIG !== 'false'; @@ -135,6 +137,20 @@ const configOptions: ConfigOptions = { ssmPath: () => `/${localOverrideEnv}/twilio/${getConfigValue('twilioAccountSid')}/auth_token`, }, + // The twilio account sid and auth token are used to target a flex account + clientTwilioAccountSid: { + envKey: 'TWILIO_ACCOUNT_SID', + ssmPath: `/${localOverrideEnv}/twilio/${clientHelplineShortCode.toUpperCase()}/account_sid`, + default: () => getConfigValue('twilioAccountSid'), + }, + clientTwilioAuthToken: { + envKey: 'TWILIO_AUTH_TOKEN', + // Order is important here. We use a function so that we can reference the clientTwilioAccountSid config value above. + ssmPath: () => + `/${localOverrideEnv}/twilio/${getConfigValue('clientTwilioAccountSid')}/auth_token`, + default: () => getConfigValue('twilioAuthToken'), + }, + // Turn on debug mode. Possibly unused. debug: { envKey: 'DEBUG', @@ -180,6 +196,30 @@ const configOptions: ConfigOptions = { default: `https://assets-${localOverrideEnv}.tl.techmatters.org/aselo-webchat-react-app/${helplineShortCode}/?e2eTestMode=true`, }, + // This should match the number set up for the Voice studio flow on the helpline under test + voicePhoneNumber: { + envKey: 'VOICE_PHONE_NUMBER', + default: '', + }, + + // This should match the number set up on the clientTwilioAccountSid that can make outgoing calls + clientVoicePhoneNumber: { + envKey: 'CLIENT_VOICE_PHONE_NUMBER', + default: '', + }, + + // This should match the number set up for the SMS studio flow on the helpline under test + smsPhoneNumber: { + envKey: 'SMS_PHONE_NUMBER', + default: () => getConfigValue('voicePhoneNumber'), + }, + + // This should match the number set up on the clientTwilioAccountSid that can send outgoing SMS messages + clientSmsPhoneNumber: { + envKey: 'CLIENT_SMS_PHONE_NUMBER', + default: () => getConfigValue('clientVoicePhoneNumber'), + }, + // inLambda is used to determine if we are running in a lambda or not and set other config values accordingly inLambda: { envKey: 'TEST_IN_LAMBDA', diff --git a/e2e-tests/twilio/channels.ts b/e2e-tests/twilio/channels.ts index 005290357b..211a118155 100644 --- a/e2e-tests/twilio/channels.ts +++ b/e2e-tests/twilio/channels.ts @@ -39,11 +39,11 @@ export const deleteChatChannels = async (): Promise => { const client = twilio(accountSid, authToken); // List all chat services - const services = await client.chat.v2.services.list(); + const services = await client.conversations.v1.services.list(); for (const service of services) { // List all users in this chat service - const users = await client.chat.v2.services(service.sid).users.list(); + const users = await client.conversations.v1.services(service.sid).users.list(); console.log(`Found ${users.length} users in service ${service.sid}`); const matchingUser = users.find((user) => user.identity === encodedEmail); @@ -65,7 +65,10 @@ export const deleteChatChannels = async (): Promise => { for (const userChannel of userChannels) { console.log(`Removing chat channel ${userChannel.channelSid} from service ${service.sid}`); - await client.chat.v2.services(service.sid).channels(userChannel.channelSid).remove(); + await client.conversations.v1.services + .get(service.sid) + .conversations.get(userChannel.channelSid) + .remove(); } } }; diff --git a/e2e-tests/twilio/sms.ts b/e2e-tests/twilio/sms.ts new file mode 100644 index 0000000000..175922f361 --- /dev/null +++ b/e2e-tests/twilio/sms.ts @@ -0,0 +1,55 @@ +import { getConfigValue } from '../config'; +import twilio from 'twilio'; +import type { ConversationInstance } from 'twilio/lib/rest/conversations/v1/conversation'; +import { AssertionError } from 'node:assert'; + +let clientConversation: ConversationInstance; + +export const sendSmsToService = async (messageText: string) => { + const accountSid = getConfigValue('clientTwilioAccountSid') as string; + const authToken = getConfigValue('clientTwilioAuthToken') as string; + const from = getConfigValue('clientSmsPhoneNumber') as string; + const to = getConfigValue('smsPhoneNumber') as string; + + const client = twilio(accountSid, authToken); + if (!clientConversation) { + clientConversation = await client.conversations.v1.conversations.create({ + friendlyName: 'E2E test client conversation', + + uniqueName: `sms/${from}/${Date.now()}`, + }); + await clientConversation.participants().create({ + identity: from, + }); + } + await client.messages.create({ from, to, body: messageText }); + console.debug(`Sent SMS message to service: '${messageText}'`); +}; +export const sendSmsFromService = async (messageText: string) => { + const accountSid = getConfigValue('twilioAccountSid') as string; + const authToken = getConfigValue('twilioAuthToken') as string; + const from = getConfigValue('smsPhoneNumber') as string; + const to = getConfigValue('clientSmsPhoneNumber') as string; + + const client = twilio(accountSid, authToken); + await client.messages.create({ from, to, body: messageText }); + console.debug(`Sent SMS message from service: '${messageText}'`); +}; + +const MAX_CHECKS = 10; + +export const checkForMessageOnClient = async (messageText: string): Promise => { + if (!clientConversation) { + throw new AssertionError({ + message: + "You cannot verify incoming messages until you've sent one and created a client side conversation", + }); + } + for (let i = 0; i < MAX_CHECKS; i++) { + const messages = await clientConversation.messages().list(); + if (messages.find((m) => m.body === messageText)) { + return true; + } + } + return false; +}; diff --git a/e2e-tests/twilio/worker.ts b/e2e-tests/twilio/worker.ts index f93f22931a..bef984e6dd 100644 --- a/e2e-tests/twilio/worker.ts +++ b/e2e-tests/twilio/worker.ts @@ -36,10 +36,3 @@ export const getSidForWorker = async (friendlyName: string): Promise => { - return page.evaluate(() => { - const manager = (window as any).Twilio.Flex.Manager.getInstance(); - return manager.workerClient.sid; - }); -}; From 396ff369f029f4fc9567b62d3c30730b99e46e76 Mon Sep 17 00:00:00 2001 From: Stephen Hand Date: Wed, 15 Jul 2026 14:44:25 +0100 Subject: [PATCH 02/53] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- e2e-tests/config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/e2e-tests/config.ts b/e2e-tests/config.ts index d7ac704d4a..71b263e858 100644 --- a/e2e-tests/config.ts +++ b/e2e-tests/config.ts @@ -42,7 +42,7 @@ export type Config = { const helplineShortCode = process.env.HL?.toLocaleLowerCase() || 'e2e'; // Account to initiate calls into the helpline under test from, for testing voice & SMS -const clientHelplineShortCode = process.env.HL?.toLocaleLowerCase() || helplineShortCode; +const clientHelplineShortCode = process.env.CLIENT_HL?.toLocaleLowerCase() || helplineShortCode; const helplineEnv = process.env.HL_ENV?.toLocaleLowerCase() || 'local'; const shouldLoadFromSsm = process.env.LOAD_SSM_CONFIG && process.env.LOAD_SSM_CONFIG !== 'false'; From 88a9075414a39235f5f5df2151d38b4586a35ecd Mon Sep 17 00:00:00 2001 From: Stephen Hand Date: Wed, 15 Jul 2026 14:44:44 +0100 Subject: [PATCH 03/53] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- e2e-tests/twilio/sms.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/e2e-tests/twilio/sms.ts b/e2e-tests/twilio/sms.ts index 175922f361..bb2682d160 100644 --- a/e2e-tests/twilio/sms.ts +++ b/e2e-tests/twilio/sms.ts @@ -50,6 +50,7 @@ export const checkForMessageOnClient = async (messageText: string): Promise m.body === messageText)) { return true; } + await new Promise((resolve) => setTimeout(resolve, 1000)); } return false; }; From 30f7c09ece53823a8026f15b7ec59365f1f80063 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 15 Jul 2026 13:51:25 +0000 Subject: [PATCH 04/53] fix: use chat api for e2e channel cleanup lookup Co-authored-by: stephenhand <1694716+stephenhand@users.noreply.github.com> --- e2e-tests/twilio/channels.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/e2e-tests/twilio/channels.ts b/e2e-tests/twilio/channels.ts index 211a118155..f1ef09fe1c 100644 --- a/e2e-tests/twilio/channels.ts +++ b/e2e-tests/twilio/channels.ts @@ -39,11 +39,11 @@ export const deleteChatChannels = async (): Promise => { const client = twilio(accountSid, authToken); // List all chat services - const services = await client.conversations.v1.services.list(); + const services = await client.chat.v2.services.list(); for (const service of services) { // List all users in this chat service - const users = await client.conversations.v1.services(service.sid).users.list(); + const users = await client.chat.v2.services(service.sid).users.list(); console.log(`Found ${users.length} users in service ${service.sid}`); const matchingUser = users.find((user) => user.identity === encodedEmail); From 859eca5e9fd430ed3ce54f8d79860a8c9636382b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 15 Jul 2026 15:08:21 +0000 Subject: [PATCH 05/53] Initial plan From a3f67305f560071dfb59d62eec3c979a718d3e89 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 15 Jul 2026 15:24:57 +0000 Subject: [PATCH 06/53] feat: add SMS E2E test mirroring webchat test with shared ChatStatement/AsyncIterable pattern Co-authored-by: stephenhand <1694716+stephenhand@users.noreply.github.com> --- e2e-tests/chatScripts.ts | 62 ++++++++++++++++++ e2e-tests/tests/sms.spec.ts | 127 ++++++++++++++++++++++++++++++++++++ e2e-tests/twilio/sms.ts | 83 ++++++++++++++++++----- 3 files changed, 255 insertions(+), 17 deletions(-) create mode 100644 e2e-tests/tests/sms.spec.ts diff --git a/e2e-tests/chatScripts.ts b/e2e-tests/chatScripts.ts index d069bff2c5..4b5cff13e3 100644 --- a/e2e-tests/chatScripts.ts +++ b/e2e-tests/chatScripts.ts @@ -80,3 +80,65 @@ export const getWebchatScript = (): ChatStatement[] => { return defaultScript; }; + +/** + * SMS scripts are structurally the same as webchat scripts (using the same ChatStatement format), + * but must start with a CALLER statement since the client must initiate an SMS conversation. + * BOT messages are expected to arrive after the client sends its first message. + */ +export const defaultSmsScript: ChatStatement[] = [ + callerStatement('hi'), + botStatement('Welcome to the helpline. Please answer the following questions.'), + callerStatement('yes'), + botStatement('How old are you?'), + callerStatement('10'), + botStatement('What is your gender?'), + callerStatement('girl'), + botStatement('We will transfer you now. Please hold for a counsellor.'), + counselorAutoStatement('Hi, this is the counsellor. How can I help you?'), + callerStatement('CALLER TEST SMS MESSAGE'), + counselorStatement('COUNSELLOR TEST SMS MESSAGE'), +]; + +export const smsCommonScripts: Record = { + ca: [ + callerStatement('CALLER TEST SMS MESSAGE'), + counselorAutoStatement("Hi, you've reached a counsellor. What would you like to talk about?"), + counselorStatement('COUNSELLOR TEST SMS MESSAGE'), + ], +}; + +export const smsEnvScripts: Record> = { + development: { + as: [ + callerStatement('hi'), + botStatement("Sorry, I didn't understand that. Please try again."), + callerStatement('hi'), + botStatement('Are you calling about yourself? Please answer Yes or No.'), + callerStatement('yes'), + botStatement('How old are you?'), + callerStatement('10'), + botStatement('What is your gender?'), + callerStatement('girl'), + botStatement("We'll transfer you now. Please hold for a counsellor."), + counselorAutoStatement('Hi, this is the counsellor. How can I help you?'), + callerStatement('CALLER TEST SMS MESSAGE'), + counselorStatement('COUNSELLOR TEST SMS MESSAGE'), + ], + }, +}; + +export const getSmsScript = (): ChatStatement[] => { + const helplineShortCode = getConfigValue('helplineShortCode') as string; + const helplineEnv = getConfigValue('helplineEnv') as string; + + if (smsEnvScripts[helplineEnv]?.[helplineShortCode]) { + return smsEnvScripts[helplineEnv][helplineShortCode]; + } + + if (smsCommonScripts[helplineShortCode]) { + return smsCommonScripts[helplineShortCode]; + } + + return defaultSmsScript; +}; diff --git a/e2e-tests/tests/sms.spec.ts b/e2e-tests/tests/sms.spec.ts new file mode 100644 index 0000000000..2488ee8a8f --- /dev/null +++ b/e2e-tests/tests/sms.spec.ts @@ -0,0 +1,127 @@ +/** + * 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 { Page, request, test } from '@playwright/test'; +import { statusIndicator } from '../workerStatus'; +import { ChatStatement, ChatStatementOrigin } from '../chatModel'; +import { getSmsScript } from '../chatScripts'; +import { flexChat } from '../flexChat'; +import { skipTestIfNotTargeted } from '../skipTest'; +import { tasks } from '../tasks'; +import { Categories, contactForm, ContactFormTab } from '../contactForm'; +import { deleteAllTasksInQueue } from '../twilio/tasks'; +import { notificationBar } from '../notificationBar'; +import { clickThroughTwilioPasteModals } from '../agent-desktop'; +import { setupContextAndPage, closePage } from '../browser'; +import { clearOfflineTask } from '../hrm/clearOfflineTask'; +import { apiHrmRequest } from '../hrm/hrmRequest'; +import { formContentsByHelpline } from '../formContentsByHelpline'; +import { getConfigValue } from '../config'; +import { smsChat } from '../twilio/sms'; + +test.describe.serial('SMS caller', () => { + skipTestIfNotTargeted(); + + let pluginPage: Page; + + test.beforeAll(async ({ browser }) => { + test.setTimeout(180000); + ({ page: pluginPage } = await setupContextAndPage(browser)); + + await clearOfflineTask( + apiHrmRequest(await request.newContext(), process.env.FLEX_TOKEN!), + process.env.LOGGED_IN_WORKER_SID!, + ); + console.info('SMS E2E test - plugin page launched.'); + + await clickThroughTwilioPasteModals(pluginPage); + console.info('Plugin page visited.'); + }); + + test.afterAll(async () => { + await statusIndicator(pluginPage)?.setStatus('OFFLINE'); + if (pluginPage) { + await notificationBar(pluginPage).dismissAllNotifications(); + } + await closePage(pluginPage); + await deleteAllTasksInQueue(); + }); + + test.afterEach(async () => { + await deleteAllTasksInQueue(); + }); + + test('Chat', async () => { + test.setTimeout(180000); + + const chatScript = getSmsScript(); + + // smsChat handles the client (caller) side via the Twilio Messages API. + // flexChat handles the counselor side via the Flex browser UI. + // Both iterate the same shared script, yielding control when they hit a + // statement the other side needs to handle — the same pattern used by the + // Aselo webchat test. + const smsChatProgress = smsChat(chatScript); + const flexChatProgress: AsyncIterator = flexChat(pluginPage).chat(chatScript); + + for await (const expectedCounselorStatement of smsChatProgress) { + console.info('Statement for flex chat to process', expectedCounselorStatement); + if (expectedCounselorStatement) { + switch (expectedCounselorStatement.origin) { + case ChatStatementOrigin.COUNSELOR_AUTO: + await statusIndicator(pluginPage).setStatus('AVAILABLE'); + await tasks(pluginPage).acceptNextTask(); + await flexChatProgress.next(); + break; + default: + await flexChatProgress.next(); + break; + } + } + } + + console.info('Starting filling form'); + const helpline = getConfigValue('helplineShortCode') as keyof typeof formContentsByHelpline; + const formContent = formContentsByHelpline[helpline]; + if (!formContent) { + throw new Error(`No form contents configured for helplineShortCode="${String(helpline)}"`); + } + const form = contactForm(pluginPage); + await form.fill([ + { + id: 'childInformation', + label: 'TabbedForms-AddChildInfoTab', + fill: form.fillStandardTab, + items: formContent.childInformation, + }, + >{ + id: 'categories', + label: 'TabbedForms-CategoriesTab', + fill: form.fillCategoriesTab, + items: formContent.categories, + }, + { + id: 'caseInformation', + label: 'TabbedForms-AddCaseInfoTab', + fill: form.fillStandardTab, + items: formContent.caseInformation, + }, + ]); + + console.info('Saving form'); + await form.save(); + }); +}); diff --git a/e2e-tests/twilio/sms.ts b/e2e-tests/twilio/sms.ts index bb2682d160..5e52833f20 100644 --- a/e2e-tests/twilio/sms.ts +++ b/e2e-tests/twilio/sms.ts @@ -1,30 +1,26 @@ import { getConfigValue } from '../config'; +// eslint-disable-next-line import/no-extraneous-dependencies import twilio from 'twilio'; -import type { ConversationInstance } from 'twilio/lib/rest/conversations/v1/conversation'; import { AssertionError } from 'node:assert'; +import { ChatStatement, ChatStatementOrigin } from '../chatModel'; -let clientConversation: ConversationInstance; +// Tracks the start of the current SMS test session so we only check messages received after this time +let sessionStartTime: Date | undefined; export const sendSmsToService = async (messageText: string) => { + if (!sessionStartTime) { + sessionStartTime = new Date(); + } const accountSid = getConfigValue('clientTwilioAccountSid') as string; const authToken = getConfigValue('clientTwilioAuthToken') as string; const from = getConfigValue('clientSmsPhoneNumber') as string; const to = getConfigValue('smsPhoneNumber') as string; const client = twilio(accountSid, authToken); - if (!clientConversation) { - clientConversation = await client.conversations.v1.conversations.create({ - friendlyName: 'E2E test client conversation', - - uniqueName: `sms/${from}/${Date.now()}`, - }); - await clientConversation.participants().create({ - identity: from, - }); - } await client.messages.create({ from, to, body: messageText }); console.debug(`Sent SMS message to service: '${messageText}'`); }; + export const sendSmsFromService = async (messageText: string) => { const accountSid = getConfigValue('twilioAccountSid') as string; const authToken = getConfigValue('twilioAuthToken') as string; @@ -38,19 +34,72 @@ export const sendSmsFromService = async (messageText: string) => { const MAX_CHECKS = 10; +/** + * Checks whether the given message text was received by the SMS client (i.e., sent from the + * service to the client phone number) at any point since the current session started. + * Uses the service Twilio account to list outbound messages to the client number. + */ export const checkForMessageOnClient = async (messageText: string): Promise => { - if (!clientConversation) { + if (!sessionStartTime) { throw new AssertionError({ - message: - "You cannot verify incoming messages until you've sent one and created a client side conversation", + message: "You cannot verify incoming messages until you've sent one and started a session", }); } + const accountSid = getConfigValue('twilioAccountSid') as string; + const authToken = getConfigValue('twilioAuthToken') as string; + const to = getConfigValue('clientSmsPhoneNumber') as string; + + const client = twilio(accountSid, authToken); + const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); for (let i = 0; i < MAX_CHECKS; i++) { - const messages = await clientConversation.messages().list(); + const messages = await client.messages.list({ to, dateSentAfter: sessionStartTime }); if (messages.find((m) => m.body === messageText)) { return true; } - await new Promise((resolve) => setTimeout(resolve, 1000)); + await delay(1000); } return false; }; + +/** + * Asserts that the given message text was received by the SMS client within the polling window. + * Throws if the message is not found. + */ +const assertMessageReceivedOnClient = async (messageText: string): Promise => { + const received = await checkForMessageOnClient(messageText); + if (!received) { + throw new AssertionError({ + message: `SMS message not received on client: '${messageText}'`, + }); + } +}; + +/** + * Runs the 'client side' of an SMS conversation using the Twilio Messages API. + * It loops through a list of chat statements, sending caller SMS messages via the API and + * polling for expected bot/counselor messages on the client number. + * As soon as it hits a counselor statement (COUNSELOR or COUNSELOR_AUTO), it yields execution + * back to the calling code so it can action those statements in Flex. + * + * A similar function exists in flexChat.ts to handle the counselor side of the conversation. + * Both iterate the same shared ChatStatement list, yielding control when they hit a statement + * the other side needs to handle. + * @param statements - a unified list of all the chat statements in a conversation + */ +export async function* smsChat(statements: ChatStatement[]): AsyncGenerator { + for (const statementItem of statements) { + const { text, origin } = statementItem; + switch (origin) { + case ChatStatementOrigin.CALLER: + await sendSmsToService(text); + break; + case ChatStatementOrigin.BOT: { + await assertMessageReceivedOnClient(text); + break; + } + default: + yield statementItem; + await assertMessageReceivedOnClient(text); + } + } +} From 5b6c9190ab36a5d7c7f6b439c78d62b1e413972b Mon Sep 17 00:00:00 2001 From: Stephen Hand Date: Wed, 15 Jul 2026 17:28:52 +0100 Subject: [PATCH 07/53] Licence headers --- e2e-tests/twilio/sms.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/e2e-tests/twilio/sms.ts b/e2e-tests/twilio/sms.ts index bb2682d160..8db0095a31 100644 --- a/e2e-tests/twilio/sms.ts +++ b/e2e-tests/twilio/sms.ts @@ -1,3 +1,19 @@ +/** + * 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 { getConfigValue } from '../config'; import twilio from 'twilio'; import type { ConversationInstance } from 'twilio/lib/rest/conversations/v1/conversation'; From 64dd61e433b292adcd8ab38e9e924808b5f81c05 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 17 Jul 2026 18:03:15 +0000 Subject: [PATCH 08/53] feat: add SMS channel for E2E development environment - Add SMS channel to twilio-iac/helplines/e2e/development.hcl using the messaging-lex-v3-blocking-lambda.tftpl template (same as aselo_webchat) and an empty contact_identity (conversations address managed separately) - Create twilio-iac/helplines/e2e/files/additional.configure.tf that uses a Twilio data source to look up the only phone number attached to the account at apply time and creates the SMS conversations address linked to the SMS studio flow - Guard twilio_conversations_configuration_addresses_v1 in channels/v1/main.tf so channels with an empty contact_identity skip automatic address creation (allowing helpline-specific additional.tf to manage it instead) Co-authored-by: stephenhand <1694716+stephenhand@users.noreply.github.com> --- twilio-iac/helplines/e2e/development.hcl | 11 ++++++++++ .../e2e/files/additional.configure.tf | 20 +++++++++++++++++++ .../terraform-modules/channels/v1/main.tf | 4 +++- 3 files changed, 34 insertions(+), 1 deletion(-) create mode 100644 twilio-iac/helplines/e2e/files/additional.configure.tf diff --git a/twilio-iac/helplines/e2e/development.hcl b/twilio-iac/helplines/e2e/development.hcl index eafb13d33c..f90e2140d9 100644 --- a/twilio-iac/helplines/e2e/development.hcl +++ b/twilio-iac/helplines/e2e/development.hcl @@ -35,6 +35,17 @@ locals { channel_flow_vars = {} chatbot_unique_names = [] } + sms : { + channel_type = "sms" + messaging_mode = "conversations" + # contact_identity is intentionally empty here; the conversations address is created + # via additional.configure.tf using a data source that resolves the only phone number + # attached to this Twilio account at apply time. + contact_identity = "" + templatefile = "/app/twilio-iac/helplines/templates/studio-flows/messaging-lex-v3-blocking-lambda.tftpl" + channel_flow_vars = {} + chatbot_unique_names = [] + } } get_profile_flags_for_identifier_base_url = "https://hrm-development.tl.techmatters.org/lambda/twilio/account-scoped" #System Down Configuration diff --git a/twilio-iac/helplines/e2e/files/additional.configure.tf b/twilio-iac/helplines/e2e/files/additional.configure.tf new file mode 100644 index 0000000000..0de4d3fba3 --- /dev/null +++ b/twilio-iac/helplines/e2e/files/additional.configure.tf @@ -0,0 +1,20 @@ +# Dynamically resolve the single phone number attached to this Twilio account and +# configure it as the SMS conversations address, linked to the SMS studio flow created +# by the channels module. +# +# Using a data source rather than hardcoding the number keeps the config portable: +# the E2E Twilio account has exactly one phone number, so one() is intentionally +# strict here – if the account ever has zero or more than one number the apply will +# fail with a clear error. + +data "twilio_api_accounts_incoming_phone_numbers_v2010" "all" {} + +resource "twilio_conversations_configuration_addresses_v1" "sms" { + type = "sms" + address = one(data.twilio_api_accounts_incoming_phone_numbers_v2010.all.incoming_phone_numbers).phone_number + friendly_name = "Sms Conversation Address" + auto_creation_enabled = true + auto_creation_type = "studio" + auto_creation_conversation_service_sid = local.services_flex_chat_service_sid + auto_creation_studio_flow_sid = module.channel.channel_studio_flows_sids["sms"].flow_sid +} diff --git a/twilio-iac/terraform-modules/channels/v1/main.tf b/twilio-iac/terraform-modules/channels/v1/main.tf index 21e6d961bd..20af2664b1 100644 --- a/twilio-iac/terraform-modules/channels/v1/main.tf +++ b/twilio-iac/terraform-modules/channels/v1/main.tf @@ -107,7 +107,9 @@ resource "twilio_conversations_configuration_addresses_v1" "conversations_addres # Must be created manually in Twilio Console for now channel.channel_type != "chat" && channel.channel_type != "custom" && - channel.messaging_mode == "conversations" + channel.messaging_mode == "conversations" && + # Channels with no contact_identity manage their own conversations address (e.g. via additional.tf) + channel.contact_identity != "" ) } type = each.value.channel_type From e036e2cb87a5432ebb12772b9de35d7c7f0ac0fe Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 17 Jul 2026 18:03:57 +0000 Subject: [PATCH 09/53] fix: use consistent SMS capitalization in friendly_name Co-authored-by: stephenhand <1694716+stephenhand@users.noreply.github.com> --- twilio-iac/helplines/e2e/files/additional.configure.tf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/twilio-iac/helplines/e2e/files/additional.configure.tf b/twilio-iac/helplines/e2e/files/additional.configure.tf index 0de4d3fba3..e8c9d036d9 100644 --- a/twilio-iac/helplines/e2e/files/additional.configure.tf +++ b/twilio-iac/helplines/e2e/files/additional.configure.tf @@ -12,7 +12,7 @@ data "twilio_api_accounts_incoming_phone_numbers_v2010" "all" {} resource "twilio_conversations_configuration_addresses_v1" "sms" { type = "sms" address = one(data.twilio_api_accounts_incoming_phone_numbers_v2010.all.incoming_phone_numbers).phone_number - friendly_name = "Sms Conversation Address" + friendly_name = "SMS Conversation Address" auto_creation_enabled = true auto_creation_type = "studio" auto_creation_conversation_service_sid = local.services_flex_chat_service_sid From 2d3f8053d273c71670c7f36503eb942e006df222 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 20 Jul 2026 14:19:00 +0000 Subject: [PATCH 10/53] refactor: hardcode E2E SMS channel phone number +12607821891 - Replace dynamic phone number lookup with hardcoded +12607821891 in both development.hcl and additional.configure.tf - Remove data source that previously scraped the phone number from the Twilio account - Set contact_identity to the hardcoded phone number in the SMS channel configuration Co-authored-by: stephenhand <1694716+stephenhand@users.noreply.github.com> --- twilio-iac/helplines/e2e/development.hcl | 5 +---- .../helplines/e2e/files/additional.configure.tf | 13 ++----------- 2 files changed, 3 insertions(+), 15 deletions(-) diff --git a/twilio-iac/helplines/e2e/development.hcl b/twilio-iac/helplines/e2e/development.hcl index f90e2140d9..ff1a8358d8 100644 --- a/twilio-iac/helplines/e2e/development.hcl +++ b/twilio-iac/helplines/e2e/development.hcl @@ -38,10 +38,7 @@ locals { sms : { channel_type = "sms" messaging_mode = "conversations" - # contact_identity is intentionally empty here; the conversations address is created - # via additional.configure.tf using a data source that resolves the only phone number - # attached to this Twilio account at apply time. - contact_identity = "" + contact_identity = "+12607821891" templatefile = "/app/twilio-iac/helplines/templates/studio-flows/messaging-lex-v3-blocking-lambda.tftpl" channel_flow_vars = {} chatbot_unique_names = [] diff --git a/twilio-iac/helplines/e2e/files/additional.configure.tf b/twilio-iac/helplines/e2e/files/additional.configure.tf index e8c9d036d9..944ff076bd 100644 --- a/twilio-iac/helplines/e2e/files/additional.configure.tf +++ b/twilio-iac/helplines/e2e/files/additional.configure.tf @@ -1,17 +1,8 @@ -# Dynamically resolve the single phone number attached to this Twilio account and -# configure it as the SMS conversations address, linked to the SMS studio flow created -# by the channels module. -# -# Using a data source rather than hardcoding the number keeps the config portable: -# the E2E Twilio account has exactly one phone number, so one() is intentionally -# strict here – if the account ever has zero or more than one number the apply will -# fail with a clear error. - -data "twilio_api_accounts_incoming_phone_numbers_v2010" "all" {} +# Configure the SMS conversations address with the hardcoded phone number +12607821891. resource "twilio_conversations_configuration_addresses_v1" "sms" { type = "sms" - address = one(data.twilio_api_accounts_incoming_phone_numbers_v2010.all.incoming_phone_numbers).phone_number + address = "+12607821891" friendly_name = "SMS Conversation Address" auto_creation_enabled = true auto_creation_type = "studio" From 0d150f9bbf2b624b5ba5cc4ce1d83e874dbeb29d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 20 Jul 2026 14:26:26 +0000 Subject: [PATCH 11/53] refactor: remove redundant additional.configure.tf for SMS address The twilio-iac/terraform-modules/channels/v1/main.tf module now automatically creates the SMS conversations address since contact_identity is no longer empty. The separate additional.configure.tf file is no longer needed. Co-authored-by: stephenhand <1694716+stephenhand@users.noreply.github.com> --- .../helplines/e2e/files/additional.configure.tf | 11 ----------- 1 file changed, 11 deletions(-) delete mode 100644 twilio-iac/helplines/e2e/files/additional.configure.tf diff --git a/twilio-iac/helplines/e2e/files/additional.configure.tf b/twilio-iac/helplines/e2e/files/additional.configure.tf deleted file mode 100644 index 944ff076bd..0000000000 --- a/twilio-iac/helplines/e2e/files/additional.configure.tf +++ /dev/null @@ -1,11 +0,0 @@ -# Configure the SMS conversations address with the hardcoded phone number +12607821891. - -resource "twilio_conversations_configuration_addresses_v1" "sms" { - type = "sms" - address = "+12607821891" - friendly_name = "SMS Conversation Address" - auto_creation_enabled = true - auto_creation_type = "studio" - auto_creation_conversation_service_sid = local.services_flex_chat_service_sid - auto_creation_studio_flow_sid = module.channel.channel_studio_flows_sids["sms"].flow_sid -} From 77c4c7e1426e97ff76d02fc8214951dc4da02852 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 22 Jul 2026 11:49:27 +0000 Subject: [PATCH 12/53] feat: add voice channel for E2E development environment - Add voice channel using voice-no-chatbot-operating-hours-blocking-lambda template - Use the same phone number (+12607821891) as the SMS channel - Include voice_ivr_greeting_message, voice_ivr_blocked_message, and voice_ivr_language - Follows established patterns used in other helplines for voice configurations Co-authored-by: stephenhand <1694716+stephenhand@users.noreply.github.com> --- twilio-iac/helplines/e2e/development.hcl | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/twilio-iac/helplines/e2e/development.hcl b/twilio-iac/helplines/e2e/development.hcl index ff1a8358d8..4cbd493785 100644 --- a/twilio-iac/helplines/e2e/development.hcl +++ b/twilio-iac/helplines/e2e/development.hcl @@ -43,6 +43,17 @@ locals { channel_flow_vars = {} chatbot_unique_names = [] } + voice : { + channel_type = "voice" + contact_identity = "+12607821891" + templatefile = "/app/twilio-iac/helplines/templates/studio-flows/voice-no-chatbot-operating-hours-blocking-lambda.tftpl" + channel_flow_vars = { + voice_ivr_greeting_message = "Thank you for contacting E2E. One of our counselors will be with you shortly." + voice_ivr_blocked_message = "You have been blocked from contacting this service." + voice_ivr_language = "en-US" + } + chatbot_unique_names = [] + } } get_profile_flags_for_identifier_base_url = "https://hrm-development.tl.techmatters.org/lambda/twilio/account-scoped" #System Down Configuration From cc25f5d7873727394368e2bd0a59c317036a44ca Mon Sep 17 00:00:00 2001 From: Stephen Hand Date: Wed, 22 Jul 2026 12:52:26 +0100 Subject: [PATCH 13/53] WIP voice testing --- e2e-tests/config.ts | 19 ++- ...ChatChannels.ts => deleteConversations.ts} | 5 +- e2e-tests/package.json | 6 +- e2e-tests/tests/sms.spec.ts | 2 + e2e-tests/tests/voice.spec.ts | 111 ++++++++++++++++ e2e-tests/twilio/channels.ts | 122 ++++++++++++------ e2e-tests/twilio/sms.ts | 46 ++++--- e2e-tests/twilio/voice.ts | 27 ++++ 8 files changed, 268 insertions(+), 70 deletions(-) rename e2e-tests/{deleteChatChannels.ts => deleteConversations.ts} (88%) create mode 100644 e2e-tests/tests/voice.spec.ts create mode 100644 e2e-tests/twilio/voice.ts diff --git a/e2e-tests/config.ts b/e2e-tests/config.ts index 71b263e858..26cef92eb1 100644 --- a/e2e-tests/config.ts +++ b/e2e-tests/config.ts @@ -42,8 +42,9 @@ export type Config = { const helplineShortCode = process.env.HL?.toLocaleLowerCase() || 'e2e'; // Account to initiate calls into the helpline under test from, for testing voice & SMS -const clientHelplineShortCode = process.env.CLIENT_HL?.toLocaleLowerCase() || helplineShortCode; const helplineEnv = process.env.HL_ENV?.toLocaleLowerCase() || 'local'; +const clientHelplineShortCode = process.env.CLIENT_HL?.toLocaleLowerCase() || 'as'; +const clientHelplineEnv = process.env.CLIENT_HL_ENV?.toLocaleLowerCase() || 'development'; const shouldLoadFromSsm = process.env.LOAD_SSM_CONFIG && process.env.LOAD_SSM_CONFIG !== 'false'; // These are environments where we want to avoid tests or steps that update HRM data @@ -139,16 +140,14 @@ const configOptions: ConfigOptions = { // The twilio account sid and auth token are used to target a flex account clientTwilioAccountSid: { - envKey: 'TWILIO_ACCOUNT_SID', - ssmPath: `/${localOverrideEnv}/twilio/${clientHelplineShortCode.toUpperCase()}/account_sid`, - default: () => getConfigValue('twilioAccountSid'), + envKey: 'CLIENT_TWILIO_ACCOUNT_SID', + ssmPath: `/${clientHelplineEnv}/twilio/${clientHelplineShortCode.toUpperCase()}/account_sid`, }, clientTwilioAuthToken: { - envKey: 'TWILIO_AUTH_TOKEN', + envKey: 'CLIENT_TWILIO_AUTH_TOKEN', // Order is important here. We use a function so that we can reference the clientTwilioAccountSid config value above. ssmPath: () => - `/${localOverrideEnv}/twilio/${getConfigValue('clientTwilioAccountSid')}/auth_token`, - default: () => getConfigValue('twilioAuthToken'), + `/${clientHelplineEnv}/twilio/${getConfigValue('clientTwilioAccountSid')}/auth_token`, }, // Turn on debug mode. Possibly unused. @@ -199,13 +198,13 @@ const configOptions: ConfigOptions = { // This should match the number set up for the Voice studio flow on the helpline under test voicePhoneNumber: { envKey: 'VOICE_PHONE_NUMBER', - default: '', + default: '+12607821891', }, // This should match the number set up on the clientTwilioAccountSid that can make outgoing calls clientVoicePhoneNumber: { envKey: 'CLIENT_VOICE_PHONE_NUMBER', - default: '', + default: '+12064083885', }, // This should match the number set up for the SMS studio flow on the helpline under test @@ -296,7 +295,7 @@ const setConfigValueFromSsm = async (key: string) => { throw err; } - console.log(`Failed to load config value from SSM at ${option.ssmPath}. Using default value`); + console.warn(`Failed to load config value from SSM at ${option.ssmPath}. Using default value`); setConfigValue(key, typeof option.default === 'function' ? option.default() : option.default); } diff --git a/e2e-tests/deleteChatChannels.ts b/e2e-tests/deleteConversations.ts similarity index 88% rename from e2e-tests/deleteChatChannels.ts rename to e2e-tests/deleteConversations.ts index 3c15d958c0..59b33ed447 100644 --- a/e2e-tests/deleteChatChannels.ts +++ b/e2e-tests/deleteConversations.ts @@ -23,12 +23,13 @@ * send new messages from the e2e test user. */ -import { deleteChatChannels } from './twilio/channels'; +import { deleteChatConversations, deleteSmsConversations } from './twilio/channels'; import { initConfig } from './config'; const main = async () => { await initConfig(); - await deleteChatChannels(); + await deleteChatConversations(); + await deleteSmsConversations(); }; main(); diff --git a/e2e-tests/package.json b/e2e-tests/package.json index 14da237ed9..9323999c69 100644 --- a/e2e-tests/package.json +++ b/e2e-tests/package.json @@ -5,12 +5,12 @@ "main": "index.js", "scripts": { "postinstall": "npx playwright install chromium", - "deleteChatChannels": "tsx deleteChatChannels.ts", + "deleteChatChannels": "tsx deleteConversations.ts", "test": "npx playwright test --workers 1 ", "test:ui": "npx playwright test --workers 1 --config ui-tests/playwright.ui-test.config.ts", - "test:local": "cross-env DEBUG=pw:api LOAD_SSM_CONFIG=true npm run test", + "test:local": "cross-env DEBUG=pw:api LOAD_SSM_CONFIG=true npm run test -- --retries 0 voice", "test:local-aselo-webchat": "cross-env DEBUG=pw:api LOAD_SSM_CONFIG=true ASELO_WEBCHAT_URL=http://localhost:3001 npm run test -- --headed --debug --retries 0 aseloWebchat", - "test:local:debug": "cross-env DEBUG=pw:api LOAD_SSM_CONFIG=true npm run test -- --headed --debug --retries 0 offline", + "test:local:debug": "cross-env DEBUG=pw:api LOAD_SSM_CONFIG=true npm run test -- --headed --debug --retries 0 voice", "test:development:as": "cross-env DEBUG=pw:api LOAD_SSM_CONFIG=true HL_ENV=development HL=as SKIP_DATA_UPDATE=true npm run test", "test:development:as:debug": "cross-env DEBUG=pw:api LOAD_SSM_CONFIG=true HL_ENV=development HL=as SKIP_DATA_UPDATE=true npm run test -- --headed --retries 0", "test:development:e2e": "cross-env DEBUG=pw:api LOAD_SSM_CONFIG=true HL_ENV=development HL=e2e npm run test", diff --git a/e2e-tests/tests/sms.spec.ts b/e2e-tests/tests/sms.spec.ts index 2488ee8a8f..5d16c47fc5 100644 --- a/e2e-tests/tests/sms.spec.ts +++ b/e2e-tests/tests/sms.spec.ts @@ -31,6 +31,7 @@ import { apiHrmRequest } from '../hrm/hrmRequest'; import { formContentsByHelpline } from '../formContentsByHelpline'; import { getConfigValue } from '../config'; import { smsChat } from '../twilio/sms'; +import { deleteSmsConversations } from '../twilio/channels'; test.describe.serial('SMS caller', () => { skipTestIfNotTargeted(); @@ -39,6 +40,7 @@ test.describe.serial('SMS caller', () => { test.beforeAll(async ({ browser }) => { test.setTimeout(180000); + await deleteSmsConversations(); ({ page: pluginPage } = await setupContextAndPage(browser)); await clearOfflineTask( diff --git a/e2e-tests/tests/voice.spec.ts b/e2e-tests/tests/voice.spec.ts new file mode 100644 index 0000000000..b9b8c616bc --- /dev/null +++ b/e2e-tests/tests/voice.spec.ts @@ -0,0 +1,111 @@ +/** + * 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 { Page, request, test } from '@playwright/test'; +import { statusIndicator } from '../workerStatus'; +import { ChatStatement, ChatStatementOrigin } from '../chatModel'; +import { getSmsScript } from '../chatScripts'; +import { flexChat } from '../flexChat'; +import { skipTestIfNotTargeted } from '../skipTest'; +import { tasks } from '../tasks'; +import { Categories, contactForm, ContactFormTab } from '../contactForm'; +import { deleteAllTasksInQueue } from '../twilio/tasks'; +import { notificationBar } from '../notificationBar'; +import { clickThroughTwilioPasteModals } from '../agent-desktop'; +import { setupContextAndPage, closePage } from '../browser'; +import { clearOfflineTask } from '../hrm/clearOfflineTask'; +import { apiHrmRequest } from '../hrm/hrmRequest'; +import { formContentsByHelpline } from '../formContentsByHelpline'; +import { getConfigValue } from '../config'; +import { smsChat } from '../twilio/sms'; +import { makeCallToService } from '../twilio/voice'; + +test.describe.serial('SMS caller', () => { + skipTestIfNotTargeted(); + + let pluginPage: Page; + + test.beforeAll(async ({ browser }) => { + test.setTimeout(180000); + ({ page: pluginPage } = await setupContextAndPage(browser)); + + await clearOfflineTask( + apiHrmRequest(await request.newContext(), process.env.FLEX_TOKEN!), + process.env.LOGGED_IN_WORKER_SID!, + ); + console.info('Voice E2E test - plugin page launched.'); + + await clickThroughTwilioPasteModals(pluginPage); + console.info('Plugin page visited.'); + }); + + test.afterAll(async () => { + await statusIndicator(pluginPage)?.setStatus('OFFLINE'); + if (pluginPage) { + await notificationBar(pluginPage).dismissAllNotifications(); + } + await closePage(pluginPage); + await deleteAllTasksInQueue(); + }); + + test.afterEach(async () => { + await deleteAllTasksInQueue(); + }); + + test('Chat', async () => { + test.setTimeout(180000); + + const chatScript = getSmsScript(); + + // smsChat handles the client (caller) side via the Twilio Messages API. + // flexChat handles the counselor side via the Flex browser UI. + // Both iterate the same shared script, yielding control when they hit a + // statement the other side needs to handle — the same pattern used by the + // Aselo webchat test. + await makeCallToService(); + + console.info('Starting filling form'); + const helpline = getConfigValue('helplineShortCode') as keyof typeof formContentsByHelpline; + const formContent = formContentsByHelpline[helpline]; + if (!formContent) { + throw new Error(`No form contents configured for helplineShortCode="${String(helpline)}"`); + } + const form = contactForm(pluginPage); + await form.fill([ + { + id: 'childInformation', + label: 'TabbedForms-AddChildInfoTab', + fill: form.fillStandardTab, + items: formContent.childInformation, + }, + >{ + id: 'categories', + label: 'TabbedForms-CategoriesTab', + fill: form.fillCategoriesTab, + items: formContent.categories, + }, + { + id: 'caseInformation', + label: 'TabbedForms-AddCaseInfoTab', + fill: form.fillStandardTab, + items: formContent.caseInformation, + }, + ]); + + console.info('Saving form'); + await form.save(); + }); +}); diff --git a/e2e-tests/twilio/channels.ts b/e2e-tests/twilio/channels.ts index f1ef09fe1c..1aeb62b168 100644 --- a/e2e-tests/twilio/channels.ts +++ b/e2e-tests/twilio/channels.ts @@ -15,7 +15,7 @@ */ // eslint-disable-next-line import/no-extraneous-dependencies -import twilio from 'twilio'; +import twilio, { Twilio } from 'twilio'; import { getConfigValue } from '../config'; const encodeEmailToUnicode = (email: string) => { @@ -30,7 +30,66 @@ const encodeEmailToUnicode = (email: string) => { .join(''); }; -export const deleteChatChannels = async (): Promise => { +const deleteSmsConversationFromOneEnd = async ( + accountSid: string, + authToken: string, + fromNumber: string, + toNumber: string, +) => { + const client = twilio(accountSid, authToken); + const activeConversations = await client.conversations.v1.conversations.list({ + state: 'active', + }); + console.info(`${activeConversations.length} active conversations found.`); + await Promise.all( + activeConversations.map(async (conversation) => { + const participants = await conversation.participants().list(); + + if ( + // eslint-disable-next-line @typescript-eslint/no-loop-func + participants.some((participant) => { + return ( + participant.messagingBinding?.address === fromNumber && + participant.messagingBinding?.proxy_address === toNumber + ); + }) + ) { + console.info( + `Found a participant with the from SMS number address (${fromNumber}) and to SMS number proxy address (${toNumber}), attempting to close conversation ${conversation.sid} from ${accountSid}`, + ); + await conversation.update({ state: 'closed' }); + } + }), + ); +}; + +export const deleteSmsConversations = async (): Promise => { + const serviceAccountSid = getConfigValue('twilioAccountSid') as string; + const serviceAuthToken = getConfigValue('twilioAuthToken') as string; + const serviceSmsNumber = getConfigValue('smsPhoneNumber') as string; + + const senderAccountSid = getConfigValue('clientTwilioAccountSid') as string; + const senderAuthToken = getConfigValue('clientTwilioAuthToken') as string; + const senderSmsNumber = getConfigValue('clientSmsPhoneNumber') as string; + + // Delete conversations from service Twilio account + await deleteSmsConversationFromOneEnd( + serviceAccountSid, + serviceAuthToken, + senderSmsNumber, + serviceSmsNumber, + ); + + // Delete conversations from sender Twilio account + await deleteSmsConversationFromOneEnd( + senderAccountSid, + senderAuthToken, + serviceSmsNumber, + senderSmsNumber, + ); +}; + +export const deleteChatConversations = async (): Promise => { const accountSid = getConfigValue('twilioAccountSid') as string; const authToken = getConfigValue('twilioAuthToken') as string; const email = getConfigValue('oktaUsername') as string; @@ -38,45 +97,36 @@ export const deleteChatChannels = async (): Promise => { const client = twilio(accountSid, authToken); - // List all chat services - const services = await client.chat.v2.services.list(); - - for (const service of services) { - // List all users in this chat service - const users = await client.chat.v2.services(service.sid).users.list(); - console.log(`Found ${users.length} users in service ${service.sid}`); - const matchingUser = users.find((user) => user.identity === encodedEmail); - - if (!matchingUser) { - continue; - } - - console.log(`Found user ${email} in service ${service.sid}`); - - // List all channels the matching user is a part of - const userChannels = await client.chat.v2 - .services(service.sid) - .users(matchingUser.sid) - .userChannels.list(); - - console.log( - `Found ${userChannels.length} chat channels for user ${email} in service ${service.sid}`, - ); - - for (const userChannel of userChannels) { - console.log(`Removing chat channel ${userChannel.channelSid} from service ${service.sid}`); - await client.conversations.v1.services - .get(service.sid) - .conversations.get(userChannel.channelSid) - .remove(); - } + // List all users in this chat service + const users = await client.conversations.v1.users.list(); + console.debug(`Found ${users.length} users in conversations`); + const matchingUser = users.find((user) => user.identity === encodedEmail); + + if (!matchingUser) { + return; + } + + console.info(`Found user ${email} in conversations`); + + // List all channels the matching user is a part of + const userConversations = await client.conversations.v1.users + .get(matchingUser.sid) + .userConversations.list(); + + console.debug(`Found ${userConversations.length} chat channels for user ${email}`); + + for (const { conversationSid } of userConversations) { + console.debug(`Removing chat channel ${conversationSid}`); + await client.conversations.v1.conversations.get(conversationSid).remove(); } }; // Handle exit signals process.on('SIGINT', () => { - deleteChatChannels().catch((err) => console.error(err)); + deleteChatConversations().catch((err) => console.error(err)); + deleteSmsConversations().catch((err) => console.error(err)); }); process.on('SIGTERM', () => { - deleteChatChannels().catch((err) => console.error(err)); + deleteChatConversations().catch((err) => console.error(err)); + deleteSmsConversations().catch((err) => console.error(err)); }); diff --git a/e2e-tests/twilio/sms.ts b/e2e-tests/twilio/sms.ts index e4af5df8ad..e4a01853e9 100644 --- a/e2e-tests/twilio/sms.ts +++ b/e2e-tests/twilio/sms.ts @@ -23,31 +23,36 @@ import { ChatStatement, ChatStatementOrigin } from '../chatModel'; // Tracks the start of the current SMS test session so we only check messages received after this time let sessionStartTime: Date | undefined; +let clientConversationSid: string; + export const sendSmsToService = async (messageText: string) => { if (!sessionStartTime) { sessionStartTime = new Date(); } - const accountSid = getConfigValue('clientTwilioAccountSid') as string; + const clientAccountSid = getConfigValue('clientTwilioAccountSid') as string; const authToken = getConfigValue('clientTwilioAuthToken') as string; const from = getConfigValue('clientSmsPhoneNumber') as string; + const serviceAccountSid = getConfigValue('twilioAccountSid') as string; const to = getConfigValue('smsPhoneNumber') as string; - const client = twilio(accountSid, authToken); - await client.messages.create({ from, to, body: messageText }); + const client = twilio(clientAccountSid, authToken); + if (!clientConversationSid) { + const clientConversation = await client.conversations.v1.conversations.create({ + friendlyName: `E2E test conversation with ${serviceAccountSid}, ${new Date().toISOString()}`, + }); + await clientConversation.participants().create({ + 'messagingBinding.address': to, + 'messagingBinding.proxyAddress': from, + 'messagingBinding.type': 'sms', + } as any); + clientConversationSid = clientConversation.sid; + } + await client.conversations.v1.conversations + .get(clientConversationSid) + .messages.create({ author: from, body: messageText }); console.debug(`Sent SMS message to service: '${messageText}'`); }; -export const sendSmsFromService = async (messageText: string) => { - const accountSid = getConfigValue('twilioAccountSid') as string; - const authToken = getConfigValue('twilioAuthToken') as string; - const from = getConfigValue('smsPhoneNumber') as string; - const to = getConfigValue('clientSmsPhoneNumber') as string; - - const client = twilio(accountSid, authToken); - await client.messages.create({ from, to, body: messageText }); - console.debug(`Sent SMS message from service: '${messageText}'`); -}; - const MAX_CHECKS = 10; /** @@ -56,20 +61,23 @@ const MAX_CHECKS = 10; * Uses the service Twilio account to list outbound messages to the client number. */ export const checkForMessageOnClient = async (messageText: string): Promise => { - if (!sessionStartTime) { + if (!clientConversationSid) { throw new AssertionError({ message: "You cannot verify incoming messages until you've sent one and started a session", }); } - const accountSid = getConfigValue('twilioAccountSid') as string; - const authToken = getConfigValue('twilioAuthToken') as string; + const accountSid = getConfigValue('clientTwilioAccountSid') as string; + const authToken = getConfigValue('clientTwilioAuthToken') as string; const to = getConfigValue('clientSmsPhoneNumber') as string; const client = twilio(accountSid, authToken); const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); for (let i = 0; i < MAX_CHECKS; i++) { - const messages = await client.messages.list({ to, dateSentAfter: sessionStartTime }); - if (messages.find((m) => m.body === messageText)) { + const messages = await client.conversations.v1.conversations + .get(clientConversationSid) + .messages.list(); + //client.conversations.v1.roles.l + if (messages.find((m) => m.body === messageText && m.author !== to)) { return true; } await delay(1000); diff --git a/e2e-tests/twilio/voice.ts b/e2e-tests/twilio/voice.ts new file mode 100644 index 0000000000..eb3798dedc --- /dev/null +++ b/e2e-tests/twilio/voice.ts @@ -0,0 +1,27 @@ +import { getConfigValue } from '../config'; +import twilio from 'twilio'; +import VoiceResponse = twilio.twiml.VoiceResponse; + +// The callSid on the caller's side +// let callerCallSid: string; + +export const makeCallToService = async () => { + const clientAccountSid = getConfigValue('clientTwilioAccountSid') as string; + const authToken = getConfigValue('clientTwilioAuthToken') as string; + const from = getConfigValue('clientSmsPhoneNumber') as string; + // const serviceAccountSid = getConfigValue('twilioAccountSid') as string; + const to = getConfigValue('voicePhoneNumber') as string; + + const response = new VoiceResponse(); + response.say("Hello, I'm and end to end test"); + + const client = twilio(clientAccountSid, authToken); + //const call = + await client.calls.create({ + method: 'GET', + twiml: response, + from, + to, + }); + //callerCallSid = call.sid; +}; From 9a0cbe83fbc47a27a82ff6a526ab06c8396bdd2a Mon Sep 17 00:00:00 2001 From: Stephen Hand Date: Wed, 22 Jul 2026 19:26:14 +0100 Subject: [PATCH 14/53] First passing voice E2E test --- e2e-tests/contactForm.ts | 27 ++++++++++- e2e-tests/formContentsByHelpline.ts | 19 +++++++- e2e-tests/package.json | 8 ++-- e2e-tests/tests/aseloWebchat.spec.ts | 21 +-------- e2e-tests/tests/offlineContact.spec.ts | 39 +++------------ e2e-tests/tests/sms.spec.ts | 21 +-------- e2e-tests/tests/voice.spec.ts | 47 ++++--------------- e2e-tests/twilio/voice.ts | 2 +- twilio-iac/helplines/defaults.hcl | 4 ++ twilio-iac/helplines/e2e/common.hcl | 4 ++ twilio-iac/helplines/e2e/development.hcl | 2 +- .../templates/workflows/master.tftpl | 10 ++++ 12 files changed, 85 insertions(+), 119 deletions(-) diff --git a/e2e-tests/contactForm.ts b/e2e-tests/contactForm.ts index e7e171575b..7e9aecf959 100644 --- a/e2e-tests/contactForm.ts +++ b/e2e-tests/contactForm.ts @@ -85,7 +85,7 @@ export function contactForm(page: Page) { } } - return { + const formApi = { selectChildCallType: async () => { const childCallTypeButton = selectors.childCallTypeButton(); const responsePromise = page.waitForResponse('**/contacts/**'); @@ -100,6 +100,28 @@ export function contactForm(page: Page) { await tab.fill(tab); } }, + fillWithContent: async (formContent: any) => { + await formApi.fill([ + { + id: 'childInformation', + label: 'TabbedForms-AddChildInfoTab', + fill: formApi.fillStandardTab, + items: formContent.childInformation, + }, + >{ + id: 'categories', + label: 'TabbedForms-CategoriesTab', + fill: formApi.fillCategoriesTab, + items: formContent.categories, + }, + { + id: 'caseInformation', + label: 'TabbedForms-AddCaseInfoTab', + fill: formApi.fillStandardTab, + items: formContent.caseInformation, + }, + ]); + }, save: async ({ saveAndAddToCase }: { saveAndAddToCase?: boolean } = {}) => { const tab = { id: 'caseInformation', @@ -124,5 +146,6 @@ export function contactForm(page: Page) { }, fillCategoriesTab, fillStandardTab, - }; + } as const; + return formApi; } diff --git a/e2e-tests/formContentsByHelpline.ts b/e2e-tests/formContentsByHelpline.ts index b1d0caf6a3..1d5b8114ba 100644 --- a/e2e-tests/formContentsByHelpline.ts +++ b/e2e-tests/formContentsByHelpline.ts @@ -14,6 +14,7 @@ * along with this program. If not, see https://www.gnu.org/licenses/. */ + export const formContentsByHelpline = { e2e: { childInformation: { @@ -27,7 +28,7 @@ export const formContentsByHelpline = { Accessibility: ['Education'], }, caseInformation: { - callSummary: 'E2E TEST CALL', + callSummary: 'E2E TEST PREPOPULATED FORM', }, }, ca: { @@ -54,3 +55,19 @@ export const formContentsByHelpline = { }, }, }; + +export const formContentsByHelplineForEmptyForm = { + ...formContentsByHelpline, + e2e: { + ...formContentsByHelpline.e2e, + childInformation: { + ...formContentsByHelpline.e2e.childInformation, + + gender: 'Unknown', + age: 'Unknown', + }, + caseInformation: { + callSummary: 'E2E TEST EMPTY FORM', + }, + }, +}; \ No newline at end of file diff --git a/e2e-tests/package.json b/e2e-tests/package.json index 9323999c69..b55dbf52be 100644 --- a/e2e-tests/package.json +++ b/e2e-tests/package.json @@ -8,16 +8,16 @@ "deleteChatChannels": "tsx deleteConversations.ts", "test": "npx playwright test --workers 1 ", "test:ui": "npx playwright test --workers 1 --config ui-tests/playwright.ui-test.config.ts", - "test:local": "cross-env DEBUG=pw:api LOAD_SSM_CONFIG=true npm run test -- --retries 0 voice", + "test:local": "cross-env DEBUG=pw:api LOAD_SSM_CONFIG=true npm run test -- --retries 0", "test:local-aselo-webchat": "cross-env DEBUG=pw:api LOAD_SSM_CONFIG=true ASELO_WEBCHAT_URL=http://localhost:3001 npm run test -- --headed --debug --retries 0 aseloWebchat", - "test:local:debug": "cross-env DEBUG=pw:api LOAD_SSM_CONFIG=true npm run test -- --headed --debug --retries 0 voice", + "test:local:debug": "cross-env DEBUG=pw:api LOAD_SSM_CONFIG=true npm run test -- --headed --debug --retries 0", "test:development:as": "cross-env DEBUG=pw:api LOAD_SSM_CONFIG=true HL_ENV=development HL=as SKIP_DATA_UPDATE=true npm run test", "test:development:as:debug": "cross-env DEBUG=pw:api LOAD_SSM_CONFIG=true HL_ENV=development HL=as SKIP_DATA_UPDATE=true npm run test -- --headed --retries 0", "test:development:e2e": "cross-env DEBUG=pw:api LOAD_SSM_CONFIG=true HL_ENV=development HL=e2e npm run test", "test:development:e2e:local": "cross-env LOAD_SSM_CONFIG=true HL_ENV=development HL=e2e npm run test -- --headed", - "test:development:e2e:debug": "cross-env DEBUG=pw:api LOAD_SSM_CONFIG=true HL_ENV=development HL=e2e npm run test -- --headed --debug --retries 0 login", + "test:development:e2e:debug": "cross-env DEBUG=pw:api LOAD_SSM_CONFIG=true HL_ENV=development HL=e2e npm run test -- --headed --debug --retries 0", "test:staging:ca": "cross-env DEBUG=pw:api LOAD_SSM_CONFIG=true HL_ENV=staging HL=ca npm run test", - "test:staging:ca:headed": "cross-env LOAD_SSM_CONFIG=true HL_ENV=staging HL=ca npm test -- --headed --debug --retries 0 aseloWebchat", + "test:staging:ca:headed": "cross-env LOAD_SSM_CONFIG=true HL_ENV=staging HL=ca npm test -- --headed --debug --retries 0", "test:production:ca": "cross-env DEBUG=pw:api LOAD_SSM_CONFIG=true HL_ENV=production HL=ca npm run test", "lint": "eslint --ext ts .", "lint:fix": "npm run lint -- --fix .", diff --git a/e2e-tests/tests/aseloWebchat.spec.ts b/e2e-tests/tests/aseloWebchat.spec.ts index ecda6bd8ca..2b3fd446a1 100644 --- a/e2e-tests/tests/aseloWebchat.spec.ts +++ b/e2e-tests/tests/aseloWebchat.spec.ts @@ -102,26 +102,7 @@ test.describe.serial('Aselo web chat caller', () => { throw new Error(`No form contents configured for helplineShortCode="${String(helpline)}"`); } const form = contactForm(pluginPage); - await form.fill([ - { - id: 'childInformation', - label: 'TabbedForms-AddChildInfoTab', - fill: form.fillStandardTab, - items: formContent.childInformation, - }, - >{ - id: 'categories', - label: 'TabbedForms-CategoriesTab', - fill: form.fillCategoriesTab, - items: formContent.categories, - }, - { - id: 'caseInformation', - label: 'TabbedForms-AddCaseInfoTab', - fill: form.fillStandardTab, - items: formContent.caseInformation, - }, - ]); + await form.fillWithContent(formContent); console.info('Saving form'); await form.save(); diff --git a/e2e-tests/tests/offlineContact.spec.ts b/e2e-tests/tests/offlineContact.spec.ts index 1af1d559ea..4af76fa29f 100644 --- a/e2e-tests/tests/offlineContact.spec.ts +++ b/e2e-tests/tests/offlineContact.spec.ts @@ -23,6 +23,8 @@ import { notificationBar } from '../notificationBar'; import { closePage, setupContextAndPage } from '../browser'; import { apiHrmRequest } from '../hrm/hrmRequest'; import { clearOfflineTask } from '../hrm/clearOfflineTask'; +import {getConfigValue} from "../config"; +import {formContentsByHelpline} from "../formContentsByHelpline"; test.describe.serial('Offline Contact (with Case)', () => { skipTestIfNotTargeted(); @@ -56,6 +58,8 @@ test.describe.serial('Offline Contact (with Case)', () => { await agentDesktopPage.addOfflineContact(); console.log('Starting filling form'); + const helpline = getConfigValue('helplineShortCode') as keyof typeof formContentsByHelpline; + const formContent = formContentsByHelpline[helpline]; const form = contactForm(pluginPage); await form.selectChildCallType(); @@ -69,39 +73,8 @@ test.describe.serial('Offline Contact (with Case)', () => { channel: 'web', helpline: 'Childline', }, - }, - { - id: 'childInformation', - label: 'TabbedForms-AddChildInfoTab', - fill: form.fillStandardTab, - items: { - firstName: 'E2E', - lastName: 'OFFLINE CONTACT', - gender: 'Unknown', - age: 'Unknown', - phone1: '1234512345', - province: 'Northern', - district: 'District A', - }, - }, - >{ - id: 'categories', - label: 'TabbedForms-CategoriesTab', - fill: form.fillCategoriesTab, - items: { - Accessibility: ['Education'], - }, - }, - { - id: 'caseInformation', - label: 'TabbedForms-AddCaseInfoTab', - fill: form.fillStandardTab, - items: { - callSummary: 'E2E OFFLINE CONTACT', - }, - }, - ]); - + }]); + await form.fillWithContent(formContent); const beforeDate = new Date(); // Capture date here since we'll create case inmediately after saving contact // if (getConfigValue('skipDataUpdate') as boolean) { diff --git a/e2e-tests/tests/sms.spec.ts b/e2e-tests/tests/sms.spec.ts index 5d16c47fc5..a82d81e9f1 100644 --- a/e2e-tests/tests/sms.spec.ts +++ b/e2e-tests/tests/sms.spec.ts @@ -102,26 +102,7 @@ test.describe.serial('SMS caller', () => { throw new Error(`No form contents configured for helplineShortCode="${String(helpline)}"`); } const form = contactForm(pluginPage); - await form.fill([ - { - id: 'childInformation', - label: 'TabbedForms-AddChildInfoTab', - fill: form.fillStandardTab, - items: formContent.childInformation, - }, - >{ - id: 'categories', - label: 'TabbedForms-CategoriesTab', - fill: form.fillCategoriesTab, - items: formContent.categories, - }, - { - id: 'caseInformation', - label: 'TabbedForms-AddCaseInfoTab', - fill: form.fillStandardTab, - items: formContent.caseInformation, - }, - ]); + await form.fillWithContent(formContent); console.info('Saving form'); await form.save(); diff --git a/e2e-tests/tests/voice.spec.ts b/e2e-tests/tests/voice.spec.ts index b9b8c616bc..84ce54d432 100644 --- a/e2e-tests/tests/voice.spec.ts +++ b/e2e-tests/tests/voice.spec.ts @@ -16,11 +16,7 @@ import { Page, request, test } from '@playwright/test'; import { statusIndicator } from '../workerStatus'; -import { ChatStatement, ChatStatementOrigin } from '../chatModel'; -import { getSmsScript } from '../chatScripts'; -import { flexChat } from '../flexChat'; import { skipTestIfNotTargeted } from '../skipTest'; -import { tasks } from '../tasks'; import { Categories, contactForm, ContactFormTab } from '../contactForm'; import { deleteAllTasksInQueue } from '../twilio/tasks'; import { notificationBar } from '../notificationBar'; @@ -28,12 +24,12 @@ import { clickThroughTwilioPasteModals } from '../agent-desktop'; import { setupContextAndPage, closePage } from '../browser'; import { clearOfflineTask } from '../hrm/clearOfflineTask'; import { apiHrmRequest } from '../hrm/hrmRequest'; -import { formContentsByHelpline } from '../formContentsByHelpline'; +import {formContentsByHelpline, formContentsByHelplineForEmptyForm} from '../formContentsByHelpline'; import { getConfigValue } from '../config'; -import { smsChat } from '../twilio/sms'; import { makeCallToService } from '../twilio/voice'; +import { tasks } from '../tasks'; -test.describe.serial('SMS caller', () => { +test.describe.serial('Voice caller', () => { skipTestIfNotTargeted(); let pluginPage: Page; @@ -65,45 +61,22 @@ test.describe.serial('SMS caller', () => { await deleteAllTasksInQueue(); }); - test('Chat', async () => { + test('Call', async () => { test.setTimeout(180000); - - const chatScript = getSmsScript(); - - // smsChat handles the client (caller) side via the Twilio Messages API. - // flexChat handles the counselor side via the Flex browser UI. - // Both iterate the same shared script, yielding control when they hit a - // statement the other side needs to handle — the same pattern used by the - // Aselo webchat test. await makeCallToService(); + await statusIndicator(pluginPage).setStatus('AVAILABLE'); + await tasks(pluginPage).acceptNextTask(); console.info('Starting filling form'); const helpline = getConfigValue('helplineShortCode') as keyof typeof formContentsByHelpline; - const formContent = formContentsByHelpline[helpline]; + const formContent = formContentsByHelplineForEmptyForm[helpline]; if (!formContent) { throw new Error(`No form contents configured for helplineShortCode="${String(helpline)}"`); } const form = contactForm(pluginPage); - await form.fill([ - { - id: 'childInformation', - label: 'TabbedForms-AddChildInfoTab', - fill: form.fillStandardTab, - items: formContent.childInformation, - }, - >{ - id: 'categories', - label: 'TabbedForms-CategoriesTab', - fill: form.fillCategoriesTab, - items: formContent.categories, - }, - { - id: 'caseInformation', - label: 'TabbedForms-AddCaseInfoTab', - fill: form.fillStandardTab, - items: formContent.caseInformation, - }, - ]); + + await form.selectChildCallType(); + await form.fillWithContent(formContent); console.info('Saving form'); await form.save(); diff --git a/e2e-tests/twilio/voice.ts b/e2e-tests/twilio/voice.ts index eb3798dedc..f34cfb194d 100644 --- a/e2e-tests/twilio/voice.ts +++ b/e2e-tests/twilio/voice.ts @@ -13,7 +13,7 @@ export const makeCallToService = async () => { const to = getConfigValue('voicePhoneNumber') as string; const response = new VoiceResponse(); - response.say("Hello, I'm and end to end test"); + response.say({ loop: 100 }, "Hello, I'm an end to end test"); const client = twilio(clientAccountSid, authToken); //const call = diff --git a/twilio-iac/helplines/defaults.hcl b/twilio-iac/helplines/defaults.hcl index d4fdc6d476..5ac2d1293a 100644 --- a/twilio-iac/helplines/defaults.hcl +++ b/twilio-iac/helplines/defaults.hcl @@ -60,6 +60,10 @@ locals { e2e_test : { "target_workers" = "email=='aselo-alerts+production@techmatters.org'", "friendly_name" = "E2E Test Queue" + }, + e2e_test_voice : { + "target_workers" = "email=='aselo-alerts+production@techmatters.org'", + "friendly_name" = "E2E Test Queue (Voice)" } // survey : { // friendly_name = "Survey" diff --git a/twilio-iac/helplines/e2e/common.hcl b/twilio-iac/helplines/e2e/common.hcl index 2b20711115..c2376b0a43 100644 --- a/twilio-iac/helplines/e2e/common.hcl +++ b/twilio-iac/helplines/e2e/common.hcl @@ -52,6 +52,10 @@ locals { e2e_test : { "target_workers" = "email=='aselo-alerts+production@techmatters.org'", "friendly_name" = "E2E Test Queue" + }, + e2e_test_voice : { + "target_workers" = "email=='aselo-alerts+production@techmatters.org'", + "friendly_name" = "E2E Test Queue (Voice)" } } diff --git a/twilio-iac/helplines/e2e/development.hcl b/twilio-iac/helplines/e2e/development.hcl index 4cbd493785..67cde25a69 100644 --- a/twilio-iac/helplines/e2e/development.hcl +++ b/twilio-iac/helplines/e2e/development.hcl @@ -7,7 +7,7 @@ locals { local_config = { enable_external_recordings = true permission_config = "e2e" - custom_task_routing_filter_expression = "*(helpline IN ['Childline', ''] OR channelType =='web') AND isContactlessTask != true" + custom_task_routing_filter_expression = "*(helpline IN ['Childline', ''] OR channelType =='web' OR channelType = 'voice' OR channelType = 'sms') AND isContactlessTask != true" flow_vars = { service_sid = "ZS43ea9fdb2e1901c2fc23b4654b285202" environment_sid = "ZE0241494e654e208f715b4d9612171dc0" diff --git a/twilio-iac/helplines/templates/workflows/master.tftpl b/twilio-iac/helplines/templates/workflows/master.tftpl index 5082bdd9a1..291d9f6b2f 100644 --- a/twilio-iac/helplines/templates/workflows/master.tftpl +++ b/twilio-iac/helplines/templates/workflows/master.tftpl @@ -42,6 +42,16 @@ "queue": "${task_queues.e2e_test}" } ] + }, + { + "filter_friendly_name": "Voice E2E Test", + "expression": "channelType=='voice' AND name=='+12064083885'", + "targets": [ + { + "expression": "(worker.waitingOfflineContact != true AND ((task.channelType == 'voice' AND worker.channel.chat.assigned_tasks == 0) OR (task.channelType != 'voice' AND worker.channel.voice.assigned_tasks == 0)) AND ((task.transferTargetType == 'worker' AND task.targetSid == worker.sid) OR (task.transferTargetType != 'worker' AND worker.sid != task.ignoreAgent))) OR (worker.waitingOfflineContact == true AND task.targetSid == worker.sid AND task.isContactlessTask == true)", + "queue": "${task_queues.e2e_test_voice}" + } + ] } ] } From 9c75bccc90fe4f4360b910519ddee95c9144a38f Mon Sep 17 00:00:00 2001 From: Stephen Hand Date: Wed, 22 Jul 2026 19:28:27 +0100 Subject: [PATCH 15/53] Licence --- e2e-tests/twilio/voice.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/e2e-tests/twilio/voice.ts b/e2e-tests/twilio/voice.ts index f34cfb194d..4126c13de5 100644 --- a/e2e-tests/twilio/voice.ts +++ b/e2e-tests/twilio/voice.ts @@ -1,3 +1,19 @@ +/** + * 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 { getConfigValue } from '../config'; import twilio from 'twilio'; import VoiceResponse = twilio.twiml.VoiceResponse; From cfdb3b214623c0229ea6b06abe514d1e39ce9bf3 Mon Sep 17 00:00:00 2001 From: Stephen Hand Date: Wed, 15 Jul 2026 14:37:20 +0100 Subject: [PATCH 16/53] WIP add config and basic SMS test methods --- e2e-tests/config.ts | 40 ++++++++++++++++++++++++++ e2e-tests/twilio/channels.ts | 9 ++++-- e2e-tests/twilio/sms.ts | 55 ++++++++++++++++++++++++++++++++++++ e2e-tests/twilio/worker.ts | 7 ----- 4 files changed, 101 insertions(+), 10 deletions(-) create mode 100644 e2e-tests/twilio/sms.ts diff --git a/e2e-tests/config.ts b/e2e-tests/config.ts index 6831eb2cee..d7ac704d4a 100644 --- a/e2e-tests/config.ts +++ b/e2e-tests/config.ts @@ -41,6 +41,8 @@ export type Config = { }; const helplineShortCode = process.env.HL?.toLocaleLowerCase() || 'e2e'; +// Account to initiate calls into the helpline under test from, for testing voice & SMS +const clientHelplineShortCode = process.env.HL?.toLocaleLowerCase() || helplineShortCode; const helplineEnv = process.env.HL_ENV?.toLocaleLowerCase() || 'local'; const shouldLoadFromSsm = process.env.LOAD_SSM_CONFIG && process.env.LOAD_SSM_CONFIG !== 'false'; @@ -135,6 +137,20 @@ const configOptions: ConfigOptions = { ssmPath: () => `/${localOverrideEnv}/twilio/${getConfigValue('twilioAccountSid')}/auth_token`, }, + // The twilio account sid and auth token are used to target a flex account + clientTwilioAccountSid: { + envKey: 'TWILIO_ACCOUNT_SID', + ssmPath: `/${localOverrideEnv}/twilio/${clientHelplineShortCode.toUpperCase()}/account_sid`, + default: () => getConfigValue('twilioAccountSid'), + }, + clientTwilioAuthToken: { + envKey: 'TWILIO_AUTH_TOKEN', + // Order is important here. We use a function so that we can reference the clientTwilioAccountSid config value above. + ssmPath: () => + `/${localOverrideEnv}/twilio/${getConfigValue('clientTwilioAccountSid')}/auth_token`, + default: () => getConfigValue('twilioAuthToken'), + }, + // Turn on debug mode. Possibly unused. debug: { envKey: 'DEBUG', @@ -180,6 +196,30 @@ const configOptions: ConfigOptions = { default: `https://assets-${localOverrideEnv}.tl.techmatters.org/aselo-webchat-react-app/${helplineShortCode}/?e2eTestMode=true`, }, + // This should match the number set up for the Voice studio flow on the helpline under test + voicePhoneNumber: { + envKey: 'VOICE_PHONE_NUMBER', + default: '', + }, + + // This should match the number set up on the clientTwilioAccountSid that can make outgoing calls + clientVoicePhoneNumber: { + envKey: 'CLIENT_VOICE_PHONE_NUMBER', + default: '', + }, + + // This should match the number set up for the SMS studio flow on the helpline under test + smsPhoneNumber: { + envKey: 'SMS_PHONE_NUMBER', + default: () => getConfigValue('voicePhoneNumber'), + }, + + // This should match the number set up on the clientTwilioAccountSid that can send outgoing SMS messages + clientSmsPhoneNumber: { + envKey: 'CLIENT_SMS_PHONE_NUMBER', + default: () => getConfigValue('clientVoicePhoneNumber'), + }, + // inLambda is used to determine if we are running in a lambda or not and set other config values accordingly inLambda: { envKey: 'TEST_IN_LAMBDA', diff --git a/e2e-tests/twilio/channels.ts b/e2e-tests/twilio/channels.ts index 005290357b..211a118155 100644 --- a/e2e-tests/twilio/channels.ts +++ b/e2e-tests/twilio/channels.ts @@ -39,11 +39,11 @@ export const deleteChatChannels = async (): Promise => { const client = twilio(accountSid, authToken); // List all chat services - const services = await client.chat.v2.services.list(); + const services = await client.conversations.v1.services.list(); for (const service of services) { // List all users in this chat service - const users = await client.chat.v2.services(service.sid).users.list(); + const users = await client.conversations.v1.services(service.sid).users.list(); console.log(`Found ${users.length} users in service ${service.sid}`); const matchingUser = users.find((user) => user.identity === encodedEmail); @@ -65,7 +65,10 @@ export const deleteChatChannels = async (): Promise => { for (const userChannel of userChannels) { console.log(`Removing chat channel ${userChannel.channelSid} from service ${service.sid}`); - await client.chat.v2.services(service.sid).channels(userChannel.channelSid).remove(); + await client.conversations.v1.services + .get(service.sid) + .conversations.get(userChannel.channelSid) + .remove(); } } }; diff --git a/e2e-tests/twilio/sms.ts b/e2e-tests/twilio/sms.ts new file mode 100644 index 0000000000..175922f361 --- /dev/null +++ b/e2e-tests/twilio/sms.ts @@ -0,0 +1,55 @@ +import { getConfigValue } from '../config'; +import twilio from 'twilio'; +import type { ConversationInstance } from 'twilio/lib/rest/conversations/v1/conversation'; +import { AssertionError } from 'node:assert'; + +let clientConversation: ConversationInstance; + +export const sendSmsToService = async (messageText: string) => { + const accountSid = getConfigValue('clientTwilioAccountSid') as string; + const authToken = getConfigValue('clientTwilioAuthToken') as string; + const from = getConfigValue('clientSmsPhoneNumber') as string; + const to = getConfigValue('smsPhoneNumber') as string; + + const client = twilio(accountSid, authToken); + if (!clientConversation) { + clientConversation = await client.conversations.v1.conversations.create({ + friendlyName: 'E2E test client conversation', + + uniqueName: `sms/${from}/${Date.now()}`, + }); + await clientConversation.participants().create({ + identity: from, + }); + } + await client.messages.create({ from, to, body: messageText }); + console.debug(`Sent SMS message to service: '${messageText}'`); +}; +export const sendSmsFromService = async (messageText: string) => { + const accountSid = getConfigValue('twilioAccountSid') as string; + const authToken = getConfigValue('twilioAuthToken') as string; + const from = getConfigValue('smsPhoneNumber') as string; + const to = getConfigValue('clientSmsPhoneNumber') as string; + + const client = twilio(accountSid, authToken); + await client.messages.create({ from, to, body: messageText }); + console.debug(`Sent SMS message from service: '${messageText}'`); +}; + +const MAX_CHECKS = 10; + +export const checkForMessageOnClient = async (messageText: string): Promise => { + if (!clientConversation) { + throw new AssertionError({ + message: + "You cannot verify incoming messages until you've sent one and created a client side conversation", + }); + } + for (let i = 0; i < MAX_CHECKS; i++) { + const messages = await clientConversation.messages().list(); + if (messages.find((m) => m.body === messageText)) { + return true; + } + } + return false; +}; diff --git a/e2e-tests/twilio/worker.ts b/e2e-tests/twilio/worker.ts index f93f22931a..bef984e6dd 100644 --- a/e2e-tests/twilio/worker.ts +++ b/e2e-tests/twilio/worker.ts @@ -36,10 +36,3 @@ export const getSidForWorker = async (friendlyName: string): Promise => { - return page.evaluate(() => { - const manager = (window as any).Twilio.Flex.Manager.getInstance(); - return manager.workerClient.sid; - }); -}; From c7e8ebd401f7391f5ebaeb4e40b1e13fdff598df Mon Sep 17 00:00:00 2001 From: Stephen Hand Date: Wed, 15 Jul 2026 14:44:25 +0100 Subject: [PATCH 17/53] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- e2e-tests/config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/e2e-tests/config.ts b/e2e-tests/config.ts index d7ac704d4a..71b263e858 100644 --- a/e2e-tests/config.ts +++ b/e2e-tests/config.ts @@ -42,7 +42,7 @@ export type Config = { const helplineShortCode = process.env.HL?.toLocaleLowerCase() || 'e2e'; // Account to initiate calls into the helpline under test from, for testing voice & SMS -const clientHelplineShortCode = process.env.HL?.toLocaleLowerCase() || helplineShortCode; +const clientHelplineShortCode = process.env.CLIENT_HL?.toLocaleLowerCase() || helplineShortCode; const helplineEnv = process.env.HL_ENV?.toLocaleLowerCase() || 'local'; const shouldLoadFromSsm = process.env.LOAD_SSM_CONFIG && process.env.LOAD_SSM_CONFIG !== 'false'; From 78fe83e6fe06895d8f45dafcc368b2ef104a4946 Mon Sep 17 00:00:00 2001 From: Stephen Hand Date: Wed, 15 Jul 2026 14:44:44 +0100 Subject: [PATCH 18/53] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- e2e-tests/twilio/sms.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/e2e-tests/twilio/sms.ts b/e2e-tests/twilio/sms.ts index 175922f361..bb2682d160 100644 --- a/e2e-tests/twilio/sms.ts +++ b/e2e-tests/twilio/sms.ts @@ -50,6 +50,7 @@ export const checkForMessageOnClient = async (messageText: string): Promise m.body === messageText)) { return true; } + await new Promise((resolve) => setTimeout(resolve, 1000)); } return false; }; From aacc4b9dd3e1d7ad1583b9e4126286bec8889bda Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 15 Jul 2026 13:51:25 +0000 Subject: [PATCH 19/53] fix: use chat api for e2e channel cleanup lookup Co-authored-by: stephenhand <1694716+stephenhand@users.noreply.github.com> --- e2e-tests/twilio/channels.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/e2e-tests/twilio/channels.ts b/e2e-tests/twilio/channels.ts index 211a118155..f1ef09fe1c 100644 --- a/e2e-tests/twilio/channels.ts +++ b/e2e-tests/twilio/channels.ts @@ -39,11 +39,11 @@ export const deleteChatChannels = async (): Promise => { const client = twilio(accountSid, authToken); // List all chat services - const services = await client.conversations.v1.services.list(); + const services = await client.chat.v2.services.list(); for (const service of services) { // List all users in this chat service - const users = await client.conversations.v1.services(service.sid).users.list(); + const users = await client.chat.v2.services(service.sid).users.list(); console.log(`Found ${users.length} users in service ${service.sid}`); const matchingUser = users.find((user) => user.identity === encodedEmail); From 62bc1fea229e820d63778fdb0a2af266b36b661f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 15 Jul 2026 15:08:21 +0000 Subject: [PATCH 20/53] Initial plan From dcba9771d578557e1369520c21efc897a92f84d9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 15 Jul 2026 15:24:57 +0000 Subject: [PATCH 21/53] feat: add SMS E2E test mirroring webchat test with shared ChatStatement/AsyncIterable pattern Co-authored-by: stephenhand <1694716+stephenhand@users.noreply.github.com> --- e2e-tests/chatScripts.ts | 62 ++++++++++++++++++ e2e-tests/tests/sms.spec.ts | 127 ++++++++++++++++++++++++++++++++++++ e2e-tests/twilio/sms.ts | 83 ++++++++++++++++++----- 3 files changed, 255 insertions(+), 17 deletions(-) create mode 100644 e2e-tests/tests/sms.spec.ts diff --git a/e2e-tests/chatScripts.ts b/e2e-tests/chatScripts.ts index d069bff2c5..4b5cff13e3 100644 --- a/e2e-tests/chatScripts.ts +++ b/e2e-tests/chatScripts.ts @@ -80,3 +80,65 @@ export const getWebchatScript = (): ChatStatement[] => { return defaultScript; }; + +/** + * SMS scripts are structurally the same as webchat scripts (using the same ChatStatement format), + * but must start with a CALLER statement since the client must initiate an SMS conversation. + * BOT messages are expected to arrive after the client sends its first message. + */ +export const defaultSmsScript: ChatStatement[] = [ + callerStatement('hi'), + botStatement('Welcome to the helpline. Please answer the following questions.'), + callerStatement('yes'), + botStatement('How old are you?'), + callerStatement('10'), + botStatement('What is your gender?'), + callerStatement('girl'), + botStatement('We will transfer you now. Please hold for a counsellor.'), + counselorAutoStatement('Hi, this is the counsellor. How can I help you?'), + callerStatement('CALLER TEST SMS MESSAGE'), + counselorStatement('COUNSELLOR TEST SMS MESSAGE'), +]; + +export const smsCommonScripts: Record = { + ca: [ + callerStatement('CALLER TEST SMS MESSAGE'), + counselorAutoStatement("Hi, you've reached a counsellor. What would you like to talk about?"), + counselorStatement('COUNSELLOR TEST SMS MESSAGE'), + ], +}; + +export const smsEnvScripts: Record> = { + development: { + as: [ + callerStatement('hi'), + botStatement("Sorry, I didn't understand that. Please try again."), + callerStatement('hi'), + botStatement('Are you calling about yourself? Please answer Yes or No.'), + callerStatement('yes'), + botStatement('How old are you?'), + callerStatement('10'), + botStatement('What is your gender?'), + callerStatement('girl'), + botStatement("We'll transfer you now. Please hold for a counsellor."), + counselorAutoStatement('Hi, this is the counsellor. How can I help you?'), + callerStatement('CALLER TEST SMS MESSAGE'), + counselorStatement('COUNSELLOR TEST SMS MESSAGE'), + ], + }, +}; + +export const getSmsScript = (): ChatStatement[] => { + const helplineShortCode = getConfigValue('helplineShortCode') as string; + const helplineEnv = getConfigValue('helplineEnv') as string; + + if (smsEnvScripts[helplineEnv]?.[helplineShortCode]) { + return smsEnvScripts[helplineEnv][helplineShortCode]; + } + + if (smsCommonScripts[helplineShortCode]) { + return smsCommonScripts[helplineShortCode]; + } + + return defaultSmsScript; +}; diff --git a/e2e-tests/tests/sms.spec.ts b/e2e-tests/tests/sms.spec.ts new file mode 100644 index 0000000000..2488ee8a8f --- /dev/null +++ b/e2e-tests/tests/sms.spec.ts @@ -0,0 +1,127 @@ +/** + * 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 { Page, request, test } from '@playwright/test'; +import { statusIndicator } from '../workerStatus'; +import { ChatStatement, ChatStatementOrigin } from '../chatModel'; +import { getSmsScript } from '../chatScripts'; +import { flexChat } from '../flexChat'; +import { skipTestIfNotTargeted } from '../skipTest'; +import { tasks } from '../tasks'; +import { Categories, contactForm, ContactFormTab } from '../contactForm'; +import { deleteAllTasksInQueue } from '../twilio/tasks'; +import { notificationBar } from '../notificationBar'; +import { clickThroughTwilioPasteModals } from '../agent-desktop'; +import { setupContextAndPage, closePage } from '../browser'; +import { clearOfflineTask } from '../hrm/clearOfflineTask'; +import { apiHrmRequest } from '../hrm/hrmRequest'; +import { formContentsByHelpline } from '../formContentsByHelpline'; +import { getConfigValue } from '../config'; +import { smsChat } from '../twilio/sms'; + +test.describe.serial('SMS caller', () => { + skipTestIfNotTargeted(); + + let pluginPage: Page; + + test.beforeAll(async ({ browser }) => { + test.setTimeout(180000); + ({ page: pluginPage } = await setupContextAndPage(browser)); + + await clearOfflineTask( + apiHrmRequest(await request.newContext(), process.env.FLEX_TOKEN!), + process.env.LOGGED_IN_WORKER_SID!, + ); + console.info('SMS E2E test - plugin page launched.'); + + await clickThroughTwilioPasteModals(pluginPage); + console.info('Plugin page visited.'); + }); + + test.afterAll(async () => { + await statusIndicator(pluginPage)?.setStatus('OFFLINE'); + if (pluginPage) { + await notificationBar(pluginPage).dismissAllNotifications(); + } + await closePage(pluginPage); + await deleteAllTasksInQueue(); + }); + + test.afterEach(async () => { + await deleteAllTasksInQueue(); + }); + + test('Chat', async () => { + test.setTimeout(180000); + + const chatScript = getSmsScript(); + + // smsChat handles the client (caller) side via the Twilio Messages API. + // flexChat handles the counselor side via the Flex browser UI. + // Both iterate the same shared script, yielding control when they hit a + // statement the other side needs to handle — the same pattern used by the + // Aselo webchat test. + const smsChatProgress = smsChat(chatScript); + const flexChatProgress: AsyncIterator = flexChat(pluginPage).chat(chatScript); + + for await (const expectedCounselorStatement of smsChatProgress) { + console.info('Statement for flex chat to process', expectedCounselorStatement); + if (expectedCounselorStatement) { + switch (expectedCounselorStatement.origin) { + case ChatStatementOrigin.COUNSELOR_AUTO: + await statusIndicator(pluginPage).setStatus('AVAILABLE'); + await tasks(pluginPage).acceptNextTask(); + await flexChatProgress.next(); + break; + default: + await flexChatProgress.next(); + break; + } + } + } + + console.info('Starting filling form'); + const helpline = getConfigValue('helplineShortCode') as keyof typeof formContentsByHelpline; + const formContent = formContentsByHelpline[helpline]; + if (!formContent) { + throw new Error(`No form contents configured for helplineShortCode="${String(helpline)}"`); + } + const form = contactForm(pluginPage); + await form.fill([ + { + id: 'childInformation', + label: 'TabbedForms-AddChildInfoTab', + fill: form.fillStandardTab, + items: formContent.childInformation, + }, + >{ + id: 'categories', + label: 'TabbedForms-CategoriesTab', + fill: form.fillCategoriesTab, + items: formContent.categories, + }, + { + id: 'caseInformation', + label: 'TabbedForms-AddCaseInfoTab', + fill: form.fillStandardTab, + items: formContent.caseInformation, + }, + ]); + + console.info('Saving form'); + await form.save(); + }); +}); diff --git a/e2e-tests/twilio/sms.ts b/e2e-tests/twilio/sms.ts index bb2682d160..5e52833f20 100644 --- a/e2e-tests/twilio/sms.ts +++ b/e2e-tests/twilio/sms.ts @@ -1,30 +1,26 @@ import { getConfigValue } from '../config'; +// eslint-disable-next-line import/no-extraneous-dependencies import twilio from 'twilio'; -import type { ConversationInstance } from 'twilio/lib/rest/conversations/v1/conversation'; import { AssertionError } from 'node:assert'; +import { ChatStatement, ChatStatementOrigin } from '../chatModel'; -let clientConversation: ConversationInstance; +// Tracks the start of the current SMS test session so we only check messages received after this time +let sessionStartTime: Date | undefined; export const sendSmsToService = async (messageText: string) => { + if (!sessionStartTime) { + sessionStartTime = new Date(); + } const accountSid = getConfigValue('clientTwilioAccountSid') as string; const authToken = getConfigValue('clientTwilioAuthToken') as string; const from = getConfigValue('clientSmsPhoneNumber') as string; const to = getConfigValue('smsPhoneNumber') as string; const client = twilio(accountSid, authToken); - if (!clientConversation) { - clientConversation = await client.conversations.v1.conversations.create({ - friendlyName: 'E2E test client conversation', - - uniqueName: `sms/${from}/${Date.now()}`, - }); - await clientConversation.participants().create({ - identity: from, - }); - } await client.messages.create({ from, to, body: messageText }); console.debug(`Sent SMS message to service: '${messageText}'`); }; + export const sendSmsFromService = async (messageText: string) => { const accountSid = getConfigValue('twilioAccountSid') as string; const authToken = getConfigValue('twilioAuthToken') as string; @@ -38,19 +34,72 @@ export const sendSmsFromService = async (messageText: string) => { const MAX_CHECKS = 10; +/** + * Checks whether the given message text was received by the SMS client (i.e., sent from the + * service to the client phone number) at any point since the current session started. + * Uses the service Twilio account to list outbound messages to the client number. + */ export const checkForMessageOnClient = async (messageText: string): Promise => { - if (!clientConversation) { + if (!sessionStartTime) { throw new AssertionError({ - message: - "You cannot verify incoming messages until you've sent one and created a client side conversation", + message: "You cannot verify incoming messages until you've sent one and started a session", }); } + const accountSid = getConfigValue('twilioAccountSid') as string; + const authToken = getConfigValue('twilioAuthToken') as string; + const to = getConfigValue('clientSmsPhoneNumber') as string; + + const client = twilio(accountSid, authToken); + const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); for (let i = 0; i < MAX_CHECKS; i++) { - const messages = await clientConversation.messages().list(); + const messages = await client.messages.list({ to, dateSentAfter: sessionStartTime }); if (messages.find((m) => m.body === messageText)) { return true; } - await new Promise((resolve) => setTimeout(resolve, 1000)); + await delay(1000); } return false; }; + +/** + * Asserts that the given message text was received by the SMS client within the polling window. + * Throws if the message is not found. + */ +const assertMessageReceivedOnClient = async (messageText: string): Promise => { + const received = await checkForMessageOnClient(messageText); + if (!received) { + throw new AssertionError({ + message: `SMS message not received on client: '${messageText}'`, + }); + } +}; + +/** + * Runs the 'client side' of an SMS conversation using the Twilio Messages API. + * It loops through a list of chat statements, sending caller SMS messages via the API and + * polling for expected bot/counselor messages on the client number. + * As soon as it hits a counselor statement (COUNSELOR or COUNSELOR_AUTO), it yields execution + * back to the calling code so it can action those statements in Flex. + * + * A similar function exists in flexChat.ts to handle the counselor side of the conversation. + * Both iterate the same shared ChatStatement list, yielding control when they hit a statement + * the other side needs to handle. + * @param statements - a unified list of all the chat statements in a conversation + */ +export async function* smsChat(statements: ChatStatement[]): AsyncGenerator { + for (const statementItem of statements) { + const { text, origin } = statementItem; + switch (origin) { + case ChatStatementOrigin.CALLER: + await sendSmsToService(text); + break; + case ChatStatementOrigin.BOT: { + await assertMessageReceivedOnClient(text); + break; + } + default: + yield statementItem; + await assertMessageReceivedOnClient(text); + } + } +} From dece3bcee8ccc4820d01ba5c7763bb76a94b65ea Mon Sep 17 00:00:00 2001 From: Stephen Hand Date: Wed, 15 Jul 2026 17:28:52 +0100 Subject: [PATCH 22/53] Licence headers --- e2e-tests/twilio/sms.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/e2e-tests/twilio/sms.ts b/e2e-tests/twilio/sms.ts index 5e52833f20..e4af5df8ad 100644 --- a/e2e-tests/twilio/sms.ts +++ b/e2e-tests/twilio/sms.ts @@ -1,3 +1,19 @@ +/** + * 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 { getConfigValue } from '../config'; // eslint-disable-next-line import/no-extraneous-dependencies import twilio from 'twilio'; From fbbc441de0c6b509c52082a8f8eaf65948a95508 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 17 Jul 2026 18:03:15 +0000 Subject: [PATCH 23/53] feat: add SMS channel for E2E development environment - Add SMS channel to twilio-iac/helplines/e2e/development.hcl using the messaging-lex-v3-blocking-lambda.tftpl template (same as aselo_webchat) and an empty contact_identity (conversations address managed separately) - Create twilio-iac/helplines/e2e/files/additional.configure.tf that uses a Twilio data source to look up the only phone number attached to the account at apply time and creates the SMS conversations address linked to the SMS studio flow - Guard twilio_conversations_configuration_addresses_v1 in channels/v1/main.tf so channels with an empty contact_identity skip automatic address creation (allowing helpline-specific additional.tf to manage it instead) Co-authored-by: stephenhand <1694716+stephenhand@users.noreply.github.com> --- twilio-iac/helplines/e2e/development.hcl | 11 ++++++++++ .../e2e/files/additional.configure.tf | 20 +++++++++++++++++++ .../terraform-modules/channels/v1/main.tf | 4 +++- 3 files changed, 34 insertions(+), 1 deletion(-) create mode 100644 twilio-iac/helplines/e2e/files/additional.configure.tf diff --git a/twilio-iac/helplines/e2e/development.hcl b/twilio-iac/helplines/e2e/development.hcl index eafb13d33c..f90e2140d9 100644 --- a/twilio-iac/helplines/e2e/development.hcl +++ b/twilio-iac/helplines/e2e/development.hcl @@ -35,6 +35,17 @@ locals { channel_flow_vars = {} chatbot_unique_names = [] } + sms : { + channel_type = "sms" + messaging_mode = "conversations" + # contact_identity is intentionally empty here; the conversations address is created + # via additional.configure.tf using a data source that resolves the only phone number + # attached to this Twilio account at apply time. + contact_identity = "" + templatefile = "/app/twilio-iac/helplines/templates/studio-flows/messaging-lex-v3-blocking-lambda.tftpl" + channel_flow_vars = {} + chatbot_unique_names = [] + } } get_profile_flags_for_identifier_base_url = "https://hrm-development.tl.techmatters.org/lambda/twilio/account-scoped" #System Down Configuration diff --git a/twilio-iac/helplines/e2e/files/additional.configure.tf b/twilio-iac/helplines/e2e/files/additional.configure.tf new file mode 100644 index 0000000000..0de4d3fba3 --- /dev/null +++ b/twilio-iac/helplines/e2e/files/additional.configure.tf @@ -0,0 +1,20 @@ +# Dynamically resolve the single phone number attached to this Twilio account and +# configure it as the SMS conversations address, linked to the SMS studio flow created +# by the channels module. +# +# Using a data source rather than hardcoding the number keeps the config portable: +# the E2E Twilio account has exactly one phone number, so one() is intentionally +# strict here – if the account ever has zero or more than one number the apply will +# fail with a clear error. + +data "twilio_api_accounts_incoming_phone_numbers_v2010" "all" {} + +resource "twilio_conversations_configuration_addresses_v1" "sms" { + type = "sms" + address = one(data.twilio_api_accounts_incoming_phone_numbers_v2010.all.incoming_phone_numbers).phone_number + friendly_name = "Sms Conversation Address" + auto_creation_enabled = true + auto_creation_type = "studio" + auto_creation_conversation_service_sid = local.services_flex_chat_service_sid + auto_creation_studio_flow_sid = module.channel.channel_studio_flows_sids["sms"].flow_sid +} diff --git a/twilio-iac/terraform-modules/channels/v1/main.tf b/twilio-iac/terraform-modules/channels/v1/main.tf index 21e6d961bd..20af2664b1 100644 --- a/twilio-iac/terraform-modules/channels/v1/main.tf +++ b/twilio-iac/terraform-modules/channels/v1/main.tf @@ -107,7 +107,9 @@ resource "twilio_conversations_configuration_addresses_v1" "conversations_addres # Must be created manually in Twilio Console for now channel.channel_type != "chat" && channel.channel_type != "custom" && - channel.messaging_mode == "conversations" + channel.messaging_mode == "conversations" && + # Channels with no contact_identity manage their own conversations address (e.g. via additional.tf) + channel.contact_identity != "" ) } type = each.value.channel_type From 49b86ec47f69a089bb33a7f295a4f1f13b337872 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 17 Jul 2026 18:03:57 +0000 Subject: [PATCH 24/53] fix: use consistent SMS capitalization in friendly_name Co-authored-by: stephenhand <1694716+stephenhand@users.noreply.github.com> --- twilio-iac/helplines/e2e/files/additional.configure.tf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/twilio-iac/helplines/e2e/files/additional.configure.tf b/twilio-iac/helplines/e2e/files/additional.configure.tf index 0de4d3fba3..e8c9d036d9 100644 --- a/twilio-iac/helplines/e2e/files/additional.configure.tf +++ b/twilio-iac/helplines/e2e/files/additional.configure.tf @@ -12,7 +12,7 @@ data "twilio_api_accounts_incoming_phone_numbers_v2010" "all" {} resource "twilio_conversations_configuration_addresses_v1" "sms" { type = "sms" address = one(data.twilio_api_accounts_incoming_phone_numbers_v2010.all.incoming_phone_numbers).phone_number - friendly_name = "Sms Conversation Address" + friendly_name = "SMS Conversation Address" auto_creation_enabled = true auto_creation_type = "studio" auto_creation_conversation_service_sid = local.services_flex_chat_service_sid From d65a26d1dd0f32b08cceb3ee30adea3e53f1142c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 20 Jul 2026 14:19:00 +0000 Subject: [PATCH 25/53] refactor: hardcode E2E SMS channel phone number +12607821891 - Replace dynamic phone number lookup with hardcoded +12607821891 in both development.hcl and additional.configure.tf - Remove data source that previously scraped the phone number from the Twilio account - Set contact_identity to the hardcoded phone number in the SMS channel configuration Co-authored-by: stephenhand <1694716+stephenhand@users.noreply.github.com> --- twilio-iac/helplines/e2e/development.hcl | 5 +---- .../helplines/e2e/files/additional.configure.tf | 13 ++----------- 2 files changed, 3 insertions(+), 15 deletions(-) diff --git a/twilio-iac/helplines/e2e/development.hcl b/twilio-iac/helplines/e2e/development.hcl index f90e2140d9..ff1a8358d8 100644 --- a/twilio-iac/helplines/e2e/development.hcl +++ b/twilio-iac/helplines/e2e/development.hcl @@ -38,10 +38,7 @@ locals { sms : { channel_type = "sms" messaging_mode = "conversations" - # contact_identity is intentionally empty here; the conversations address is created - # via additional.configure.tf using a data source that resolves the only phone number - # attached to this Twilio account at apply time. - contact_identity = "" + contact_identity = "+12607821891" templatefile = "/app/twilio-iac/helplines/templates/studio-flows/messaging-lex-v3-blocking-lambda.tftpl" channel_flow_vars = {} chatbot_unique_names = [] diff --git a/twilio-iac/helplines/e2e/files/additional.configure.tf b/twilio-iac/helplines/e2e/files/additional.configure.tf index e8c9d036d9..944ff076bd 100644 --- a/twilio-iac/helplines/e2e/files/additional.configure.tf +++ b/twilio-iac/helplines/e2e/files/additional.configure.tf @@ -1,17 +1,8 @@ -# Dynamically resolve the single phone number attached to this Twilio account and -# configure it as the SMS conversations address, linked to the SMS studio flow created -# by the channels module. -# -# Using a data source rather than hardcoding the number keeps the config portable: -# the E2E Twilio account has exactly one phone number, so one() is intentionally -# strict here – if the account ever has zero or more than one number the apply will -# fail with a clear error. - -data "twilio_api_accounts_incoming_phone_numbers_v2010" "all" {} +# Configure the SMS conversations address with the hardcoded phone number +12607821891. resource "twilio_conversations_configuration_addresses_v1" "sms" { type = "sms" - address = one(data.twilio_api_accounts_incoming_phone_numbers_v2010.all.incoming_phone_numbers).phone_number + address = "+12607821891" friendly_name = "SMS Conversation Address" auto_creation_enabled = true auto_creation_type = "studio" From 5e704584ecaf4e44b71b0dd0bd2470539becfa2b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 20 Jul 2026 14:26:26 +0000 Subject: [PATCH 26/53] refactor: remove redundant additional.configure.tf for SMS address The twilio-iac/terraform-modules/channels/v1/main.tf module now automatically creates the SMS conversations address since contact_identity is no longer empty. The separate additional.configure.tf file is no longer needed. Co-authored-by: stephenhand <1694716+stephenhand@users.noreply.github.com> --- .../helplines/e2e/files/additional.configure.tf | 11 ----------- 1 file changed, 11 deletions(-) delete mode 100644 twilio-iac/helplines/e2e/files/additional.configure.tf diff --git a/twilio-iac/helplines/e2e/files/additional.configure.tf b/twilio-iac/helplines/e2e/files/additional.configure.tf deleted file mode 100644 index 944ff076bd..0000000000 --- a/twilio-iac/helplines/e2e/files/additional.configure.tf +++ /dev/null @@ -1,11 +0,0 @@ -# Configure the SMS conversations address with the hardcoded phone number +12607821891. - -resource "twilio_conversations_configuration_addresses_v1" "sms" { - type = "sms" - address = "+12607821891" - friendly_name = "SMS Conversation Address" - auto_creation_enabled = true - auto_creation_type = "studio" - auto_creation_conversation_service_sid = local.services_flex_chat_service_sid - auto_creation_studio_flow_sid = module.channel.channel_studio_flows_sids["sms"].flow_sid -} From fc9a0b41877e14b46b85e1899150ab68d1b921ea Mon Sep 17 00:00:00 2001 From: Stephen Hand Date: Wed, 22 Jul 2026 12:52:26 +0100 Subject: [PATCH 27/53] WIP voice testing --- e2e-tests/config.ts | 19 ++- ...ChatChannels.ts => deleteConversations.ts} | 5 +- e2e-tests/package.json | 6 +- e2e-tests/tests/sms.spec.ts | 2 + e2e-tests/tests/voice.spec.ts | 111 ++++++++++++++++ e2e-tests/twilio/channels.ts | 122 ++++++++++++------ e2e-tests/twilio/sms.ts | 46 ++++--- e2e-tests/twilio/voice.ts | 27 ++++ 8 files changed, 268 insertions(+), 70 deletions(-) rename e2e-tests/{deleteChatChannels.ts => deleteConversations.ts} (88%) create mode 100644 e2e-tests/tests/voice.spec.ts create mode 100644 e2e-tests/twilio/voice.ts diff --git a/e2e-tests/config.ts b/e2e-tests/config.ts index 71b263e858..26cef92eb1 100644 --- a/e2e-tests/config.ts +++ b/e2e-tests/config.ts @@ -42,8 +42,9 @@ export type Config = { const helplineShortCode = process.env.HL?.toLocaleLowerCase() || 'e2e'; // Account to initiate calls into the helpline under test from, for testing voice & SMS -const clientHelplineShortCode = process.env.CLIENT_HL?.toLocaleLowerCase() || helplineShortCode; const helplineEnv = process.env.HL_ENV?.toLocaleLowerCase() || 'local'; +const clientHelplineShortCode = process.env.CLIENT_HL?.toLocaleLowerCase() || 'as'; +const clientHelplineEnv = process.env.CLIENT_HL_ENV?.toLocaleLowerCase() || 'development'; const shouldLoadFromSsm = process.env.LOAD_SSM_CONFIG && process.env.LOAD_SSM_CONFIG !== 'false'; // These are environments where we want to avoid tests or steps that update HRM data @@ -139,16 +140,14 @@ const configOptions: ConfigOptions = { // The twilio account sid and auth token are used to target a flex account clientTwilioAccountSid: { - envKey: 'TWILIO_ACCOUNT_SID', - ssmPath: `/${localOverrideEnv}/twilio/${clientHelplineShortCode.toUpperCase()}/account_sid`, - default: () => getConfigValue('twilioAccountSid'), + envKey: 'CLIENT_TWILIO_ACCOUNT_SID', + ssmPath: `/${clientHelplineEnv}/twilio/${clientHelplineShortCode.toUpperCase()}/account_sid`, }, clientTwilioAuthToken: { - envKey: 'TWILIO_AUTH_TOKEN', + envKey: 'CLIENT_TWILIO_AUTH_TOKEN', // Order is important here. We use a function so that we can reference the clientTwilioAccountSid config value above. ssmPath: () => - `/${localOverrideEnv}/twilio/${getConfigValue('clientTwilioAccountSid')}/auth_token`, - default: () => getConfigValue('twilioAuthToken'), + `/${clientHelplineEnv}/twilio/${getConfigValue('clientTwilioAccountSid')}/auth_token`, }, // Turn on debug mode. Possibly unused. @@ -199,13 +198,13 @@ const configOptions: ConfigOptions = { // This should match the number set up for the Voice studio flow on the helpline under test voicePhoneNumber: { envKey: 'VOICE_PHONE_NUMBER', - default: '', + default: '+12607821891', }, // This should match the number set up on the clientTwilioAccountSid that can make outgoing calls clientVoicePhoneNumber: { envKey: 'CLIENT_VOICE_PHONE_NUMBER', - default: '', + default: '+12064083885', }, // This should match the number set up for the SMS studio flow on the helpline under test @@ -296,7 +295,7 @@ const setConfigValueFromSsm = async (key: string) => { throw err; } - console.log(`Failed to load config value from SSM at ${option.ssmPath}. Using default value`); + console.warn(`Failed to load config value from SSM at ${option.ssmPath}. Using default value`); setConfigValue(key, typeof option.default === 'function' ? option.default() : option.default); } diff --git a/e2e-tests/deleteChatChannels.ts b/e2e-tests/deleteConversations.ts similarity index 88% rename from e2e-tests/deleteChatChannels.ts rename to e2e-tests/deleteConversations.ts index 3c15d958c0..59b33ed447 100644 --- a/e2e-tests/deleteChatChannels.ts +++ b/e2e-tests/deleteConversations.ts @@ -23,12 +23,13 @@ * send new messages from the e2e test user. */ -import { deleteChatChannels } from './twilio/channels'; +import { deleteChatConversations, deleteSmsConversations } from './twilio/channels'; import { initConfig } from './config'; const main = async () => { await initConfig(); - await deleteChatChannels(); + await deleteChatConversations(); + await deleteSmsConversations(); }; main(); diff --git a/e2e-tests/package.json b/e2e-tests/package.json index 14da237ed9..9323999c69 100644 --- a/e2e-tests/package.json +++ b/e2e-tests/package.json @@ -5,12 +5,12 @@ "main": "index.js", "scripts": { "postinstall": "npx playwright install chromium", - "deleteChatChannels": "tsx deleteChatChannels.ts", + "deleteChatChannels": "tsx deleteConversations.ts", "test": "npx playwright test --workers 1 ", "test:ui": "npx playwright test --workers 1 --config ui-tests/playwright.ui-test.config.ts", - "test:local": "cross-env DEBUG=pw:api LOAD_SSM_CONFIG=true npm run test", + "test:local": "cross-env DEBUG=pw:api LOAD_SSM_CONFIG=true npm run test -- --retries 0 voice", "test:local-aselo-webchat": "cross-env DEBUG=pw:api LOAD_SSM_CONFIG=true ASELO_WEBCHAT_URL=http://localhost:3001 npm run test -- --headed --debug --retries 0 aseloWebchat", - "test:local:debug": "cross-env DEBUG=pw:api LOAD_SSM_CONFIG=true npm run test -- --headed --debug --retries 0 offline", + "test:local:debug": "cross-env DEBUG=pw:api LOAD_SSM_CONFIG=true npm run test -- --headed --debug --retries 0 voice", "test:development:as": "cross-env DEBUG=pw:api LOAD_SSM_CONFIG=true HL_ENV=development HL=as SKIP_DATA_UPDATE=true npm run test", "test:development:as:debug": "cross-env DEBUG=pw:api LOAD_SSM_CONFIG=true HL_ENV=development HL=as SKIP_DATA_UPDATE=true npm run test -- --headed --retries 0", "test:development:e2e": "cross-env DEBUG=pw:api LOAD_SSM_CONFIG=true HL_ENV=development HL=e2e npm run test", diff --git a/e2e-tests/tests/sms.spec.ts b/e2e-tests/tests/sms.spec.ts index 2488ee8a8f..5d16c47fc5 100644 --- a/e2e-tests/tests/sms.spec.ts +++ b/e2e-tests/tests/sms.spec.ts @@ -31,6 +31,7 @@ import { apiHrmRequest } from '../hrm/hrmRequest'; import { formContentsByHelpline } from '../formContentsByHelpline'; import { getConfigValue } from '../config'; import { smsChat } from '../twilio/sms'; +import { deleteSmsConversations } from '../twilio/channels'; test.describe.serial('SMS caller', () => { skipTestIfNotTargeted(); @@ -39,6 +40,7 @@ test.describe.serial('SMS caller', () => { test.beforeAll(async ({ browser }) => { test.setTimeout(180000); + await deleteSmsConversations(); ({ page: pluginPage } = await setupContextAndPage(browser)); await clearOfflineTask( diff --git a/e2e-tests/tests/voice.spec.ts b/e2e-tests/tests/voice.spec.ts new file mode 100644 index 0000000000..b9b8c616bc --- /dev/null +++ b/e2e-tests/tests/voice.spec.ts @@ -0,0 +1,111 @@ +/** + * 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 { Page, request, test } from '@playwright/test'; +import { statusIndicator } from '../workerStatus'; +import { ChatStatement, ChatStatementOrigin } from '../chatModel'; +import { getSmsScript } from '../chatScripts'; +import { flexChat } from '../flexChat'; +import { skipTestIfNotTargeted } from '../skipTest'; +import { tasks } from '../tasks'; +import { Categories, contactForm, ContactFormTab } from '../contactForm'; +import { deleteAllTasksInQueue } from '../twilio/tasks'; +import { notificationBar } from '../notificationBar'; +import { clickThroughTwilioPasteModals } from '../agent-desktop'; +import { setupContextAndPage, closePage } from '../browser'; +import { clearOfflineTask } from '../hrm/clearOfflineTask'; +import { apiHrmRequest } from '../hrm/hrmRequest'; +import { formContentsByHelpline } from '../formContentsByHelpline'; +import { getConfigValue } from '../config'; +import { smsChat } from '../twilio/sms'; +import { makeCallToService } from '../twilio/voice'; + +test.describe.serial('SMS caller', () => { + skipTestIfNotTargeted(); + + let pluginPage: Page; + + test.beforeAll(async ({ browser }) => { + test.setTimeout(180000); + ({ page: pluginPage } = await setupContextAndPage(browser)); + + await clearOfflineTask( + apiHrmRequest(await request.newContext(), process.env.FLEX_TOKEN!), + process.env.LOGGED_IN_WORKER_SID!, + ); + console.info('Voice E2E test - plugin page launched.'); + + await clickThroughTwilioPasteModals(pluginPage); + console.info('Plugin page visited.'); + }); + + test.afterAll(async () => { + await statusIndicator(pluginPage)?.setStatus('OFFLINE'); + if (pluginPage) { + await notificationBar(pluginPage).dismissAllNotifications(); + } + await closePage(pluginPage); + await deleteAllTasksInQueue(); + }); + + test.afterEach(async () => { + await deleteAllTasksInQueue(); + }); + + test('Chat', async () => { + test.setTimeout(180000); + + const chatScript = getSmsScript(); + + // smsChat handles the client (caller) side via the Twilio Messages API. + // flexChat handles the counselor side via the Flex browser UI. + // Both iterate the same shared script, yielding control when they hit a + // statement the other side needs to handle — the same pattern used by the + // Aselo webchat test. + await makeCallToService(); + + console.info('Starting filling form'); + const helpline = getConfigValue('helplineShortCode') as keyof typeof formContentsByHelpline; + const formContent = formContentsByHelpline[helpline]; + if (!formContent) { + throw new Error(`No form contents configured for helplineShortCode="${String(helpline)}"`); + } + const form = contactForm(pluginPage); + await form.fill([ + { + id: 'childInformation', + label: 'TabbedForms-AddChildInfoTab', + fill: form.fillStandardTab, + items: formContent.childInformation, + }, + >{ + id: 'categories', + label: 'TabbedForms-CategoriesTab', + fill: form.fillCategoriesTab, + items: formContent.categories, + }, + { + id: 'caseInformation', + label: 'TabbedForms-AddCaseInfoTab', + fill: form.fillStandardTab, + items: formContent.caseInformation, + }, + ]); + + console.info('Saving form'); + await form.save(); + }); +}); diff --git a/e2e-tests/twilio/channels.ts b/e2e-tests/twilio/channels.ts index f1ef09fe1c..1aeb62b168 100644 --- a/e2e-tests/twilio/channels.ts +++ b/e2e-tests/twilio/channels.ts @@ -15,7 +15,7 @@ */ // eslint-disable-next-line import/no-extraneous-dependencies -import twilio from 'twilio'; +import twilio, { Twilio } from 'twilio'; import { getConfigValue } from '../config'; const encodeEmailToUnicode = (email: string) => { @@ -30,7 +30,66 @@ const encodeEmailToUnicode = (email: string) => { .join(''); }; -export const deleteChatChannels = async (): Promise => { +const deleteSmsConversationFromOneEnd = async ( + accountSid: string, + authToken: string, + fromNumber: string, + toNumber: string, +) => { + const client = twilio(accountSid, authToken); + const activeConversations = await client.conversations.v1.conversations.list({ + state: 'active', + }); + console.info(`${activeConversations.length} active conversations found.`); + await Promise.all( + activeConversations.map(async (conversation) => { + const participants = await conversation.participants().list(); + + if ( + // eslint-disable-next-line @typescript-eslint/no-loop-func + participants.some((participant) => { + return ( + participant.messagingBinding?.address === fromNumber && + participant.messagingBinding?.proxy_address === toNumber + ); + }) + ) { + console.info( + `Found a participant with the from SMS number address (${fromNumber}) and to SMS number proxy address (${toNumber}), attempting to close conversation ${conversation.sid} from ${accountSid}`, + ); + await conversation.update({ state: 'closed' }); + } + }), + ); +}; + +export const deleteSmsConversations = async (): Promise => { + const serviceAccountSid = getConfigValue('twilioAccountSid') as string; + const serviceAuthToken = getConfigValue('twilioAuthToken') as string; + const serviceSmsNumber = getConfigValue('smsPhoneNumber') as string; + + const senderAccountSid = getConfigValue('clientTwilioAccountSid') as string; + const senderAuthToken = getConfigValue('clientTwilioAuthToken') as string; + const senderSmsNumber = getConfigValue('clientSmsPhoneNumber') as string; + + // Delete conversations from service Twilio account + await deleteSmsConversationFromOneEnd( + serviceAccountSid, + serviceAuthToken, + senderSmsNumber, + serviceSmsNumber, + ); + + // Delete conversations from sender Twilio account + await deleteSmsConversationFromOneEnd( + senderAccountSid, + senderAuthToken, + serviceSmsNumber, + senderSmsNumber, + ); +}; + +export const deleteChatConversations = async (): Promise => { const accountSid = getConfigValue('twilioAccountSid') as string; const authToken = getConfigValue('twilioAuthToken') as string; const email = getConfigValue('oktaUsername') as string; @@ -38,45 +97,36 @@ export const deleteChatChannels = async (): Promise => { const client = twilio(accountSid, authToken); - // List all chat services - const services = await client.chat.v2.services.list(); - - for (const service of services) { - // List all users in this chat service - const users = await client.chat.v2.services(service.sid).users.list(); - console.log(`Found ${users.length} users in service ${service.sid}`); - const matchingUser = users.find((user) => user.identity === encodedEmail); - - if (!matchingUser) { - continue; - } - - console.log(`Found user ${email} in service ${service.sid}`); - - // List all channels the matching user is a part of - const userChannels = await client.chat.v2 - .services(service.sid) - .users(matchingUser.sid) - .userChannels.list(); - - console.log( - `Found ${userChannels.length} chat channels for user ${email} in service ${service.sid}`, - ); - - for (const userChannel of userChannels) { - console.log(`Removing chat channel ${userChannel.channelSid} from service ${service.sid}`); - await client.conversations.v1.services - .get(service.sid) - .conversations.get(userChannel.channelSid) - .remove(); - } + // List all users in this chat service + const users = await client.conversations.v1.users.list(); + console.debug(`Found ${users.length} users in conversations`); + const matchingUser = users.find((user) => user.identity === encodedEmail); + + if (!matchingUser) { + return; + } + + console.info(`Found user ${email} in conversations`); + + // List all channels the matching user is a part of + const userConversations = await client.conversations.v1.users + .get(matchingUser.sid) + .userConversations.list(); + + console.debug(`Found ${userConversations.length} chat channels for user ${email}`); + + for (const { conversationSid } of userConversations) { + console.debug(`Removing chat channel ${conversationSid}`); + await client.conversations.v1.conversations.get(conversationSid).remove(); } }; // Handle exit signals process.on('SIGINT', () => { - deleteChatChannels().catch((err) => console.error(err)); + deleteChatConversations().catch((err) => console.error(err)); + deleteSmsConversations().catch((err) => console.error(err)); }); process.on('SIGTERM', () => { - deleteChatChannels().catch((err) => console.error(err)); + deleteChatConversations().catch((err) => console.error(err)); + deleteSmsConversations().catch((err) => console.error(err)); }); diff --git a/e2e-tests/twilio/sms.ts b/e2e-tests/twilio/sms.ts index e4af5df8ad..e4a01853e9 100644 --- a/e2e-tests/twilio/sms.ts +++ b/e2e-tests/twilio/sms.ts @@ -23,31 +23,36 @@ import { ChatStatement, ChatStatementOrigin } from '../chatModel'; // Tracks the start of the current SMS test session so we only check messages received after this time let sessionStartTime: Date | undefined; +let clientConversationSid: string; + export const sendSmsToService = async (messageText: string) => { if (!sessionStartTime) { sessionStartTime = new Date(); } - const accountSid = getConfigValue('clientTwilioAccountSid') as string; + const clientAccountSid = getConfigValue('clientTwilioAccountSid') as string; const authToken = getConfigValue('clientTwilioAuthToken') as string; const from = getConfigValue('clientSmsPhoneNumber') as string; + const serviceAccountSid = getConfigValue('twilioAccountSid') as string; const to = getConfigValue('smsPhoneNumber') as string; - const client = twilio(accountSid, authToken); - await client.messages.create({ from, to, body: messageText }); + const client = twilio(clientAccountSid, authToken); + if (!clientConversationSid) { + const clientConversation = await client.conversations.v1.conversations.create({ + friendlyName: `E2E test conversation with ${serviceAccountSid}, ${new Date().toISOString()}`, + }); + await clientConversation.participants().create({ + 'messagingBinding.address': to, + 'messagingBinding.proxyAddress': from, + 'messagingBinding.type': 'sms', + } as any); + clientConversationSid = clientConversation.sid; + } + await client.conversations.v1.conversations + .get(clientConversationSid) + .messages.create({ author: from, body: messageText }); console.debug(`Sent SMS message to service: '${messageText}'`); }; -export const sendSmsFromService = async (messageText: string) => { - const accountSid = getConfigValue('twilioAccountSid') as string; - const authToken = getConfigValue('twilioAuthToken') as string; - const from = getConfigValue('smsPhoneNumber') as string; - const to = getConfigValue('clientSmsPhoneNumber') as string; - - const client = twilio(accountSid, authToken); - await client.messages.create({ from, to, body: messageText }); - console.debug(`Sent SMS message from service: '${messageText}'`); -}; - const MAX_CHECKS = 10; /** @@ -56,20 +61,23 @@ const MAX_CHECKS = 10; * Uses the service Twilio account to list outbound messages to the client number. */ export const checkForMessageOnClient = async (messageText: string): Promise => { - if (!sessionStartTime) { + if (!clientConversationSid) { throw new AssertionError({ message: "You cannot verify incoming messages until you've sent one and started a session", }); } - const accountSid = getConfigValue('twilioAccountSid') as string; - const authToken = getConfigValue('twilioAuthToken') as string; + const accountSid = getConfigValue('clientTwilioAccountSid') as string; + const authToken = getConfigValue('clientTwilioAuthToken') as string; const to = getConfigValue('clientSmsPhoneNumber') as string; const client = twilio(accountSid, authToken); const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); for (let i = 0; i < MAX_CHECKS; i++) { - const messages = await client.messages.list({ to, dateSentAfter: sessionStartTime }); - if (messages.find((m) => m.body === messageText)) { + const messages = await client.conversations.v1.conversations + .get(clientConversationSid) + .messages.list(); + //client.conversations.v1.roles.l + if (messages.find((m) => m.body === messageText && m.author !== to)) { return true; } await delay(1000); diff --git a/e2e-tests/twilio/voice.ts b/e2e-tests/twilio/voice.ts new file mode 100644 index 0000000000..eb3798dedc --- /dev/null +++ b/e2e-tests/twilio/voice.ts @@ -0,0 +1,27 @@ +import { getConfigValue } from '../config'; +import twilio from 'twilio'; +import VoiceResponse = twilio.twiml.VoiceResponse; + +// The callSid on the caller's side +// let callerCallSid: string; + +export const makeCallToService = async () => { + const clientAccountSid = getConfigValue('clientTwilioAccountSid') as string; + const authToken = getConfigValue('clientTwilioAuthToken') as string; + const from = getConfigValue('clientSmsPhoneNumber') as string; + // const serviceAccountSid = getConfigValue('twilioAccountSid') as string; + const to = getConfigValue('voicePhoneNumber') as string; + + const response = new VoiceResponse(); + response.say("Hello, I'm and end to end test"); + + const client = twilio(clientAccountSid, authToken); + //const call = + await client.calls.create({ + method: 'GET', + twiml: response, + from, + to, + }); + //callerCallSid = call.sid; +}; From 188b5d67aea8c24239fed38969bd0d5032b805e2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 22 Jul 2026 11:49:27 +0000 Subject: [PATCH 28/53] feat: add voice channel for E2E development environment - Add voice channel using voice-no-chatbot-operating-hours-blocking-lambda template - Use the same phone number (+12607821891) as the SMS channel - Include voice_ivr_greeting_message, voice_ivr_blocked_message, and voice_ivr_language - Follows established patterns used in other helplines for voice configurations Co-authored-by: stephenhand <1694716+stephenhand@users.noreply.github.com> --- twilio-iac/helplines/e2e/development.hcl | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/twilio-iac/helplines/e2e/development.hcl b/twilio-iac/helplines/e2e/development.hcl index ff1a8358d8..4cbd493785 100644 --- a/twilio-iac/helplines/e2e/development.hcl +++ b/twilio-iac/helplines/e2e/development.hcl @@ -43,6 +43,17 @@ locals { channel_flow_vars = {} chatbot_unique_names = [] } + voice : { + channel_type = "voice" + contact_identity = "+12607821891" + templatefile = "/app/twilio-iac/helplines/templates/studio-flows/voice-no-chatbot-operating-hours-blocking-lambda.tftpl" + channel_flow_vars = { + voice_ivr_greeting_message = "Thank you for contacting E2E. One of our counselors will be with you shortly." + voice_ivr_blocked_message = "You have been blocked from contacting this service." + voice_ivr_language = "en-US" + } + chatbot_unique_names = [] + } } get_profile_flags_for_identifier_base_url = "https://hrm-development.tl.techmatters.org/lambda/twilio/account-scoped" #System Down Configuration From f1144a11b4ba32f31f9e982a5e52bc78c5819ffe Mon Sep 17 00:00:00 2001 From: Stephen Hand Date: Wed, 22 Jul 2026 19:26:14 +0100 Subject: [PATCH 29/53] First passing voice E2E test --- e2e-tests/contactForm.ts | 27 ++++++++++- e2e-tests/formContentsByHelpline.ts | 19 +++++++- e2e-tests/package.json | 8 ++-- e2e-tests/tests/aseloWebchat.spec.ts | 21 +-------- e2e-tests/tests/offlineContact.spec.ts | 39 +++------------ e2e-tests/tests/sms.spec.ts | 21 +-------- e2e-tests/tests/voice.spec.ts | 47 ++++--------------- e2e-tests/twilio/voice.ts | 2 +- twilio-iac/helplines/defaults.hcl | 4 ++ twilio-iac/helplines/e2e/common.hcl | 4 ++ twilio-iac/helplines/e2e/development.hcl | 2 +- .../templates/workflows/master.tftpl | 10 ++++ 12 files changed, 85 insertions(+), 119 deletions(-) diff --git a/e2e-tests/contactForm.ts b/e2e-tests/contactForm.ts index e7e171575b..7e9aecf959 100644 --- a/e2e-tests/contactForm.ts +++ b/e2e-tests/contactForm.ts @@ -85,7 +85,7 @@ export function contactForm(page: Page) { } } - return { + const formApi = { selectChildCallType: async () => { const childCallTypeButton = selectors.childCallTypeButton(); const responsePromise = page.waitForResponse('**/contacts/**'); @@ -100,6 +100,28 @@ export function contactForm(page: Page) { await tab.fill(tab); } }, + fillWithContent: async (formContent: any) => { + await formApi.fill([ + { + id: 'childInformation', + label: 'TabbedForms-AddChildInfoTab', + fill: formApi.fillStandardTab, + items: formContent.childInformation, + }, + >{ + id: 'categories', + label: 'TabbedForms-CategoriesTab', + fill: formApi.fillCategoriesTab, + items: formContent.categories, + }, + { + id: 'caseInformation', + label: 'TabbedForms-AddCaseInfoTab', + fill: formApi.fillStandardTab, + items: formContent.caseInformation, + }, + ]); + }, save: async ({ saveAndAddToCase }: { saveAndAddToCase?: boolean } = {}) => { const tab = { id: 'caseInformation', @@ -124,5 +146,6 @@ export function contactForm(page: Page) { }, fillCategoriesTab, fillStandardTab, - }; + } as const; + return formApi; } diff --git a/e2e-tests/formContentsByHelpline.ts b/e2e-tests/formContentsByHelpline.ts index b1d0caf6a3..1d5b8114ba 100644 --- a/e2e-tests/formContentsByHelpline.ts +++ b/e2e-tests/formContentsByHelpline.ts @@ -14,6 +14,7 @@ * along with this program. If not, see https://www.gnu.org/licenses/. */ + export const formContentsByHelpline = { e2e: { childInformation: { @@ -27,7 +28,7 @@ export const formContentsByHelpline = { Accessibility: ['Education'], }, caseInformation: { - callSummary: 'E2E TEST CALL', + callSummary: 'E2E TEST PREPOPULATED FORM', }, }, ca: { @@ -54,3 +55,19 @@ export const formContentsByHelpline = { }, }, }; + +export const formContentsByHelplineForEmptyForm = { + ...formContentsByHelpline, + e2e: { + ...formContentsByHelpline.e2e, + childInformation: { + ...formContentsByHelpline.e2e.childInformation, + + gender: 'Unknown', + age: 'Unknown', + }, + caseInformation: { + callSummary: 'E2E TEST EMPTY FORM', + }, + }, +}; \ No newline at end of file diff --git a/e2e-tests/package.json b/e2e-tests/package.json index 9323999c69..b55dbf52be 100644 --- a/e2e-tests/package.json +++ b/e2e-tests/package.json @@ -8,16 +8,16 @@ "deleteChatChannels": "tsx deleteConversations.ts", "test": "npx playwright test --workers 1 ", "test:ui": "npx playwright test --workers 1 --config ui-tests/playwright.ui-test.config.ts", - "test:local": "cross-env DEBUG=pw:api LOAD_SSM_CONFIG=true npm run test -- --retries 0 voice", + "test:local": "cross-env DEBUG=pw:api LOAD_SSM_CONFIG=true npm run test -- --retries 0", "test:local-aselo-webchat": "cross-env DEBUG=pw:api LOAD_SSM_CONFIG=true ASELO_WEBCHAT_URL=http://localhost:3001 npm run test -- --headed --debug --retries 0 aseloWebchat", - "test:local:debug": "cross-env DEBUG=pw:api LOAD_SSM_CONFIG=true npm run test -- --headed --debug --retries 0 voice", + "test:local:debug": "cross-env DEBUG=pw:api LOAD_SSM_CONFIG=true npm run test -- --headed --debug --retries 0", "test:development:as": "cross-env DEBUG=pw:api LOAD_SSM_CONFIG=true HL_ENV=development HL=as SKIP_DATA_UPDATE=true npm run test", "test:development:as:debug": "cross-env DEBUG=pw:api LOAD_SSM_CONFIG=true HL_ENV=development HL=as SKIP_DATA_UPDATE=true npm run test -- --headed --retries 0", "test:development:e2e": "cross-env DEBUG=pw:api LOAD_SSM_CONFIG=true HL_ENV=development HL=e2e npm run test", "test:development:e2e:local": "cross-env LOAD_SSM_CONFIG=true HL_ENV=development HL=e2e npm run test -- --headed", - "test:development:e2e:debug": "cross-env DEBUG=pw:api LOAD_SSM_CONFIG=true HL_ENV=development HL=e2e npm run test -- --headed --debug --retries 0 login", + "test:development:e2e:debug": "cross-env DEBUG=pw:api LOAD_SSM_CONFIG=true HL_ENV=development HL=e2e npm run test -- --headed --debug --retries 0", "test:staging:ca": "cross-env DEBUG=pw:api LOAD_SSM_CONFIG=true HL_ENV=staging HL=ca npm run test", - "test:staging:ca:headed": "cross-env LOAD_SSM_CONFIG=true HL_ENV=staging HL=ca npm test -- --headed --debug --retries 0 aseloWebchat", + "test:staging:ca:headed": "cross-env LOAD_SSM_CONFIG=true HL_ENV=staging HL=ca npm test -- --headed --debug --retries 0", "test:production:ca": "cross-env DEBUG=pw:api LOAD_SSM_CONFIG=true HL_ENV=production HL=ca npm run test", "lint": "eslint --ext ts .", "lint:fix": "npm run lint -- --fix .", diff --git a/e2e-tests/tests/aseloWebchat.spec.ts b/e2e-tests/tests/aseloWebchat.spec.ts index ecda6bd8ca..2b3fd446a1 100644 --- a/e2e-tests/tests/aseloWebchat.spec.ts +++ b/e2e-tests/tests/aseloWebchat.spec.ts @@ -102,26 +102,7 @@ test.describe.serial('Aselo web chat caller', () => { throw new Error(`No form contents configured for helplineShortCode="${String(helpline)}"`); } const form = contactForm(pluginPage); - await form.fill([ - { - id: 'childInformation', - label: 'TabbedForms-AddChildInfoTab', - fill: form.fillStandardTab, - items: formContent.childInformation, - }, - >{ - id: 'categories', - label: 'TabbedForms-CategoriesTab', - fill: form.fillCategoriesTab, - items: formContent.categories, - }, - { - id: 'caseInformation', - label: 'TabbedForms-AddCaseInfoTab', - fill: form.fillStandardTab, - items: formContent.caseInformation, - }, - ]); + await form.fillWithContent(formContent); console.info('Saving form'); await form.save(); diff --git a/e2e-tests/tests/offlineContact.spec.ts b/e2e-tests/tests/offlineContact.spec.ts index 1af1d559ea..4af76fa29f 100644 --- a/e2e-tests/tests/offlineContact.spec.ts +++ b/e2e-tests/tests/offlineContact.spec.ts @@ -23,6 +23,8 @@ import { notificationBar } from '../notificationBar'; import { closePage, setupContextAndPage } from '../browser'; import { apiHrmRequest } from '../hrm/hrmRequest'; import { clearOfflineTask } from '../hrm/clearOfflineTask'; +import {getConfigValue} from "../config"; +import {formContentsByHelpline} from "../formContentsByHelpline"; test.describe.serial('Offline Contact (with Case)', () => { skipTestIfNotTargeted(); @@ -56,6 +58,8 @@ test.describe.serial('Offline Contact (with Case)', () => { await agentDesktopPage.addOfflineContact(); console.log('Starting filling form'); + const helpline = getConfigValue('helplineShortCode') as keyof typeof formContentsByHelpline; + const formContent = formContentsByHelpline[helpline]; const form = contactForm(pluginPage); await form.selectChildCallType(); @@ -69,39 +73,8 @@ test.describe.serial('Offline Contact (with Case)', () => { channel: 'web', helpline: 'Childline', }, - }, - { - id: 'childInformation', - label: 'TabbedForms-AddChildInfoTab', - fill: form.fillStandardTab, - items: { - firstName: 'E2E', - lastName: 'OFFLINE CONTACT', - gender: 'Unknown', - age: 'Unknown', - phone1: '1234512345', - province: 'Northern', - district: 'District A', - }, - }, - >{ - id: 'categories', - label: 'TabbedForms-CategoriesTab', - fill: form.fillCategoriesTab, - items: { - Accessibility: ['Education'], - }, - }, - { - id: 'caseInformation', - label: 'TabbedForms-AddCaseInfoTab', - fill: form.fillStandardTab, - items: { - callSummary: 'E2E OFFLINE CONTACT', - }, - }, - ]); - + }]); + await form.fillWithContent(formContent); const beforeDate = new Date(); // Capture date here since we'll create case inmediately after saving contact // if (getConfigValue('skipDataUpdate') as boolean) { diff --git a/e2e-tests/tests/sms.spec.ts b/e2e-tests/tests/sms.spec.ts index 5d16c47fc5..a82d81e9f1 100644 --- a/e2e-tests/tests/sms.spec.ts +++ b/e2e-tests/tests/sms.spec.ts @@ -102,26 +102,7 @@ test.describe.serial('SMS caller', () => { throw new Error(`No form contents configured for helplineShortCode="${String(helpline)}"`); } const form = contactForm(pluginPage); - await form.fill([ - { - id: 'childInformation', - label: 'TabbedForms-AddChildInfoTab', - fill: form.fillStandardTab, - items: formContent.childInformation, - }, - >{ - id: 'categories', - label: 'TabbedForms-CategoriesTab', - fill: form.fillCategoriesTab, - items: formContent.categories, - }, - { - id: 'caseInformation', - label: 'TabbedForms-AddCaseInfoTab', - fill: form.fillStandardTab, - items: formContent.caseInformation, - }, - ]); + await form.fillWithContent(formContent); console.info('Saving form'); await form.save(); diff --git a/e2e-tests/tests/voice.spec.ts b/e2e-tests/tests/voice.spec.ts index b9b8c616bc..84ce54d432 100644 --- a/e2e-tests/tests/voice.spec.ts +++ b/e2e-tests/tests/voice.spec.ts @@ -16,11 +16,7 @@ import { Page, request, test } from '@playwright/test'; import { statusIndicator } from '../workerStatus'; -import { ChatStatement, ChatStatementOrigin } from '../chatModel'; -import { getSmsScript } from '../chatScripts'; -import { flexChat } from '../flexChat'; import { skipTestIfNotTargeted } from '../skipTest'; -import { tasks } from '../tasks'; import { Categories, contactForm, ContactFormTab } from '../contactForm'; import { deleteAllTasksInQueue } from '../twilio/tasks'; import { notificationBar } from '../notificationBar'; @@ -28,12 +24,12 @@ import { clickThroughTwilioPasteModals } from '../agent-desktop'; import { setupContextAndPage, closePage } from '../browser'; import { clearOfflineTask } from '../hrm/clearOfflineTask'; import { apiHrmRequest } from '../hrm/hrmRequest'; -import { formContentsByHelpline } from '../formContentsByHelpline'; +import {formContentsByHelpline, formContentsByHelplineForEmptyForm} from '../formContentsByHelpline'; import { getConfigValue } from '../config'; -import { smsChat } from '../twilio/sms'; import { makeCallToService } from '../twilio/voice'; +import { tasks } from '../tasks'; -test.describe.serial('SMS caller', () => { +test.describe.serial('Voice caller', () => { skipTestIfNotTargeted(); let pluginPage: Page; @@ -65,45 +61,22 @@ test.describe.serial('SMS caller', () => { await deleteAllTasksInQueue(); }); - test('Chat', async () => { + test('Call', async () => { test.setTimeout(180000); - - const chatScript = getSmsScript(); - - // smsChat handles the client (caller) side via the Twilio Messages API. - // flexChat handles the counselor side via the Flex browser UI. - // Both iterate the same shared script, yielding control when they hit a - // statement the other side needs to handle — the same pattern used by the - // Aselo webchat test. await makeCallToService(); + await statusIndicator(pluginPage).setStatus('AVAILABLE'); + await tasks(pluginPage).acceptNextTask(); console.info('Starting filling form'); const helpline = getConfigValue('helplineShortCode') as keyof typeof formContentsByHelpline; - const formContent = formContentsByHelpline[helpline]; + const formContent = formContentsByHelplineForEmptyForm[helpline]; if (!formContent) { throw new Error(`No form contents configured for helplineShortCode="${String(helpline)}"`); } const form = contactForm(pluginPage); - await form.fill([ - { - id: 'childInformation', - label: 'TabbedForms-AddChildInfoTab', - fill: form.fillStandardTab, - items: formContent.childInformation, - }, - >{ - id: 'categories', - label: 'TabbedForms-CategoriesTab', - fill: form.fillCategoriesTab, - items: formContent.categories, - }, - { - id: 'caseInformation', - label: 'TabbedForms-AddCaseInfoTab', - fill: form.fillStandardTab, - items: formContent.caseInformation, - }, - ]); + + await form.selectChildCallType(); + await form.fillWithContent(formContent); console.info('Saving form'); await form.save(); diff --git a/e2e-tests/twilio/voice.ts b/e2e-tests/twilio/voice.ts index eb3798dedc..f34cfb194d 100644 --- a/e2e-tests/twilio/voice.ts +++ b/e2e-tests/twilio/voice.ts @@ -13,7 +13,7 @@ export const makeCallToService = async () => { const to = getConfigValue('voicePhoneNumber') as string; const response = new VoiceResponse(); - response.say("Hello, I'm and end to end test"); + response.say({ loop: 100 }, "Hello, I'm an end to end test"); const client = twilio(clientAccountSid, authToken); //const call = diff --git a/twilio-iac/helplines/defaults.hcl b/twilio-iac/helplines/defaults.hcl index d4fdc6d476..5ac2d1293a 100644 --- a/twilio-iac/helplines/defaults.hcl +++ b/twilio-iac/helplines/defaults.hcl @@ -60,6 +60,10 @@ locals { e2e_test : { "target_workers" = "email=='aselo-alerts+production@techmatters.org'", "friendly_name" = "E2E Test Queue" + }, + e2e_test_voice : { + "target_workers" = "email=='aselo-alerts+production@techmatters.org'", + "friendly_name" = "E2E Test Queue (Voice)" } // survey : { // friendly_name = "Survey" diff --git a/twilio-iac/helplines/e2e/common.hcl b/twilio-iac/helplines/e2e/common.hcl index 2b20711115..c2376b0a43 100644 --- a/twilio-iac/helplines/e2e/common.hcl +++ b/twilio-iac/helplines/e2e/common.hcl @@ -52,6 +52,10 @@ locals { e2e_test : { "target_workers" = "email=='aselo-alerts+production@techmatters.org'", "friendly_name" = "E2E Test Queue" + }, + e2e_test_voice : { + "target_workers" = "email=='aselo-alerts+production@techmatters.org'", + "friendly_name" = "E2E Test Queue (Voice)" } } diff --git a/twilio-iac/helplines/e2e/development.hcl b/twilio-iac/helplines/e2e/development.hcl index 4cbd493785..67cde25a69 100644 --- a/twilio-iac/helplines/e2e/development.hcl +++ b/twilio-iac/helplines/e2e/development.hcl @@ -7,7 +7,7 @@ locals { local_config = { enable_external_recordings = true permission_config = "e2e" - custom_task_routing_filter_expression = "*(helpline IN ['Childline', ''] OR channelType =='web') AND isContactlessTask != true" + custom_task_routing_filter_expression = "*(helpline IN ['Childline', ''] OR channelType =='web' OR channelType = 'voice' OR channelType = 'sms') AND isContactlessTask != true" flow_vars = { service_sid = "ZS43ea9fdb2e1901c2fc23b4654b285202" environment_sid = "ZE0241494e654e208f715b4d9612171dc0" diff --git a/twilio-iac/helplines/templates/workflows/master.tftpl b/twilio-iac/helplines/templates/workflows/master.tftpl index 5082bdd9a1..291d9f6b2f 100644 --- a/twilio-iac/helplines/templates/workflows/master.tftpl +++ b/twilio-iac/helplines/templates/workflows/master.tftpl @@ -42,6 +42,16 @@ "queue": "${task_queues.e2e_test}" } ] + }, + { + "filter_friendly_name": "Voice E2E Test", + "expression": "channelType=='voice' AND name=='+12064083885'", + "targets": [ + { + "expression": "(worker.waitingOfflineContact != true AND ((task.channelType == 'voice' AND worker.channel.chat.assigned_tasks == 0) OR (task.channelType != 'voice' AND worker.channel.voice.assigned_tasks == 0)) AND ((task.transferTargetType == 'worker' AND task.targetSid == worker.sid) OR (task.transferTargetType != 'worker' AND worker.sid != task.ignoreAgent))) OR (worker.waitingOfflineContact == true AND task.targetSid == worker.sid AND task.isContactlessTask == true)", + "queue": "${task_queues.e2e_test_voice}" + } + ] } ] } From 64730def90e6cc4c863a6bb33c2fd75dcd8f7dd3 Mon Sep 17 00:00:00 2001 From: Stephen Hand Date: Wed, 22 Jul 2026 19:28:27 +0100 Subject: [PATCH 30/53] Licence --- e2e-tests/twilio/voice.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/e2e-tests/twilio/voice.ts b/e2e-tests/twilio/voice.ts index f34cfb194d..4126c13de5 100644 --- a/e2e-tests/twilio/voice.ts +++ b/e2e-tests/twilio/voice.ts @@ -1,3 +1,19 @@ +/** + * 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 { getConfigValue } from '../config'; import twilio from 'twilio'; import VoiceResponse = twilio.twiml.VoiceResponse; From 4547284e9b4e46d4401ce384e1bc58d05c40181b Mon Sep 17 00:00:00 2001 From: Stephen Hand Date: Wed, 22 Jul 2026 23:52:37 +0100 Subject: [PATCH 31/53] Fix offlie contact e2e test --- e2e-tests/tests/offlineContact.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/e2e-tests/tests/offlineContact.spec.ts b/e2e-tests/tests/offlineContact.spec.ts index 4af76fa29f..2bbfaeab23 100644 --- a/e2e-tests/tests/offlineContact.spec.ts +++ b/e2e-tests/tests/offlineContact.spec.ts @@ -24,7 +24,7 @@ import { closePage, setupContextAndPage } from '../browser'; import { apiHrmRequest } from '../hrm/hrmRequest'; import { clearOfflineTask } from '../hrm/clearOfflineTask'; import {getConfigValue} from "../config"; -import {formContentsByHelpline} from "../formContentsByHelpline"; +import {formContentsByHelpline, formContentsByHelplineForEmptyForm} from "../formContentsByHelpline"; test.describe.serial('Offline Contact (with Case)', () => { skipTestIfNotTargeted(); @@ -59,7 +59,7 @@ test.describe.serial('Offline Contact (with Case)', () => { console.log('Starting filling form'); const helpline = getConfigValue('helplineShortCode') as keyof typeof formContentsByHelpline; - const formContent = formContentsByHelpline[helpline]; + const formContent = formContentsByHelplineForEmptyForm[helpline]; const form = contactForm(pluginPage); await form.selectChildCallType(); From 465730d058e79dae6a003b55fd242d9584980556 Mon Sep 17 00:00:00 2001 From: Stephen Hand Date: Wed, 22 Jul 2026 23:57:08 +0100 Subject: [PATCH 32/53] Fix offlie contact e2e test --- e2e-tests/tests/offlineContact.spec.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/e2e-tests/tests/offlineContact.spec.ts b/e2e-tests/tests/offlineContact.spec.ts index 2bbfaeab23..fd27dead94 100644 --- a/e2e-tests/tests/offlineContact.spec.ts +++ b/e2e-tests/tests/offlineContact.spec.ts @@ -15,7 +15,7 @@ */ import { expect, Page, request, test } from '@playwright/test'; -import { Categories, contactForm, ContactFormTab } from '../contactForm'; +import { contactForm, ContactFormTab } from '../contactForm'; import { caseHome } from '../case'; import { agentDesktop, navigateToAgentDesktop } from '../agent-desktop'; import { skipTestIfDataUpdateDisabled, skipTestIfNotTargeted } from '../skipTest'; @@ -23,8 +23,11 @@ import { notificationBar } from '../notificationBar'; import { closePage, setupContextAndPage } from '../browser'; import { apiHrmRequest } from '../hrm/hrmRequest'; import { clearOfflineTask } from '../hrm/clearOfflineTask'; -import {getConfigValue} from "../config"; -import {formContentsByHelpline, formContentsByHelplineForEmptyForm} from "../formContentsByHelpline"; +import { getConfigValue } from '../config'; +import { + formContentsByHelpline, + formContentsByHelplineForEmptyForm, +} from '../formContentsByHelpline'; test.describe.serial('Offline Contact (with Case)', () => { skipTestIfNotTargeted(); @@ -73,7 +76,8 @@ test.describe.serial('Offline Contact (with Case)', () => { channel: 'web', helpline: 'Childline', }, - }]); + }, + ]); await form.fillWithContent(formContent); const beforeDate = new Date(); // Capture date here since we'll create case inmediately after saving contact From cf32d57165e6f7112728386bd05deceee64b128d Mon Sep 17 00:00:00 2001 From: Stephen Hand Date: Thu, 23 Jul 2026 00:21:56 +0100 Subject: [PATCH 33/53] Linter --- e2e-tests/formContentsByHelpline.ts | 3 +-- e2e-tests/tests/aseloWebchat.spec.ts | 2 +- e2e-tests/tests/sms.spec.ts | 2 +- e2e-tests/tests/voice.spec.ts | 7 +++++-- e2e-tests/twilio/channels.ts | 2 +- e2e-tests/twilio/voice.ts | 1 + 6 files changed, 10 insertions(+), 7 deletions(-) diff --git a/e2e-tests/formContentsByHelpline.ts b/e2e-tests/formContentsByHelpline.ts index 1d5b8114ba..3fbb37a484 100644 --- a/e2e-tests/formContentsByHelpline.ts +++ b/e2e-tests/formContentsByHelpline.ts @@ -14,7 +14,6 @@ * along with this program. If not, see https://www.gnu.org/licenses/. */ - export const formContentsByHelpline = { e2e: { childInformation: { @@ -70,4 +69,4 @@ export const formContentsByHelplineForEmptyForm = { callSummary: 'E2E TEST EMPTY FORM', }, }, -}; \ No newline at end of file +}; diff --git a/e2e-tests/tests/aseloWebchat.spec.ts b/e2e-tests/tests/aseloWebchat.spec.ts index 2b3fd446a1..78b2b6d400 100644 --- a/e2e-tests/tests/aseloWebchat.spec.ts +++ b/e2e-tests/tests/aseloWebchat.spec.ts @@ -23,7 +23,7 @@ import { getWebchatScript } from '../chatScripts'; import { flexChat } from '../flexChat'; import { skipTestIfNotTargeted } from '../skipTest'; import { tasks } from '../tasks'; -import { Categories, contactForm, ContactFormTab } from '../contactForm'; +import { contactForm } from '../contactForm'; import { deleteAllTasksInQueue } from '../twilio/tasks'; import { notificationBar } from '../notificationBar'; import { clickThroughTwilioPasteModals } from '../agent-desktop'; diff --git a/e2e-tests/tests/sms.spec.ts b/e2e-tests/tests/sms.spec.ts index a82d81e9f1..510e017a4a 100644 --- a/e2e-tests/tests/sms.spec.ts +++ b/e2e-tests/tests/sms.spec.ts @@ -21,7 +21,7 @@ import { getSmsScript } from '../chatScripts'; import { flexChat } from '../flexChat'; import { skipTestIfNotTargeted } from '../skipTest'; import { tasks } from '../tasks'; -import { Categories, contactForm, ContactFormTab } from '../contactForm'; +import { contactForm } from '../contactForm'; import { deleteAllTasksInQueue } from '../twilio/tasks'; import { notificationBar } from '../notificationBar'; import { clickThroughTwilioPasteModals } from '../agent-desktop'; diff --git a/e2e-tests/tests/voice.spec.ts b/e2e-tests/tests/voice.spec.ts index 84ce54d432..8e88a64e0d 100644 --- a/e2e-tests/tests/voice.spec.ts +++ b/e2e-tests/tests/voice.spec.ts @@ -17,14 +17,17 @@ import { Page, request, test } from '@playwright/test'; import { statusIndicator } from '../workerStatus'; import { skipTestIfNotTargeted } from '../skipTest'; -import { Categories, contactForm, ContactFormTab } from '../contactForm'; +import { contactForm } from '../contactForm'; import { deleteAllTasksInQueue } from '../twilio/tasks'; import { notificationBar } from '../notificationBar'; import { clickThroughTwilioPasteModals } from '../agent-desktop'; import { setupContextAndPage, closePage } from '../browser'; import { clearOfflineTask } from '../hrm/clearOfflineTask'; import { apiHrmRequest } from '../hrm/hrmRequest'; -import {formContentsByHelpline, formContentsByHelplineForEmptyForm} from '../formContentsByHelpline'; +import { + formContentsByHelpline, + formContentsByHelplineForEmptyForm, +} from '../formContentsByHelpline'; import { getConfigValue } from '../config'; import { makeCallToService } from '../twilio/voice'; import { tasks } from '../tasks'; diff --git a/e2e-tests/twilio/channels.ts b/e2e-tests/twilio/channels.ts index 1aeb62b168..da543ce7ab 100644 --- a/e2e-tests/twilio/channels.ts +++ b/e2e-tests/twilio/channels.ts @@ -15,7 +15,7 @@ */ // eslint-disable-next-line import/no-extraneous-dependencies -import twilio, { Twilio } from 'twilio'; +import twilio from 'twilio'; import { getConfigValue } from '../config'; const encodeEmailToUnicode = (email: string) => { diff --git a/e2e-tests/twilio/voice.ts b/e2e-tests/twilio/voice.ts index 4126c13de5..ec016f6c85 100644 --- a/e2e-tests/twilio/voice.ts +++ b/e2e-tests/twilio/voice.ts @@ -15,6 +15,7 @@ */ import { getConfigValue } from '../config'; +// eslint-disable-next-line import/no-extraneous-dependencies import twilio from 'twilio'; import VoiceResponse = twilio.twiml.VoiceResponse; From d4c6192187fd04e527c2bd5007a0343cba0a7d49 Mon Sep 17 00:00:00 2001 From: Stephen Hand Date: Thu, 23 Jul 2026 08:24:48 +0100 Subject: [PATCH 34/53] Extra assertion in E2E tests --- e2e-tests/workerStatus.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/e2e-tests/workerStatus.ts b/e2e-tests/workerStatus.ts index f04030f167..d8fd7d068f 100644 --- a/e2e-tests/workerStatus.ts +++ b/e2e-tests/workerStatus.ts @@ -15,7 +15,7 @@ */ // eslint-disable-next-line import/no-extraneous-dependencies -import { Locator, Page } from '@playwright/test'; +import {expect, Locator, Page} from '@playwright/test'; export const WORKER_STATUS = { AVAILABLE: ['Available', 'Ready'], @@ -45,12 +45,13 @@ export function statusIndicator(page: Page) { return { setStatus: async function (status: WorkerStatus) { await selectors.userActivityDropdownButton.click(); - console.log('Worker status dropdown should be open'); + console.debug('Worker status dropdown should be open'); await selectors.activityMenu.waitFor({ state: 'visible' }); const statusSelector = await getFirstMatchingStatus(page, WORKER_STATUS[status]); - console.log('Worker status option spotted'); + console.debug('Worker status option spotted'); await statusSelector.click(); - console.log('Worker status option clicked'); + console.debug('Worker status option clicked'); + await expect(statusSelector).toContainText(new RegExp(WORKER_STATUS[status].join('|'))); }, }; } From 8cc9d5e21562dce4469676e13b47b09fca3741d1 Mon Sep 17 00:00:00 2001 From: Stephen Hand Date: Thu, 23 Jul 2026 08:39:58 +0100 Subject: [PATCH 35/53] Fake sound input for all E2E browsers, not just those running in a lambda --- e2e-tests/package.json | 2 +- e2e-tests/playwright.config.ts | 9 ++++++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/e2e-tests/package.json b/e2e-tests/package.json index b55dbf52be..5bde88e89e 100644 --- a/e2e-tests/package.json +++ b/e2e-tests/package.json @@ -10,7 +10,7 @@ "test:ui": "npx playwright test --workers 1 --config ui-tests/playwright.ui-test.config.ts", "test:local": "cross-env DEBUG=pw:api LOAD_SSM_CONFIG=true npm run test -- --retries 0", "test:local-aselo-webchat": "cross-env DEBUG=pw:api LOAD_SSM_CONFIG=true ASELO_WEBCHAT_URL=http://localhost:3001 npm run test -- --headed --debug --retries 0 aseloWebchat", - "test:local:debug": "cross-env DEBUG=pw:api LOAD_SSM_CONFIG=true npm run test -- --headed --debug --retries 0", + "test:local:debug": "cross-env DEBUG=pw:api LOAD_SSM_CONFIG=true npm run test -- --headed --debug --retries 0 voice", "test:development:as": "cross-env DEBUG=pw:api LOAD_SSM_CONFIG=true HL_ENV=development HL=as SKIP_DATA_UPDATE=true npm run test", "test:development:as:debug": "cross-env DEBUG=pw:api LOAD_SSM_CONFIG=true HL_ENV=development HL=as SKIP_DATA_UPDATE=true npm run test -- --headed --retries 0", "test:development:e2e": "cross-env DEBUG=pw:api LOAD_SSM_CONFIG=true HL_ENV=development HL=e2e npm run test", diff --git a/e2e-tests/playwright.config.ts b/e2e-tests/playwright.config.ts index d4363de572..ddb8924a2b 100644 --- a/e2e-tests/playwright.config.ts +++ b/e2e-tests/playwright.config.ts @@ -21,6 +21,8 @@ import { getConfigValue } from './config'; const inLambda = getConfigValue('inLambda') as boolean; +const browserArgs = ['--use-fake-ui-for-media-stream', '--use-fake-device-for-media-stream']; + const playwrightConfig: PlaywrightTestConfig = { globalSetup: require.resolve('./global-setup'), use: { @@ -40,6 +42,7 @@ const playwrightConfig: PlaywrightTestConfig = { * of chromium/Playwright. We use the `TEST_NAME` environment variable to set * a unique target for each test that runs in lambdas to avoid this issue. */ + ...browserArgs, '--single-process', '--autoplay-policy=user-gesture-required', '--disable-background-networking', @@ -81,11 +84,11 @@ const playwrightConfig: PlaywrightTestConfig = { '--disable-gpu', '--use-gl=swiftshader', '--autoplay-policy=no-user-gesture-required', - '--use-fake-ui-for-media-stream', - '--use-fake-device-for-media-stream', ], } - : {}, + : { + args: browserArgs, + }, }, testDir: './tests', retries: inLambda ? 0 : 1, From e37b2a80d39db47966d93a0ee9a2ad230ba29bb9 Mon Sep 17 00:00:00 2001 From: Stephen Hand Date: Thu, 23 Jul 2026 08:40:33 +0100 Subject: [PATCH 36/53] Revert local change --- e2e-tests/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/e2e-tests/package.json b/e2e-tests/package.json index 5bde88e89e..b55dbf52be 100644 --- a/e2e-tests/package.json +++ b/e2e-tests/package.json @@ -10,7 +10,7 @@ "test:ui": "npx playwright test --workers 1 --config ui-tests/playwright.ui-test.config.ts", "test:local": "cross-env DEBUG=pw:api LOAD_SSM_CONFIG=true npm run test -- --retries 0", "test:local-aselo-webchat": "cross-env DEBUG=pw:api LOAD_SSM_CONFIG=true ASELO_WEBCHAT_URL=http://localhost:3001 npm run test -- --headed --debug --retries 0 aseloWebchat", - "test:local:debug": "cross-env DEBUG=pw:api LOAD_SSM_CONFIG=true npm run test -- --headed --debug --retries 0 voice", + "test:local:debug": "cross-env DEBUG=pw:api LOAD_SSM_CONFIG=true npm run test -- --headed --debug --retries 0", "test:development:as": "cross-env DEBUG=pw:api LOAD_SSM_CONFIG=true HL_ENV=development HL=as SKIP_DATA_UPDATE=true npm run test", "test:development:as:debug": "cross-env DEBUG=pw:api LOAD_SSM_CONFIG=true HL_ENV=development HL=as SKIP_DATA_UPDATE=true npm run test -- --headed --retries 0", "test:development:e2e": "cross-env DEBUG=pw:api LOAD_SSM_CONFIG=true HL_ENV=development HL=e2e npm run test", From 42daf154844aa4e16e34243ac613fc9b6438ed34 Mon Sep 17 00:00:00 2001 From: Stephen Hand Date: Thu, 23 Jul 2026 08:53:24 +0100 Subject: [PATCH 37/53] Linter --- e2e-tests/workerStatus.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/e2e-tests/workerStatus.ts b/e2e-tests/workerStatus.ts index d8fd7d068f..b18a3f62b9 100644 --- a/e2e-tests/workerStatus.ts +++ b/e2e-tests/workerStatus.ts @@ -15,7 +15,7 @@ */ // eslint-disable-next-line import/no-extraneous-dependencies -import {expect, Locator, Page} from '@playwright/test'; +import { expect, Locator, Page } from '@playwright/test'; export const WORKER_STATUS = { AVAILABLE: ['Available', 'Ready'], From 5fb2a6f1248f41aa09d0baf81e00af119d0ad451 Mon Sep 17 00:00:00 2001 From: Stephen Hand Date: Thu, 23 Jul 2026 16:23:45 +0100 Subject: [PATCH 38/53] Apply suggestions from code review Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- twilio-iac/helplines/e2e/development.hcl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/twilio-iac/helplines/e2e/development.hcl b/twilio-iac/helplines/e2e/development.hcl index 67cde25a69..dce9b14ab3 100644 --- a/twilio-iac/helplines/e2e/development.hcl +++ b/twilio-iac/helplines/e2e/development.hcl @@ -7,7 +7,7 @@ locals { local_config = { enable_external_recordings = true permission_config = "e2e" - custom_task_routing_filter_expression = "*(helpline IN ['Childline', ''] OR channelType =='web' OR channelType = 'voice' OR channelType = 'sms') AND isContactlessTask != true" + custom_task_routing_filter_expression = "*(helpline IN ['Childline', ''] OR channelType =='web' OR channelType == 'voice' OR channelType == 'sms') AND isContactlessTask != true" flow_vars = { service_sid = "ZS43ea9fdb2e1901c2fc23b4654b285202" environment_sid = "ZE0241494e654e208f715b4d9612171dc0" From 02ebb61ad9d6c26b24111a11421ba8ff041270cf Mon Sep 17 00:00:00 2001 From: Stephen Hand Date: Thu, 23 Jul 2026 16:28:58 +0100 Subject: [PATCH 39/53] Remove E2E voice queue --- twilio-iac/helplines/defaults.hcl | 4 ---- twilio-iac/helplines/e2e/common.hcl | 4 ---- twilio-iac/helplines/templates/workflows/master.tftpl | 10 ---------- 3 files changed, 18 deletions(-) diff --git a/twilio-iac/helplines/defaults.hcl b/twilio-iac/helplines/defaults.hcl index 5ac2d1293a..d4fdc6d476 100644 --- a/twilio-iac/helplines/defaults.hcl +++ b/twilio-iac/helplines/defaults.hcl @@ -60,10 +60,6 @@ locals { e2e_test : { "target_workers" = "email=='aselo-alerts+production@techmatters.org'", "friendly_name" = "E2E Test Queue" - }, - e2e_test_voice : { - "target_workers" = "email=='aselo-alerts+production@techmatters.org'", - "friendly_name" = "E2E Test Queue (Voice)" } // survey : { // friendly_name = "Survey" diff --git a/twilio-iac/helplines/e2e/common.hcl b/twilio-iac/helplines/e2e/common.hcl index c2376b0a43..2b20711115 100644 --- a/twilio-iac/helplines/e2e/common.hcl +++ b/twilio-iac/helplines/e2e/common.hcl @@ -52,10 +52,6 @@ locals { e2e_test : { "target_workers" = "email=='aselo-alerts+production@techmatters.org'", "friendly_name" = "E2E Test Queue" - }, - e2e_test_voice : { - "target_workers" = "email=='aselo-alerts+production@techmatters.org'", - "friendly_name" = "E2E Test Queue (Voice)" } } diff --git a/twilio-iac/helplines/templates/workflows/master.tftpl b/twilio-iac/helplines/templates/workflows/master.tftpl index 291d9f6b2f..5082bdd9a1 100644 --- a/twilio-iac/helplines/templates/workflows/master.tftpl +++ b/twilio-iac/helplines/templates/workflows/master.tftpl @@ -42,16 +42,6 @@ "queue": "${task_queues.e2e_test}" } ] - }, - { - "filter_friendly_name": "Voice E2E Test", - "expression": "channelType=='voice' AND name=='+12064083885'", - "targets": [ - { - "expression": "(worker.waitingOfflineContact != true AND ((task.channelType == 'voice' AND worker.channel.chat.assigned_tasks == 0) OR (task.channelType != 'voice' AND worker.channel.voice.assigned_tasks == 0)) AND ((task.transferTargetType == 'worker' AND task.targetSid == worker.sid) OR (task.transferTargetType != 'worker' AND worker.sid != task.ignoreAgent))) OR (worker.waitingOfflineContact == true AND task.targetSid == worker.sid AND task.isContactlessTask == true)", - "queue": "${task_queues.e2e_test_voice}" - } - ] } ] } From 8924abace85d235c9688f4b94edf028be3a8c3c4 Mon Sep 17 00:00:00 2001 From: Stephen Hand Date: Thu, 23 Jul 2026 16:33:54 +0100 Subject: [PATCH 40/53] Revert unused update --- twilio-iac/terraform-modules/channels/v1/main.tf | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/twilio-iac/terraform-modules/channels/v1/main.tf b/twilio-iac/terraform-modules/channels/v1/main.tf index 20af2664b1..21e6d961bd 100644 --- a/twilio-iac/terraform-modules/channels/v1/main.tf +++ b/twilio-iac/terraform-modules/channels/v1/main.tf @@ -107,9 +107,7 @@ resource "twilio_conversations_configuration_addresses_v1" "conversations_addres # Must be created manually in Twilio Console for now channel.channel_type != "chat" && channel.channel_type != "custom" && - channel.messaging_mode == "conversations" && - # Channels with no contact_identity manage their own conversations address (e.g. via additional.tf) - channel.contact_identity != "" + channel.messaging_mode == "conversations" ) } type = each.value.channel_type From 1f7c36e42093b47f878e2dbaecf9abf2f813ac83 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 6 Aug 2026 19:09:41 +0000 Subject: [PATCH 41/53] Initial plan From 34834ef1a0b51d9c0dd204b238284c25790543f9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 7 Aug 2026 09:25:24 +0000 Subject: [PATCH 42/53] Add private Twilio configuration endpoint to account-scoped lambda Co-authored-by: stephenhand <1694716+stephenhand@users.noreply.github.com> --- lambdas/account-scoped/package.json | 1 + .../getTwilioPrivateConfiguration.ts | 39 ++++++ lambdas/account-scoped/src/router.ts | 5 + .../getTwilioPrivateConfiguration.test.ts | 119 ++++++++++++++++++ 4 files changed, 164 insertions(+) create mode 100644 lambdas/account-scoped/src/configuration/getTwilioPrivateConfiguration.ts create mode 100644 lambdas/account-scoped/tests/unit/configuration/getTwilioPrivateConfiguration.test.ts diff --git a/lambdas/account-scoped/package.json b/lambdas/account-scoped/package.json index 83505e9f2d..be2e9930c5 100644 --- a/lambdas/account-scoped/package.json +++ b/lambdas/account-scoped/package.json @@ -28,6 +28,7 @@ "@aws-sdk/client-lex-runtime-v2": "^3.1045.0", "@aws-sdk/client-ssm": "^3.1045.0", "@tech-matters/configuration": "^1.0.0", + "@tech-matters/s3": "^1.0.0", "@tech-matters/hrm-form-definitions": "^1.0.0", "@tech-matters/hrm-types": "^1.0.0", "@tech-matters/ssm-cache": "^1.0.0", diff --git a/lambdas/account-scoped/src/configuration/getTwilioPrivateConfiguration.ts b/lambdas/account-scoped/src/configuration/getTwilioPrivateConfiguration.ts new file mode 100644 index 0000000000..f25f7c2e38 --- /dev/null +++ b/lambdas/account-scoped/src/configuration/getTwilioPrivateConfiguration.ts @@ -0,0 +1,39 @@ +/** + * 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 type { AccountSID } from '@tech-matters/twilio-types'; +import { getDocsBucketName } from '@tech-matters/twilio-configuration'; +import { getS3Object } from '@tech-matters/s3'; +import { newErr, newOk } from '../Result'; +import { AccountScopedHandler } from '../httpTypes'; + +const TWILIO_PRIVATE_CONFIGURATION_KEY = 'configuration/twilio-private.json'; + +export const getTwilioPrivateConfigurationHandler: AccountScopedHandler = async ( + _event, + accountSid: AccountSID, +) => { + try { + const bucket = await getDocsBucketName(accountSid); + const content = await getS3Object(bucket, TWILIO_PRIVATE_CONFIGURATION_KEY); + return newOk(JSON.parse(content)); + } catch (err: any) { + if (err?.name === 'NoSuchKey') { + return newOk({}); + } + return newErr({ message: err.message, error: { statusCode: 500, cause: err } }); + } +}; diff --git a/lambdas/account-scoped/src/router.ts b/lambdas/account-scoped/src/router.ts index a93921818a..4506d784ad 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 { getTwilioPrivateConfigurationHandler } from './configuration/getTwilioPrivateConfiguration'; /** * Super simple router sufficient for directly ported Twilio Serverless functions @@ -409,6 +410,10 @@ const ACCOUNTSID_ROUTES: Record< requestPipeline: [validateRequestMethod('POST'), validateWebhookRequest], handler: filterCountryOrVoIPHandler, }), + 'configuration/twilioPrivate': newRoute({ + requestPipeline: [validateRequestMethod('GET'), validateWebhookRequest], + handler: getTwilioPrivateConfigurationHandler, + }), }; const ENV_SHORTCODE_ROUTES: Record = { diff --git a/lambdas/account-scoped/tests/unit/configuration/getTwilioPrivateConfiguration.test.ts b/lambdas/account-scoped/tests/unit/configuration/getTwilioPrivateConfiguration.test.ts new file mode 100644 index 0000000000..156e45bc2f --- /dev/null +++ b/lambdas/account-scoped/tests/unit/configuration/getTwilioPrivateConfiguration.test.ts @@ -0,0 +1,119 @@ +/** + * 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 { getTwilioPrivateConfigurationHandler } from '../../../src/configuration/getTwilioPrivateConfiguration'; +import { getDocsBucketName } from '@tech-matters/twilio-configuration'; +import { getS3Object } from '@tech-matters/s3'; +import { isErr, isOk } from '../../../src/Result'; +import { HttpRequest } from '../../../src/httpTypes'; +import { TEST_ACCOUNT_SID } from '../../testTwilioValues'; + +jest.mock('@tech-matters/twilio-configuration', () => ({ + getDocsBucketName: jest.fn(), +})); + +jest.mock('@tech-matters/s3', () => ({ + getS3Object: jest.fn(), +})); + +const mockGetDocsBucketName = getDocsBucketName as jest.MockedFunction< + typeof getDocsBucketName +>; +const mockGetS3Object = getS3Object as jest.MockedFunction; + +const TEST_BUCKET = 'test-docs-bucket'; + +const createMockRequest = (): HttpRequest => ({ + method: 'GET', + headers: {}, + path: '/test', + query: {}, + body: {}, +}); + +describe('getTwilioPrivateConfigurationHandler', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockGetDocsBucketName.mockResolvedValue(TEST_BUCKET); + }); + + it('should return parsed JSON content when configuration file exists', async () => { + const config = { someKey: 'someValue', nested: { flag: true } }; + mockGetS3Object.mockResolvedValue(JSON.stringify(config)); + + const result = await getTwilioPrivateConfigurationHandler( + createMockRequest(), + TEST_ACCOUNT_SID, + ); + + expect(isOk(result)).toBe(true); + if (isOk(result)) { + expect(result.data).toEqual(config); + } + expect(mockGetDocsBucketName).toHaveBeenCalledWith(TEST_ACCOUNT_SID); + expect(mockGetS3Object).toHaveBeenCalledWith( + TEST_BUCKET, + 'configuration/twilio-private.json', + ); + }); + + it('should return an empty object when configuration file does not exist (NoSuchKey)', async () => { + const noSuchKeyError = Object.assign(new Error('The specified key does not exist.'), { + name: 'NoSuchKey', + }); + mockGetS3Object.mockRejectedValue(noSuchKeyError); + + const result = await getTwilioPrivateConfigurationHandler( + createMockRequest(), + TEST_ACCOUNT_SID, + ); + + expect(isOk(result)).toBe(true); + if (isOk(result)) { + expect(result.data).toEqual({}); + } + }); + + it('should return 500 on unexpected S3 error', async () => { + mockGetS3Object.mockRejectedValue(new Error('S3 service unavailable')); + + const result = await getTwilioPrivateConfigurationHandler( + createMockRequest(), + TEST_ACCOUNT_SID, + ); + + expect(isErr(result)).toBe(true); + if (isErr(result)) { + expect(result.message).toBe('S3 service unavailable'); + expect(result.error.statusCode).toBe(500); + } + }); + + it('should return 500 when getDocsBucketName fails', async () => { + mockGetDocsBucketName.mockRejectedValue(new Error('SSM parameter not found')); + + const result = await getTwilioPrivateConfigurationHandler( + createMockRequest(), + TEST_ACCOUNT_SID, + ); + + expect(isErr(result)).toBe(true); + if (isErr(result)) { + expect(result.message).toBe('SSM parameter not found'); + expect(result.error.statusCode).toBe(500); + } + }); +}); From ff334e54aefcf8b8ee9a30cb0c7ea7d53dacef15 Mon Sep 17 00:00:00 2001 From: Stephen Hand Date: Mon, 17 Aug 2026 15:41:06 +0100 Subject: [PATCH 43/53] Redux & client support in plugin for 'private' configuration --- lambdas/account-scoped/src/router.ts | 5 ++- plugin-hrm-form/src/HrmFormPlugin.tsx | 3 ++ .../src/services/configurationService.ts | 23 ++++++++++ .../src/services/fetchProtectedApi.ts | 35 ++++++++++++--- .../configuration/loadPrivateTwilioConfig.ts | 44 +++++++++++++++++++ .../src/states/configuration/reducer.ts | 7 +++ .../configuration/selectQuickDialOptions.ts | 22 ++++++++++ 7 files changed, 132 insertions(+), 7 deletions(-) create mode 100644 plugin-hrm-form/src/services/configurationService.ts create mode 100644 plugin-hrm-form/src/states/configuration/loadPrivateTwilioConfig.ts create mode 100644 plugin-hrm-form/src/states/configuration/selectQuickDialOptions.ts diff --git a/lambdas/account-scoped/src/router.ts b/lambdas/account-scoped/src/router.ts index 4506d784ad..f4a8a25f62 100644 --- a/lambdas/account-scoped/src/router.ts +++ b/lambdas/account-scoped/src/router.ts @@ -411,7 +411,10 @@ const ACCOUNTSID_ROUTES: Record< handler: filterCountryOrVoIPHandler, }), 'configuration/twilioPrivate': newRoute({ - requestPipeline: [validateRequestMethod('GET'), validateWebhookRequest], + requestPipeline: [ + validateRequestMethod('GET'), + validateFlexTokenRequest({ tokenMode: 'agent' }), + ], handler: getTwilioPrivateConfigurationHandler, }), }; diff --git a/plugin-hrm-form/src/HrmFormPlugin.tsx b/plugin-hrm-form/src/HrmFormPlugin.tsx index 1900171f4b..aee6bc64d8 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 { newLoadPrivateTwilioConfigurationAsyncAction } from './states/configuration/loadPrivateTwilioConfig'; const PLUGIN_NAME = 'HrmFormPlugin'; @@ -230,6 +231,8 @@ export default class HrmFormPlugin extends FlexPlugin { }, }; manager.updateConfig(managerConfiguration); + // The 'private' configuration is ok to store in plain text in memory on the client, it doesn't need to be treated as sensitive for security purposes so can be kept in redux + manager.store.dispatch(newLoadPrivateTwilioConfigurationAsyncAction()); // TODO(nick): Eventually remove this log line or set to debug. Should we fail hard here? const { hrmBaseUrl } = config; diff --git a/plugin-hrm-form/src/services/configurationService.ts b/plugin-hrm-form/src/services/configurationService.ts new file mode 100644 index 0000000000..4e727b9f44 --- /dev/null +++ b/plugin-hrm-form/src/services/configurationService.ts @@ -0,0 +1,23 @@ +/** + * 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 { getFromAccountScopedLambda } from './fetchProtectedApi'; +import type { ConfigurationState } from '../states/configuration/reducer'; + +/** + * Sends a new message to the channel bounded to the provided taskSid. Optionally you can change the "from" value (default is "system"). + */ +export const getPrivateTwilioConfiguration = async (): Promise => + getFromAccountScopedLambda(`configuration/twilioPrivate`); diff --git a/plugin-hrm-form/src/services/fetchProtectedApi.ts b/plugin-hrm-form/src/services/fetchProtectedApi.ts index 591319a467..f7af820049 100644 --- a/plugin-hrm-form/src/services/fetchProtectedApi.ts +++ b/plugin-hrm-form/src/services/fetchProtectedApi.ts @@ -34,13 +34,10 @@ export class ProtectedApiError extends ApiError { /** * Factored out function that handles a protected api call hosted in serverless toolkit. * Will throw Error if server responses with and http error code. - * @param {string} endPoint endpoint to fetch from (withouth the host part of url, e.g. "/cases/contacts"). - * @param {{ [k: string]: any }} body Same options object that will be passed to the fetch function (here you can include the BODY of the request) - * @param {FetchOptions & { useTwilioLambda?: boolean }} allOptions - * @returns {Promise} the api response (if not error) + */ const fetchProtectedApi = async ( - endPoint, + endpoint: string, body: Record = {}, allOptions?: FetchOptions & { useTwilioLambda?: boolean; useJsonEncode?: boolean }, ) => { @@ -65,7 +62,33 @@ const fetchProtectedApi = async ( ...fetchOptions, }; try { - return await fetchApi(new URL(useTwilioLambda ? accountScopedLambdaBaseUrl : serverlessBaseUrl), endPoint, options); + return await fetchApi(new URL(useTwilioLambda ? accountScopedLambdaBaseUrl : serverlessBaseUrl), endpoint, options); + } catch (error) { + if (error instanceof ApiError) { + const message = error.response?.status === 403 ? 'Server responded with 403 status (Forbidden)' : error.message; + throw new ProtectedApiError(message, { response: error.response, body: error.body }, error); + } else throw error; + } +}; + +// eslint-disable-next-line import/no-unused-modules +export const postToAccountScopedLambda = async ( + endpoint: string, + body: Record = {}, + allOptions?: FetchOptions & { useJsonEncode?: boolean }, +) => fetchProtectedApi(endpoint, body, { ...(allOptions ?? {}), useTwilioLambda: true }); + +export const getFromAccountScopedLambda = async (endpoint: string, fetchOptions?: FetchOptions) => { + const { accountScopedLambdaBaseUrl } = getHrmConfig(); + const token = getValidToken(); + if (token instanceof Error) throw new ApiError(`Aborting request due to token issue: ${token.message}`, {}, token); + + const options: RequestInit = { + method: 'GET', + ...(fetchOptions ?? {}), + }; + try { + return await fetchApi(new URL(accountScopedLambdaBaseUrl), endpoint, options); } catch (error) { if (error instanceof ApiError) { const message = error.response?.status === 403 ? 'Server responded with 403 status (Forbidden)' : error.message; diff --git a/plugin-hrm-form/src/states/configuration/loadPrivateTwilioConfig.ts b/plugin-hrm-form/src/states/configuration/loadPrivateTwilioConfig.ts new file mode 100644 index 0000000000..2f2605fd89 --- /dev/null +++ b/plugin-hrm-form/src/states/configuration/loadPrivateTwilioConfig.ts @@ -0,0 +1,44 @@ +/** + * 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 { createAsyncAction, createReducer } from 'redux-promise-middleware-actions'; + +import { ConfigurationState } from './reducer'; +import { getPrivateTwilioConfiguration } from '../../services/configurationService'; + +const LOAD_PRIVATE_TWILIO_CONFIGURATION: string = 'configuration-action/load-private-twilio-configuration'; + +type TwilioPrivateConfigurationState = ConfigurationState['twilioPrivateConfiguration']; + +export const newLoadPrivateTwilioConfigurationAsyncAction = createAsyncAction( + LOAD_PRIVATE_TWILIO_CONFIGURATION, + async (): Promise => { + return getPrivateTwilioConfiguration(); + }, +); + +export const loadPrivateTwilioConfigurationReducer = (initialState: ConfigurationState) => + createReducer(initialState, handleAction => [ + handleAction( + newLoadPrivateTwilioConfigurationAsyncAction.fulfilled, + (state, { payload }): ConfigurationState => { + return { + ...state, + twilioPrivateConfiguration: payload, + }; + }, + ), + ]); diff --git a/plugin-hrm-form/src/states/configuration/reducer.ts b/plugin-hrm-form/src/states/configuration/reducer.ts index 1585b196ac..2ac2960a71 100644 --- a/plugin-hrm-form/src/states/configuration/reducer.ts +++ b/plugin-hrm-form/src/states/configuration/reducer.ts @@ -27,6 +27,7 @@ import { SearchContactsSuccessAction, SearchCasesSuccessAction, } from '../search/results'; +import { loadPrivateTwilioConfigurationReducer } from './loadPrivateTwilioConfig'; export type ConfigurationState = { locale: { @@ -40,6 +41,9 @@ export type ConfigurationState = { workerInfo: { chatChannelCapacity: number }; definitionVersions: { [version: string]: DefinitionVersion | undefined }; currentDefinitionVersion?: DefinitionVersion; + twilioPrivateConfiguration: { + quickDialOptions?: { labelKey: string; phoneNumber: string }[]; + }; }; export const initialState: ConfigurationState = { @@ -55,9 +59,11 @@ export const initialState: ConfigurationState = { }, workerInfo: { chatChannelCapacity: 0 }, definitionVersions: {}, + twilioPrivateConfiguration: {}, }; const boundChangeLanguageReducer = changeLanguageReducer(initialState); +const boundLoadPrivateTwilioConfiguration = loadPrivateTwilioConfigurationReducer(initialState); // eslint-disable-next-line import/no-unused-modules export function reduce( @@ -68,6 +74,7 @@ export function reduce( | SearchCasesSuccessAction | SearchContactsSuccessAction, ): ConfigurationState { + inputState = boundLoadPrivateTwilioConfiguration(inputState, action as any); const state = boundChangeLanguageReducer(inputState, action as any); switch (action.type) { diff --git a/plugin-hrm-form/src/states/configuration/selectQuickDialOptions.ts b/plugin-hrm-form/src/states/configuration/selectQuickDialOptions.ts new file mode 100644 index 0000000000..df010d41e7 --- /dev/null +++ b/plugin-hrm-form/src/states/configuration/selectQuickDialOptions.ts @@ -0,0 +1,22 @@ +/** + * 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 { RootState } from '..'; +import { namespace } from '../storeNamespaces'; + +// eslint-disable-next-line import/no-unused-modules +export const selectQuickDialOptions = (state: RootState) => + state[namespace].configuration.twilioPrivateConfiguration.quickDialOptions ?? []; From de981be473deb387628aadab0e1c65edea529728 Mon Sep 17 00:00:00 2001 From: Stephen Hand Date: Mon, 17 Aug 2026 18:08:53 +0100 Subject: [PATCH 44/53] Fix lambda container build --- lambdas/account-scoped/tsconfig.build.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lambdas/account-scoped/tsconfig.build.json b/lambdas/account-scoped/tsconfig.build.json index 04856c6c97..b1fa4e36f8 100644 --- a/lambdas/account-scoped/tsconfig.build.json +++ b/lambdas/account-scoped/tsconfig.build.json @@ -3,12 +3,13 @@ "extends": "./tsconfig.base.json", "files": [], "references": [ + { "path": "packages/s3" }, { "path": "packages/ssm-cache" }, { "path": "packages/configuration" }, { "path": "packages/hrm-types" }, { "path": "packages/hrm-form-definitions" }, { "path": "packages/twilio-types" }, { "path": "packages/twilio-configuration" }, - { "path": "account-scoped" }, + { "path": "account-scoped" } ] } From 70172cd9cfb1aad7d08508830da725ab55e1b687 Mon Sep 17 00:00:00 2001 From: Stephen Hand Date: Tue, 18 Aug 2026 08:23:11 +0100 Subject: [PATCH 45/53] Add support for flex token in auth header for account-scoped lambda --- lambdas/account-scoped/src/validation/flexToken.ts | 11 +++++++++-- plugin-hrm-form/src/HrmFormPlugin.tsx | 8 ++++++-- plugin-hrm-form/src/services/fetchProtectedApi.ts | 6 ++++++ 3 files changed, 21 insertions(+), 4 deletions(-) diff --git a/lambdas/account-scoped/src/validation/flexToken.ts b/lambdas/account-scoped/src/validation/flexToken.ts index dbc876bb96..e26464f654 100644 --- a/lambdas/account-scoped/src/validation/flexToken.ts +++ b/lambdas/account-scoped/src/validation/flexToken.ts @@ -46,9 +46,16 @@ export const validateFlexTokenRequest: ({ }) => HttpRequestPipelineStep = ({ tokenMode }: { tokenMode: 'supervisor' | 'agent' | 'guest' }) => async (request, { accountSid }) => { - const { Token: token } = request.body; + let token: string; + if (request.headers?.authorization?.startsWith('Bearer ')) { + token = request.headers?.authorization.slice('Bearer '.length); + } else { + token = request.body?.Token; + } if (!token) { - return newMissingParameterResult('Token'); + return newMissingParameterResult( + 'Bearer authorization header or Token body parameter', + ); } try { const tokenResult: TokenValidatorResponse = (await validator( diff --git a/plugin-hrm-form/src/HrmFormPlugin.tsx b/plugin-hrm-form/src/HrmFormPlugin.tsx index aee6bc64d8..88f75d2a1d 100644 --- a/plugin-hrm-form/src/HrmFormPlugin.tsx +++ b/plugin-hrm-form/src/HrmFormPlugin.tsx @@ -49,6 +49,7 @@ import { setUpFullStory } from './fullStory/setUp'; import { getPathFromUrl } from './states/routing/reducer'; import { setUpCustomSideLinks } from './components/customSideLinks/setUpCustomSideLinks'; import { newLoadPrivateTwilioConfigurationAsyncAction } from './states/configuration/loadPrivateTwilioConfig'; +import asyncDispatch from "./states/asyncDispatch"; const PLUGIN_NAME = 'HrmFormPlugin'; @@ -232,8 +233,11 @@ export default class HrmFormPlugin extends FlexPlugin { }; manager.updateConfig(managerConfiguration); // The 'private' configuration is ok to store in plain text in memory on the client, it doesn't need to be treated as sensitive for security purposes so can be kept in redux - manager.store.dispatch(newLoadPrivateTwilioConfigurationAsyncAction()); - + try { + await asyncDispatch(manager.store.dispatch)(newLoadPrivateTwilioConfigurationAsyncAction()); + } catch (error) { + console.warn('Failed to load private configuration', error); + } // TODO(nick): Eventually remove this log line or set to debug. Should we fail hard here? const { hrmBaseUrl } = config; console.info(`HRM URL: ${hrmBaseUrl}`); diff --git a/plugin-hrm-form/src/services/fetchProtectedApi.ts b/plugin-hrm-form/src/services/fetchProtectedApi.ts index f7af820049..fc112a4ff8 100644 --- a/plugin-hrm-form/src/services/fetchProtectedApi.ts +++ b/plugin-hrm-form/src/services/fetchProtectedApi.ts @@ -57,7 +57,9 @@ const fetchProtectedApi = async ( method: 'POST', body: encodedBody, headers: { + Authorization: `Bearer ${token}`, 'Content-Type': contentType, + ...fetchOptions.headers, }, ...fetchOptions, }; @@ -86,6 +88,10 @@ export const getFromAccountScopedLambda = async (endpoint: string, fetchOptions? const options: RequestInit = { method: 'GET', ...(fetchOptions ?? {}), + headers: { + Authorization: `Bearer ${token}`, + ...fetchOptions.headers, + }, }; try { return await fetchApi(new URL(accountScopedLambdaBaseUrl), endpoint, options); From e2ccc89e511fc04c9555343d80fd981079d3979c Mon Sep 17 00:00:00 2001 From: Stephen Hand Date: Tue, 18 Aug 2026 08:26:49 +0100 Subject: [PATCH 46/53] Comment about deprecation --- plugin-hrm-form/src/services/fetchProtectedApi.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/plugin-hrm-form/src/services/fetchProtectedApi.ts b/plugin-hrm-form/src/services/fetchProtectedApi.ts index fc112a4ff8..ff808048fe 100644 --- a/plugin-hrm-form/src/services/fetchProtectedApi.ts +++ b/plugin-hrm-form/src/services/fetchProtectedApi.ts @@ -46,6 +46,8 @@ const fetchProtectedApi = async ( const token = getValidToken(); if (token instanceof Error) throw new ApiError(`Aborting request due to token issue: ${token.message}`, {}, token); + // Adding the token to the body is for backwards compatibility only + // Once serverless is fully deprecated and all account scoped lambdas are past v2.65.x it can be removed const { contentType, encodedBody } = useJsonEncode ? { contentType: 'application/json', encodedBody: JSON.stringify({ ...body, Token: token }) } : { From 919562ddb30bbd43373b2dfedfd20570dcb0dbf6 Mon Sep 17 00:00:00 2001 From: Stephen Hand Date: Tue, 18 Aug 2026 08:40:32 +0100 Subject: [PATCH 47/53] Move account scoped lambda service methods to separate file --- .../src/services/configurationService.ts | 2 +- .../services/fetchAccountScopedLambdaApi.ts | 49 +++++++++++++++++++ .../src/services/fetchProtectedApi.ts | 38 ++------------ 3 files changed, 55 insertions(+), 34 deletions(-) create mode 100644 plugin-hrm-form/src/services/fetchAccountScopedLambdaApi.ts diff --git a/plugin-hrm-form/src/services/configurationService.ts b/plugin-hrm-form/src/services/configurationService.ts index 4e727b9f44..86fabd0b58 100644 --- a/plugin-hrm-form/src/services/configurationService.ts +++ b/plugin-hrm-form/src/services/configurationService.ts @@ -13,8 +13,8 @@ * 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 { getFromAccountScopedLambda } from './fetchProtectedApi'; import type { ConfigurationState } from '../states/configuration/reducer'; +import {getFromAccountScopedLambda} from "./fetchAccountScopedLambdaApi"; /** * Sends a new message to the channel bounded to the provided taskSid. Optionally you can change the "from" value (default is "system"). diff --git a/plugin-hrm-form/src/services/fetchAccountScopedLambdaApi.ts b/plugin-hrm-form/src/services/fetchAccountScopedLambdaApi.ts new file mode 100644 index 0000000000..fa7f9430d6 --- /dev/null +++ b/plugin-hrm-form/src/services/fetchAccountScopedLambdaApi.ts @@ -0,0 +1,49 @@ +/** + * 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-next-line import/no-unused-modules +import { ApiError, fetchApi, FetchOptions } from './fetchApi'; +import { getHrmConfig } from '../hrmConfig'; +import { getValidToken } from '../authentication'; +import fetchProtectedApi, { ProtectedApiError } from './fetchProtectedApi'; + +export const postToAccountScopedLambda = async ( + endpoint: string, + body: Record = {}, + allOptions?: FetchOptions & { useJsonEncode?: boolean }, +) => fetchProtectedApi(endpoint, body, { ...(allOptions ?? {}), useTwilioLambda: true }); +export const getFromAccountScopedLambda = async (endpoint: string, fetchOptions?: FetchOptions) => { + const { accountScopedLambdaBaseUrl } = getHrmConfig(); + const token = getValidToken(); + if (token instanceof Error) throw new ApiError(`Aborting request due to token issue: ${token.message}`, {}, token); + + const options: RequestInit = { + method: 'GET', + ...(fetchOptions ?? {}), + headers: { + Authorization: `Bearer ${token}`, + ...fetchOptions.headers, + }, + }; + try { + return await fetchApi(new URL(accountScopedLambdaBaseUrl), endpoint, options); + } catch (error) { + if (error instanceof ApiError) { + const message = error.response?.status === 403 ? 'Server responded with 403 status (Forbidden)' : error.message; + throw new ProtectedApiError(message, { response: error.response, body: error.body }, error); + } else throw error; + } +}; diff --git a/plugin-hrm-form/src/services/fetchProtectedApi.ts b/plugin-hrm-form/src/services/fetchProtectedApi.ts index ff808048fe..a9095a9685 100644 --- a/plugin-hrm-form/src/services/fetchProtectedApi.ts +++ b/plugin-hrm-form/src/services/fetchProtectedApi.ts @@ -1,5 +1,5 @@ /** - * Copyright (C) 2021-2023 Technology Matters + * 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 @@ -34,9 +34,10 @@ export class ProtectedApiError extends ApiError { /** * Factored out function that handles a protected api call hosted in serverless toolkit. * Will throw Error if server responses with and http error code. - + * TODO: Once serverless is fully deprecated, move all account scoped lambda calls to go via fetchAccountScopedLambdaApi.ts methods + * TODO: Then refactor this to be a generic base method that fetchHrmApi, fetchResourcesApi and fetchAccountScopedApi all call to add the token to the request */ -const fetchProtectedApi = async ( +export const fetchProtectedApi = async ( endpoint: string, body: Record = {}, allOptions?: FetchOptions & { useTwilioLambda?: boolean; useJsonEncode?: boolean }, @@ -48,6 +49,7 @@ const fetchProtectedApi = async ( // Adding the token to the body is for backwards compatibility only // Once serverless is fully deprecated and all account scoped lambdas are past v2.65.x it can be removed + // Also, support for form encoded payloads can probably be removed once serverless is deprecated too, since account-scoped lambda supports JSON request bodies on all endpoints const { contentType, encodedBody } = useJsonEncode ? { contentType: 'application/json', encodedBody: JSON.stringify({ ...body, Token: token }) } : { @@ -75,34 +77,4 @@ const fetchProtectedApi = async ( } }; -// eslint-disable-next-line import/no-unused-modules -export const postToAccountScopedLambda = async ( - endpoint: string, - body: Record = {}, - allOptions?: FetchOptions & { useJsonEncode?: boolean }, -) => fetchProtectedApi(endpoint, body, { ...(allOptions ?? {}), useTwilioLambda: true }); - -export const getFromAccountScopedLambda = async (endpoint: string, fetchOptions?: FetchOptions) => { - const { accountScopedLambdaBaseUrl } = getHrmConfig(); - const token = getValidToken(); - if (token instanceof Error) throw new ApiError(`Aborting request due to token issue: ${token.message}`, {}, token); - - const options: RequestInit = { - method: 'GET', - ...(fetchOptions ?? {}), - headers: { - Authorization: `Bearer ${token}`, - ...fetchOptions.headers, - }, - }; - try { - return await fetchApi(new URL(accountScopedLambdaBaseUrl), endpoint, options); - } catch (error) { - if (error instanceof ApiError) { - const message = error.response?.status === 403 ? 'Server responded with 403 status (Forbidden)' : error.message; - throw new ProtectedApiError(message, { response: error.response, body: error.body }, error); - } else throw error; - } -}; - export default fetchProtectedApi; From 522f248375377529ccd3651828a77cb4b5b10738 Mon Sep 17 00:00:00 2001 From: Stephen Hand Date: Tue, 18 Aug 2026 09:15:48 +0100 Subject: [PATCH 48/53] Rename 'private twilio configuration' -> 'aselo twilio configuration' --- ...tion.ts => getAseloTwilioConfiguration.ts} | 6 ++--- lambdas/account-scoped/src/router.ts | 6 ++--- ...ts => getAseloTwilioConfiguration.test.ts} | 10 ++++---- plugin-hrm-form/src/HrmFormPlugin.tsx | 6 ++--- .../src/services/configurationService.ts | 6 ++--- ...fig.ts => loadAseloTwilioConfiguration.ts} | 23 ++++++++++++------- .../src/states/configuration/reducer.ts | 10 ++++---- .../configuration/selectQuickDialOptions.ts | 2 +- 8 files changed, 38 insertions(+), 31 deletions(-) rename lambdas/account-scoped/src/configuration/{getTwilioPrivateConfiguration.ts => getAseloTwilioConfiguration.ts} (84%) rename lambdas/account-scoped/tests/unit/configuration/{getTwilioPrivateConfiguration.test.ts => getAseloTwilioConfiguration.test.ts} (90%) rename plugin-hrm-form/src/states/configuration/{loadPrivateTwilioConfig.ts => loadAseloTwilioConfiguration.ts} (60%) diff --git a/lambdas/account-scoped/src/configuration/getTwilioPrivateConfiguration.ts b/lambdas/account-scoped/src/configuration/getAseloTwilioConfiguration.ts similarity index 84% rename from lambdas/account-scoped/src/configuration/getTwilioPrivateConfiguration.ts rename to lambdas/account-scoped/src/configuration/getAseloTwilioConfiguration.ts index f25f7c2e38..d613e3b313 100644 --- a/lambdas/account-scoped/src/configuration/getTwilioPrivateConfiguration.ts +++ b/lambdas/account-scoped/src/configuration/getAseloTwilioConfiguration.ts @@ -20,15 +20,15 @@ import { getS3Object } from '@tech-matters/s3'; import { newErr, newOk } from '../Result'; import { AccountScopedHandler } from '../httpTypes'; -const TWILIO_PRIVATE_CONFIGURATION_KEY = 'configuration/twilio-private.json'; +const ASELO_TWILIO_CONFIGURATION_KEY = 'configuration/twilio.json'; -export const getTwilioPrivateConfigurationHandler: AccountScopedHandler = async ( +export const getAseloTwilioConfigurationHandler: AccountScopedHandler = async ( _event, accountSid: AccountSID, ) => { try { const bucket = await getDocsBucketName(accountSid); - const content = await getS3Object(bucket, TWILIO_PRIVATE_CONFIGURATION_KEY); + const content = await getS3Object(bucket, ASELO_TWILIO_CONFIGURATION_KEY); return newOk(JSON.parse(content)); } catch (err: any) { if (err?.name === 'NoSuchKey') { diff --git a/lambdas/account-scoped/src/router.ts b/lambdas/account-scoped/src/router.ts index f4a8a25f62..3140bf5780 100644 --- a/lambdas/account-scoped/src/router.ts +++ b/lambdas/account-scoped/src/router.ts @@ -84,7 +84,7 @@ import { triggerPostStudioFlowHandler } from './studioFlow/postStudioFlowTaskRou import { randomOptionSelectorHandler } from './randomOptionSelector'; import { isSkilledWorkerAvailableHandler } from './worker/isSkilledWorkerAvailable'; import { filterCountryOrVoIPHandler } from './voice/filterCountryOrVoIP'; -import { getTwilioPrivateConfigurationHandler } from './configuration/getTwilioPrivateConfiguration'; +import { getAseloTwilioConfigurationHandler } from './configuration/getAseloTwilioConfiguration'; /** * Super simple router sufficient for directly ported Twilio Serverless functions @@ -410,12 +410,12 @@ const ACCOUNTSID_ROUTES: Record< requestPipeline: [validateRequestMethod('POST'), validateWebhookRequest], handler: filterCountryOrVoIPHandler, }), - 'configuration/twilioPrivate': newRoute({ + 'configuration/twilio': newRoute({ requestPipeline: [ validateRequestMethod('GET'), validateFlexTokenRequest({ tokenMode: 'agent' }), ], - handler: getTwilioPrivateConfigurationHandler, + handler: getAseloTwilioConfigurationHandler, }), }; diff --git a/lambdas/account-scoped/tests/unit/configuration/getTwilioPrivateConfiguration.test.ts b/lambdas/account-scoped/tests/unit/configuration/getAseloTwilioConfiguration.test.ts similarity index 90% rename from lambdas/account-scoped/tests/unit/configuration/getTwilioPrivateConfiguration.test.ts rename to lambdas/account-scoped/tests/unit/configuration/getAseloTwilioConfiguration.test.ts index 156e45bc2f..b601f75b71 100644 --- a/lambdas/account-scoped/tests/unit/configuration/getTwilioPrivateConfiguration.test.ts +++ b/lambdas/account-scoped/tests/unit/configuration/getAseloTwilioConfiguration.test.ts @@ -14,7 +14,7 @@ * along with this program. If not, see https://www.gnu.org/licenses/. */ -import { getTwilioPrivateConfigurationHandler } from '../../../src/configuration/getTwilioPrivateConfiguration'; +import { getAseloTwilioConfigurationHandler } from '../../../src/configuration/getAseloTwilioConfiguration'; import { getDocsBucketName } from '@tech-matters/twilio-configuration'; import { getS3Object } from '@tech-matters/s3'; import { isErr, isOk } from '../../../src/Result'; @@ -54,7 +54,7 @@ describe('getTwilioPrivateConfigurationHandler', () => { const config = { someKey: 'someValue', nested: { flag: true } }; mockGetS3Object.mockResolvedValue(JSON.stringify(config)); - const result = await getTwilioPrivateConfigurationHandler( + const result = await getAseloTwilioConfigurationHandler( createMockRequest(), TEST_ACCOUNT_SID, ); @@ -76,7 +76,7 @@ describe('getTwilioPrivateConfigurationHandler', () => { }); mockGetS3Object.mockRejectedValue(noSuchKeyError); - const result = await getTwilioPrivateConfigurationHandler( + const result = await getAseloTwilioConfigurationHandler( createMockRequest(), TEST_ACCOUNT_SID, ); @@ -90,7 +90,7 @@ describe('getTwilioPrivateConfigurationHandler', () => { it('should return 500 on unexpected S3 error', async () => { mockGetS3Object.mockRejectedValue(new Error('S3 service unavailable')); - const result = await getTwilioPrivateConfigurationHandler( + const result = await getAseloTwilioConfigurationHandler( createMockRequest(), TEST_ACCOUNT_SID, ); @@ -105,7 +105,7 @@ describe('getTwilioPrivateConfigurationHandler', () => { it('should return 500 when getDocsBucketName fails', async () => { mockGetDocsBucketName.mockRejectedValue(new Error('SSM parameter not found')); - const result = await getTwilioPrivateConfigurationHandler( + const result = await getAseloTwilioConfigurationHandler( createMockRequest(), TEST_ACCOUNT_SID, ); diff --git a/plugin-hrm-form/src/HrmFormPlugin.tsx b/plugin-hrm-form/src/HrmFormPlugin.tsx index 88f75d2a1d..c2e113b24b 100644 --- a/plugin-hrm-form/src/HrmFormPlugin.tsx +++ b/plugin-hrm-form/src/HrmFormPlugin.tsx @@ -48,7 +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 { newLoadPrivateTwilioConfigurationAsyncAction } from './states/configuration/loadPrivateTwilioConfig'; +import { newLoadAseloTwilioConfigurationAsyncAction } from './states/configuration/loadAseloTwilioConfiguration'; import asyncDispatch from "./states/asyncDispatch"; const PLUGIN_NAME = 'HrmFormPlugin'; @@ -234,9 +234,9 @@ export default class HrmFormPlugin extends FlexPlugin { manager.updateConfig(managerConfiguration); // The 'private' configuration is ok to store in plain text in memory on the client, it doesn't need to be treated as sensitive for security purposes so can be kept in redux try { - await asyncDispatch(manager.store.dispatch)(newLoadPrivateTwilioConfigurationAsyncAction()); + await asyncDispatch(manager.store.dispatch)(newLoadAseloTwilioConfigurationAsyncAction()); } catch (error) { - console.warn('Failed to load private configuration', error); + console.warn('Failed to load private configuration, using default', error); } // TODO(nick): Eventually remove this log line or set to debug. Should we fail hard here? const { hrmBaseUrl } = config; diff --git a/plugin-hrm-form/src/services/configurationService.ts b/plugin-hrm-form/src/services/configurationService.ts index 86fabd0b58..cad80f13f3 100644 --- a/plugin-hrm-form/src/services/configurationService.ts +++ b/plugin-hrm-form/src/services/configurationService.ts @@ -14,10 +14,10 @@ * along with this program. If not, see https://www.gnu.org/licenses/. */ import type { ConfigurationState } from '../states/configuration/reducer'; -import {getFromAccountScopedLambda} from "./fetchAccountScopedLambdaApi"; +import { getFromAccountScopedLambda } from './fetchAccountScopedLambdaApi'; /** * Sends a new message to the channel bounded to the provided taskSid. Optionally you can change the "from" value (default is "system"). */ -export const getPrivateTwilioConfiguration = async (): Promise => - getFromAccountScopedLambda(`configuration/twilioPrivate`); +export const getAseloTwilioConfiguration = async (): Promise => + getFromAccountScopedLambda(`configuration/twilio`); diff --git a/plugin-hrm-form/src/states/configuration/loadPrivateTwilioConfig.ts b/plugin-hrm-form/src/states/configuration/loadAseloTwilioConfiguration.ts similarity index 60% rename from plugin-hrm-form/src/states/configuration/loadPrivateTwilioConfig.ts rename to plugin-hrm-form/src/states/configuration/loadAseloTwilioConfiguration.ts index 2f2605fd89..d1179ac7c4 100644 --- a/plugin-hrm-form/src/states/configuration/loadPrivateTwilioConfig.ts +++ b/plugin-hrm-form/src/states/configuration/loadAseloTwilioConfiguration.ts @@ -17,28 +17,35 @@ import { createAsyncAction, createReducer } from 'redux-promise-middleware-actions'; import { ConfigurationState } from './reducer'; -import { getPrivateTwilioConfiguration } from '../../services/configurationService'; +import { getAseloTwilioConfiguration } from '../../services/configurationService'; const LOAD_PRIVATE_TWILIO_CONFIGURATION: string = 'configuration-action/load-private-twilio-configuration'; -type TwilioPrivateConfigurationState = ConfigurationState['twilioPrivateConfiguration']; +type AseloTwilioConfigurationState = ConfigurationState['aseloTwilioConfiguration']; -export const newLoadPrivateTwilioConfigurationAsyncAction = createAsyncAction( +export const newLoadAseloTwilioConfigurationAsyncAction = createAsyncAction( LOAD_PRIVATE_TWILIO_CONFIGURATION, - async (): Promise => { - return getPrivateTwilioConfiguration(); + async (): Promise => { + return getAseloTwilioConfiguration(); }, ); -export const loadPrivateTwilioConfigurationReducer = (initialState: ConfigurationState) => +export const loadAseloTwilioConfigurationReducer = (initialState: ConfigurationState) => createReducer(initialState, handleAction => [ handleAction( - newLoadPrivateTwilioConfigurationAsyncAction.fulfilled, + newLoadAseloTwilioConfigurationAsyncAction.fulfilled, (state, { payload }): ConfigurationState => { return { ...state, - twilioPrivateConfiguration: payload, + aseloTwilioConfiguration: payload, }; }, ), + handleAction( + newLoadAseloTwilioConfigurationAsyncAction.rejected, + (state, { payload }): ConfigurationState => { + console.warn(`Failed to load aselo twilio configuration`, payload); + return state; + }, + ), ]); diff --git a/plugin-hrm-form/src/states/configuration/reducer.ts b/plugin-hrm-form/src/states/configuration/reducer.ts index 2ac2960a71..3208ada9f5 100644 --- a/plugin-hrm-form/src/states/configuration/reducer.ts +++ b/plugin-hrm-form/src/states/configuration/reducer.ts @@ -27,7 +27,7 @@ import { SearchContactsSuccessAction, SearchCasesSuccessAction, } from '../search/results'; -import { loadPrivateTwilioConfigurationReducer } from './loadPrivateTwilioConfig'; +import { loadAseloTwilioConfigurationReducer } from './loadAseloTwilioConfiguration'; export type ConfigurationState = { locale: { @@ -41,7 +41,7 @@ export type ConfigurationState = { workerInfo: { chatChannelCapacity: number }; definitionVersions: { [version: string]: DefinitionVersion | undefined }; currentDefinitionVersion?: DefinitionVersion; - twilioPrivateConfiguration: { + aseloTwilioConfiguration: { quickDialOptions?: { labelKey: string; phoneNumber: string }[]; }; }; @@ -59,11 +59,11 @@ export const initialState: ConfigurationState = { }, workerInfo: { chatChannelCapacity: 0 }, definitionVersions: {}, - twilioPrivateConfiguration: {}, + aseloTwilioConfiguration: {}, }; const boundChangeLanguageReducer = changeLanguageReducer(initialState); -const boundLoadPrivateTwilioConfiguration = loadPrivateTwilioConfigurationReducer(initialState); +const boundLoadAseloTwilioConfiguration = loadAseloTwilioConfigurationReducer(initialState); // eslint-disable-next-line import/no-unused-modules export function reduce( @@ -74,7 +74,7 @@ export function reduce( | SearchCasesSuccessAction | SearchContactsSuccessAction, ): ConfigurationState { - inputState = boundLoadPrivateTwilioConfiguration(inputState, action as any); + inputState = boundLoadAseloTwilioConfiguration(inputState, action as any); const state = boundChangeLanguageReducer(inputState, action as any); switch (action.type) { diff --git a/plugin-hrm-form/src/states/configuration/selectQuickDialOptions.ts b/plugin-hrm-form/src/states/configuration/selectQuickDialOptions.ts index df010d41e7..d5220268b4 100644 --- a/plugin-hrm-form/src/states/configuration/selectQuickDialOptions.ts +++ b/plugin-hrm-form/src/states/configuration/selectQuickDialOptions.ts @@ -19,4 +19,4 @@ import { namespace } from '../storeNamespaces'; // eslint-disable-next-line import/no-unused-modules export const selectQuickDialOptions = (state: RootState) => - state[namespace].configuration.twilioPrivateConfiguration.quickDialOptions ?? []; + state[namespace].configuration.aseloTwilioConfiguration.quickDialOptions ?? []; From 5c89b88e48c9e49d1a09bc3b452f460a1b778f87 Mon Sep 17 00:00:00 2001 From: Stephen Hand Date: Tue, 18 Aug 2026 09:19:58 +0100 Subject: [PATCH 49/53] Fix tests --- .../unit/configuration/getAseloTwilioConfiguration.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lambdas/account-scoped/tests/unit/configuration/getAseloTwilioConfiguration.test.ts b/lambdas/account-scoped/tests/unit/configuration/getAseloTwilioConfiguration.test.ts index b601f75b71..c420cb46eb 100644 --- a/lambdas/account-scoped/tests/unit/configuration/getAseloTwilioConfiguration.test.ts +++ b/lambdas/account-scoped/tests/unit/configuration/getAseloTwilioConfiguration.test.ts @@ -44,7 +44,7 @@ const createMockRequest = (): HttpRequest => ({ body: {}, }); -describe('getTwilioPrivateConfigurationHandler', () => { +describe('getAseloTwilioConfigurationHandler', () => { beforeEach(() => { jest.clearAllMocks(); mockGetDocsBucketName.mockResolvedValue(TEST_BUCKET); @@ -66,7 +66,7 @@ describe('getTwilioPrivateConfigurationHandler', () => { expect(mockGetDocsBucketName).toHaveBeenCalledWith(TEST_ACCOUNT_SID); expect(mockGetS3Object).toHaveBeenCalledWith( TEST_BUCKET, - 'configuration/twilio-private.json', + 'configuration/twilio.json', ); }); From 9f204e0f261ef04a84afa7b89d671e15bca342e4 Mon Sep 17 00:00:00 2001 From: Stephen Hand Date: Tue, 18 Aug 2026 09:23:46 +0100 Subject: [PATCH 50/53] Fix lint --- plugin-hrm-form/src/HrmFormPlugin.tsx | 2 +- plugin-hrm-form/src/services/ServerlessService.ts | 1 - plugin-hrm-form/src/services/fetchAccountScopedLambdaApi.ts | 1 + plugin-hrm-form/src/services/fetchProtectedApi.ts | 2 +- 4 files changed, 3 insertions(+), 3 deletions(-) diff --git a/plugin-hrm-form/src/HrmFormPlugin.tsx b/plugin-hrm-form/src/HrmFormPlugin.tsx index c2e113b24b..a73bd07ea8 100644 --- a/plugin-hrm-form/src/HrmFormPlugin.tsx +++ b/plugin-hrm-form/src/HrmFormPlugin.tsx @@ -49,7 +49,7 @@ import { setUpFullStory } from './fullStory/setUp'; import { getPathFromUrl } from './states/routing/reducer'; import { setUpCustomSideLinks } from './components/customSideLinks/setUpCustomSideLinks'; import { newLoadAseloTwilioConfigurationAsyncAction } from './states/configuration/loadAseloTwilioConfiguration'; -import asyncDispatch from "./states/asyncDispatch"; +import asyncDispatch from './states/asyncDispatch'; const PLUGIN_NAME = 'HrmFormPlugin'; diff --git a/plugin-hrm-form/src/services/ServerlessService.ts b/plugin-hrm-form/src/services/ServerlessService.ts index 9e92225f82..01c85a539f 100644 --- a/plugin-hrm-form/src/services/ServerlessService.ts +++ b/plugin-hrm-form/src/services/ServerlessService.ts @@ -20,7 +20,6 @@ /* eslint-disable sonarjs/prefer-immediate-return */ /* eslint-disable camelcase */ -import { ITask, Notifications } from '@twilio/flex-ui'; import { DefinitionVersion, loadDefinition } from 'hrm-form-definitions'; import fetchProtectedApi from './fetchProtectedApi'; diff --git a/plugin-hrm-form/src/services/fetchAccountScopedLambdaApi.ts b/plugin-hrm-form/src/services/fetchAccountScopedLambdaApi.ts index fa7f9430d6..7b00e2947b 100644 --- a/plugin-hrm-form/src/services/fetchAccountScopedLambdaApi.ts +++ b/plugin-hrm-form/src/services/fetchAccountScopedLambdaApi.ts @@ -20,6 +20,7 @@ import { getHrmConfig } from '../hrmConfig'; import { getValidToken } from '../authentication'; import fetchProtectedApi, { ProtectedApiError } from './fetchProtectedApi'; +// eslint-disable-next-line import/no-unused-modules export const postToAccountScopedLambda = async ( endpoint: string, body: Record = {}, diff --git a/plugin-hrm-form/src/services/fetchProtectedApi.ts b/plugin-hrm-form/src/services/fetchProtectedApi.ts index a9095a9685..7647c2cb49 100644 --- a/plugin-hrm-form/src/services/fetchProtectedApi.ts +++ b/plugin-hrm-form/src/services/fetchProtectedApi.ts @@ -37,7 +37,7 @@ export class ProtectedApiError extends ApiError { * TODO: Once serverless is fully deprecated, move all account scoped lambda calls to go via fetchAccountScopedLambdaApi.ts methods * TODO: Then refactor this to be a generic base method that fetchHrmApi, fetchResourcesApi and fetchAccountScopedApi all call to add the token to the request */ -export const fetchProtectedApi = async ( +const fetchProtectedApi = async ( endpoint: string, body: Record = {}, allOptions?: FetchOptions & { useTwilioLambda?: boolean; useJsonEncode?: boolean }, From ff9c892e2982fec254fa60207572a2ea14f3da65 Mon Sep 17 00:00:00 2001 From: Stephen Hand Date: Tue, 18 Aug 2026 12:10:33 +0100 Subject: [PATCH 51/53] Finish wiring up configuration to quickdial dialog --- .../as/v1/customStrings/Substitutions.json | 4 ++- .../ConferenceActions/PhoneInputDialog.tsx | 26 +++++++------------ plugin-hrm-form/src/hrmConfig.ts | 2 ++ .../src/services/configurationService.ts | 2 +- .../services/fetchAccountScopedLambdaApi.ts | 3 ++- .../src/services/fetchProtectedApi.ts | 2 +- plugin-hrm-form/src/translations/en.json | 2 -- 7 files changed, 19 insertions(+), 22 deletions(-) diff --git a/lambdas/packages/hrm-form-definitions/form-definitions/as/v1/customStrings/Substitutions.json b/lambdas/packages/hrm-form-definitions/form-definitions/as/v1/customStrings/Substitutions.json index 94f4cab013..58561cfa5e 100644 --- a/lambdas/packages/hrm-form-definitions/form-definitions/as/v1/customStrings/Substitutions.json +++ b/lambdas/packages/hrm-form-definitions/form-definitions/as/v1/customStrings/Substitutions.json @@ -4,7 +4,9 @@ "Switchboard-NoQueuesSwitchboarded": "No queues are currently being switchboarded", "Admin": "Translated Admin", "Chat Queue Test": "Quat Teue Chest", - "CustomLink-Label-ResourceMap": "Resource Map" + "CustomLink-Label-ResourceMap": "Resource Map", + "Conference-PhoneInputDialog-QuickDialItem/988-English": "988 (English)", + "Conference-PhoneInputDialog-QuickDialItem/988-Spanish": "988 (Spanish)" }, "es": { "HelplineSubstitution": "Substitución de la Línea de Ayuda", diff --git a/plugin-hrm-form/src/components/Conference/ConferenceActions/PhoneInputDialog.tsx b/plugin-hrm-form/src/components/Conference/ConferenceActions/PhoneInputDialog.tsx index 841da969bc..ca4e1c4b0b 100644 --- a/plugin-hrm-form/src/components/Conference/ConferenceActions/PhoneInputDialog.tsx +++ b/plugin-hrm-form/src/components/Conference/ConferenceActions/PhoneInputDialog.tsx @@ -17,9 +17,12 @@ import React from 'react'; import { Template, Manager } from '@twilio/flex-ui'; import { CallEnd as CallEndIcon } from '@material-ui/icons'; import { CircularProgress } from '@material-ui/core'; +import { useSelector } from 'react-redux'; import { Row, Bold, CloseButton, SecondaryButton } from '../../../styles'; import { PhoneDialogWrapper, DialogArrow } from './styles'; +import { selectQuickDialOptions } from '../../../states/configuration/selectQuickDialOptions'; +import { getHrmConfig } from '../../../hrmConfig'; type PhoneDialogProps = { targetNumber: string; @@ -32,18 +35,6 @@ type PhoneDialogProps = { const ENTER_NUMBER_KEY = 'Conference-EnterPhoneNumber'; -type QuickDialItem = { - labelKey: string; - phoneNumber: string; -}; - -const TEMPORARY_HARDCODED_QUICKDIAL: QuickDialItem[] = [ - { labelKey: 'Conference-PhoneInputDialog-QuickDialItem/988-English', phoneNumber: '+35314482861' }, - { labelKey: 'Conference-PhoneInputDialog-QuickDialItem/988-Spanish', phoneNumber: '+35317712424 ' }, -]; - -const ALLOW_MANUAL_DIAL: boolean = true; - const PhoneInputDialog: React.FC = ({ targetNumber, setTargetNumber, @@ -52,6 +43,9 @@ const PhoneInputDialog: React.FC = ({ setIsDialogOpen, isLoading, }) => { + const quickDialOptions = useSelector(selectQuickDialOptions); + const { allowManualDialOutForConferencing } = getHrmConfig(); + const dialButton = (handleClick: () => void) => { return ( @@ -79,22 +73,22 @@ const PhoneInputDialog: React.FC = ({ setIsDialogOpen(false)} aria-label="CloseButton" style={{ marginLeft: 'auto' }} /> - {TEMPORARY_HARDCODED_QUICKDIAL.length && ( + {Boolean(quickDialOptions.length) && (