From 4566f0e466d7f9a4de4e43c6dbfca81a9bbfacb8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 26 Feb 2026 08:45:03 +0000 Subject: [PATCH 1/6] Initial plan From 86b0af8097d0d8d518ffa8c00bca7835ad1bbef4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 26 Feb 2026 09:02:06 +0000 Subject: [PATCH 2/6] Migrate Telegram, Modica, and Line custom channels to account-scoped lambda Co-authored-by: stephenhand <1694716+stephenhand@users.noreply.github.com> --- .../src/customChannels/configuration.ts | 24 ++ .../src/customChannels/line/flexToLine.ts | 129 +++++++++ .../src/customChannels/line/lineToFlex.ts | 161 +++++++++++ .../src/customChannels/modica/flexToModica.ts | 137 +++++++++ .../src/customChannels/modica/modicaToFlex.ts | 78 +++++ .../customChannels/telegram/flexToTelegram.ts | 116 ++++++++ .../customChannels/telegram/telegramToFlex.ts | 94 ++++++ lambdas/account-scoped/src/router.ts | 30 ++ .../customChannels/line/flexToLine.test.ts | 234 +++++++++++++++ .../customChannels/line/lineToFlex.test.ts | 230 +++++++++++++++ .../modica/flexToModica.test.ts | 267 ++++++++++++++++++ .../modica/modicaToFlex.test.ts | 146 ++++++++++ .../telegram/flexToTelegram.test.ts | 231 +++++++++++++++ .../telegram/telegramToFlex.test.ts | 170 +++++++++++ 14 files changed, 2047 insertions(+) create mode 100644 lambdas/account-scoped/src/customChannels/line/flexToLine.ts create mode 100644 lambdas/account-scoped/src/customChannels/line/lineToFlex.ts create mode 100644 lambdas/account-scoped/src/customChannels/modica/flexToModica.ts create mode 100644 lambdas/account-scoped/src/customChannels/modica/modicaToFlex.ts create mode 100644 lambdas/account-scoped/src/customChannels/telegram/flexToTelegram.ts create mode 100644 lambdas/account-scoped/src/customChannels/telegram/telegramToFlex.ts create mode 100644 lambdas/account-scoped/tests/unit/customChannels/line/flexToLine.test.ts create mode 100644 lambdas/account-scoped/tests/unit/customChannels/line/lineToFlex.test.ts create mode 100644 lambdas/account-scoped/tests/unit/customChannels/modica/flexToModica.test.ts create mode 100644 lambdas/account-scoped/tests/unit/customChannels/modica/modicaToFlex.test.ts create mode 100644 lambdas/account-scoped/tests/unit/customChannels/telegram/flexToTelegram.test.ts create mode 100644 lambdas/account-scoped/tests/unit/customChannels/telegram/telegramToFlex.test.ts diff --git a/lambdas/account-scoped/src/customChannels/configuration.ts b/lambdas/account-scoped/src/customChannels/configuration.ts index 7cc2732016..1b6c1f31ec 100644 --- a/lambdas/account-scoped/src/customChannels/configuration.ts +++ b/lambdas/account-scoped/src/customChannels/configuration.ts @@ -94,3 +94,27 @@ export const getChannelStudioFlowSid = ( getSsmParameter( `/${process.env.NODE_ENV}/twilio/${accountSid}/${channelName}_studio_flow_sid`, ); + +export const getTelegramBotApiSecretToken = (accountSid: AccountSID): Promise => + getSsmParameter( + `/${process.env.NODE_ENV}/twilio/${accountSid}/telegram_bot_api_secret_token`, + ); + +export const getTelegramFlexBotToken = (accountSid: AccountSID): Promise => + getSsmParameter( + `/${process.env.NODE_ENV}/twilio/${accountSid}/telegram_flex_bot_token`, + ); + +export const getModicaAppName = (accountSid: AccountSID): Promise => + getSsmParameter(`/${process.env.NODE_ENV}/twilio/${accountSid}/modica_app_name`); + +export const getModicaAppPassword = (accountSid: AccountSID): Promise => + getSsmParameter(`/${process.env.NODE_ENV}/twilio/${accountSid}/modica_app_password`); + +export const getLineChannelSecret = (accountSid: AccountSID): Promise => + getSsmParameter(`/${process.env.NODE_ENV}/twilio/${accountSid}/line_channel_secret`); + +export const getLineChannelAccessToken = (accountSid: AccountSID): Promise => + getSsmParameter( + `/${process.env.NODE_ENV}/twilio/${accountSid}/line_channel_access_token`, + ); diff --git a/lambdas/account-scoped/src/customChannels/line/flexToLine.ts b/lambdas/account-scoped/src/customChannels/line/flexToLine.ts new file mode 100644 index 0000000000..24ef9b3b7e --- /dev/null +++ b/lambdas/account-scoped/src/customChannels/line/flexToLine.ts @@ -0,0 +1,129 @@ +/** + * 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 crypto from 'crypto'; +import { + ConversationWebhookEvent, + ExternalSendResult, + redirectConversationMessageToExternalChat, + RedirectResult, + TEST_SEND_URL, +} from '../flexToCustomChannel'; +import { AccountScopedHandler, HttpError, HttpRequest } from '../../httpTypes'; +import { isErr, newOk, Result } from '../../Result'; +import { newMissingParameterResult } from '../../httpErrors'; +import { getTwilioClient } from '@tech-matters/twilio-configuration'; +import { getLineChannelAccessToken } from '../configuration'; +import { AccountSID } from '@tech-matters/twilio-types'; + +const LINE_SEND_MESSAGE_URL = 'https://api.line.me/v2/bot/message/push'; + +export type Body = ConversationWebhookEvent & { + recipientId: string; // The Line id of the user that started the conversation. Provided as query parameter +}; + +const sendLineMessage = + (lineChannelAccessToken: string) => + async ( + recipientId: string, + messageText: string, + testSessionId?: string, + ): Promise => { + const payload = { + to: recipientId, + messages: [ + { + type: 'text', + text: messageText, + }, + ], + }; + + const headers = { + 'Content-Type': 'application/json', + 'X-Line-Retry-Key': crypto.randomUUID(), + Authorization: `Bearer ${lineChannelAccessToken}`, + ...(testSessionId ? { 'x-webhook-receiver-session-id': testSessionId } : {}), + }; + + const sendUrl = testSessionId ? TEST_SEND_URL : LINE_SEND_MESSAGE_URL; + const response = await fetch(sendUrl, { + method: 'post', + body: JSON.stringify(payload), + headers, + }); + + return { + ok: response.ok, + resultCode: response.status, + body: await response.json(), + meta: Object.fromEntries(Object.entries(response.headers)), + }; + }; + +const validateProperties = ( + event: any, + requiredProperties: string[], +): Result => { + for (const prop of requiredProperties) { + if (event[prop] === undefined) { + return newMissingParameterResult(prop); + } + } + return newOk(true); +}; + +export const flexToLineHandler: AccountScopedHandler = async ( + { body: event, query: { recipientId } }: HttpRequest, + accountSid: AccountSID, +) => { + console.info('==== FlexToLine handler ===='); + console.info('Received event:', event); + + if (!recipientId) { + return newMissingParameterResult('recipientId'); + } + + let result: RedirectResult; + const requiredProperties: (keyof ConversationWebhookEvent)[] = [ + 'ConversationSid', + 'Body', + 'Author', + 'EventType', + 'Source', + ]; + const validationResult = validateProperties(event, requiredProperties); + if (isErr(validationResult)) { + return validationResult; + } + + const lineChannelAccessToken = await getLineChannelAccessToken(accountSid); + const client = await getTwilioClient(accountSid); + result = await redirectConversationMessageToExternalChat(client, { + event, + recipientId, + sendExternalMessage: sendLineMessage(lineChannelAccessToken), + }); + + switch (result.status) { + case 'sent': + return newOk(result.response); + case 'ignored': + return newOk({ message: 'Ignored event' }); + default: + throw new Error('Reached unexpected default case'); + } +}; diff --git a/lambdas/account-scoped/src/customChannels/line/lineToFlex.ts b/lambdas/account-scoped/src/customChannels/line/lineToFlex.ts new file mode 100644 index 0000000000..8941684920 --- /dev/null +++ b/lambdas/account-scoped/src/customChannels/line/lineToFlex.ts @@ -0,0 +1,161 @@ +/** + * 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 crypto from 'crypto'; +import { AccountSID } from '@tech-matters/twilio-types'; +import { + AseloCustomChannel, + sendConversationMessageToFlex, +} from '../customChannelToFlex'; +import { AccountScopedHandler, HttpRequest } from '../../httpTypes'; +import { newErr, newOk } from '../../Result'; +import { getChannelStudioFlowSid, getLineChannelSecret } from '../configuration'; + +export const LINE_SIGNATURE_HEADER = 'x-line-signature'; + +type LineMessage = { + type: 'text' | string; + id: string; + text: string; +}; + +type LineSource = { + type: 'user' | 'group' | 'room'; + userId: string; +}; + +type LineEvent = { + type: 'message' | string; + message: LineMessage; + timestamp: number; + replyToken: string; + source: LineSource; +}; + +export type Body = { + destination: string; + events: LineEvent[]; +}; + +// Line seems to have generated signatures using escaped unicode for emoji characters +// https://gist.github.com/jirawatee/366d6bef98b137131ab53dfa079bd0a4 +const fixUnicodeForLine = (text: string): string => + text.replace(/\p{Emoji_Presentation}/gu, emojiChars => + emojiChars + .split('') + .map(c => `\\u${c.charCodeAt(0).toString(16).toUpperCase()}`) + .join(''), + ); + +/** + * Validates that the payload is signed with LINE_CHANNEL_SECRET so we know it's coming from Line + */ +const isValidLinePayload = ( + body: Body, + xLineSignature: string | undefined, + lineChannelSecret: string, +): boolean => { + if (!xLineSignature) return false; + + const payloadAsString = JSON.stringify(body); + + const expectedSignature = crypto + .createHmac('sha256', lineChannelSecret) + .update(fixUnicodeForLine(payloadAsString)) + .digest('base64'); + + try { + return crypto.timingSafeEqual( + Buffer.from(xLineSignature), + Buffer.from(expectedSignature), + ); + } catch (e) { + console.warn('Unknown error validating signature (rejecting with 403):', e); + return false; + } +}; + +export const lineToFlexHandler: AccountScopedHandler = async ( + { body, headers }: HttpRequest, + accountSid: AccountSID, +) => { + console.info('==== LineToFlex handler ===='); + + const lineChannelSecret = await getLineChannelSecret(accountSid); + const xLineSignature = headers[LINE_SIGNATURE_HEADER]; + + if (!isValidLinePayload(body, xLineSignature, lineChannelSecret)) { + return newErr({ + message: 'Forbidden', + error: { statusCode: 403 }, + }); + } + + const event: Body = body; + const { destination, events } = event; + + const messageEvents = events.filter(e => e.type === 'message'); + + if (messageEvents.length === 0) { + return newOk({ message: 'No messages to send' }); + } + + const studioFlowSid = await getChannelStudioFlowSid( + accountSid, + AseloCustomChannel.Line, + ); + const responses: any[] = []; + + for (const messageEvent of messageEvents) { + const messageText = messageEvent.message.text; + const channelType = AseloCustomChannel.Line; + const subscribedExternalId = destination; // AseloChat ID on Line + const senderExternalId = messageEvent.source.userId; // The child ID on Line + const chatFriendlyName = `${channelType}:${senderExternalId}`; + const uniqueUserName = `${channelType}:${senderExternalId}`; + const senderScreenName = 'child'; + const onMessageSentWebhookUrl = `${process.env.WEBHOOK_BASE_URL}/lambda/twilio/account-scoped/${accountSid}/customChannels/line/flexToLine?recipientId=${senderExternalId}`; + + // eslint-disable-next-line no-await-in-loop + const result = await sendConversationMessageToFlex(accountSid, { + studioFlowSid, + channelType, + uniqueUserName, + senderScreenName, + onMessageSentWebhookUrl, + messageText, + senderExternalId, + customSubscribedExternalId: subscribedExternalId, + conversationFriendlyName: chatFriendlyName, + }); + + switch (result.status) { + case 'sent': + responses.push(result.response); + break; + case 'ignored': + responses.push({ message: 'Ignored event.' }); + break; + default: + return newErr({ + message: 'Reached unexpected default case', + error: { statusCode: 500, error: new Error('Reached unexpected default case') }, + }); + } + } + + return newOk(responses); +}; diff --git a/lambdas/account-scoped/src/customChannels/modica/flexToModica.ts b/lambdas/account-scoped/src/customChannels/modica/flexToModica.ts new file mode 100644 index 0000000000..40316aaf0d --- /dev/null +++ b/lambdas/account-scoped/src/customChannels/modica/flexToModica.ts @@ -0,0 +1,137 @@ +/** + * 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 { + ConversationWebhookEvent, + ExternalSendResult, + redirectConversationMessageToExternalChat, + RedirectResult, +} from '../flexToCustomChannel'; +import { AccountScopedHandler, HttpError, HttpRequest } from '../../httpTypes'; +import { isErr, newOk, Result } from '../../Result'; +import { newMissingParameterResult } from '../../httpErrors'; +import { getTwilioClient } from '@tech-matters/twilio-configuration'; +import { getModicaAppName, getModicaAppPassword } from '../configuration'; +import { AccountSID } from '@tech-matters/twilio-types'; + +const DEFAULT_MODICA_SEND_MESSAGE_URL = + 'https://api.modicagroup.com/rest/gateway/messages'; + +export type Body = ConversationWebhookEvent & { + recipientId: string; // The phone number of the user that started the conversation. Provided as query parameter +}; + +/** + * Adds a '+' symbol at the beginning of the recipientId if it's missing. + * Modica expects the destination to be in E.164 format. + * The recipientId may have a space or missing '+' due to URL encoding of query parameters. + */ +const sanitizeRecipientId = (recipientIdRaw: string) => { + const recipientId = recipientIdRaw.trim(); + return recipientId.charAt(0) !== '+' ? `+${recipientId}` : recipientId; +}; + +const sendModicaMessage = + (appName: string, appPassword: string) => + async (recipientId: string, messageText: string): Promise => { + const payload = { + destination: sanitizeRecipientId(recipientId), + content: messageText, + }; + + const base64Credentials = Buffer.from(`${appName}:${appPassword}`).toString('base64'); + const response = await fetch(DEFAULT_MODICA_SEND_MESSAGE_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Basic ${base64Credentials}`, + }, + body: JSON.stringify(payload), + }); + + const bodyText = await response.text(); + let responseBody; + try { + responseBody = JSON.parse(bodyText); + } catch (e) { + responseBody = bodyText; + } + + return { + ok: response.ok, + resultCode: response.status, + body: responseBody, + meta: Object.fromEntries(Object.entries(response.headers)), + }; + }; + +const validateProperties = ( + event: any, + requiredProperties: string[], +): Result => { + for (const prop of requiredProperties) { + if (event[prop] === undefined) { + return newMissingParameterResult(prop); + } + } + return newOk(true); +}; + +export const flexToModicaHandler: AccountScopedHandler = async ( + { body: event, query: { recipientId } }: HttpRequest, + accountSid: AccountSID, +) => { + console.info('==== FlexToModica handler ===='); + console.info('Received event:', event); + + if (!recipientId) { + return newMissingParameterResult('recipientId'); + } + + let result: RedirectResult; + const requiredProperties: (keyof ConversationWebhookEvent)[] = [ + 'ConversationSid', + 'Body', + 'Author', + 'EventType', + 'Source', + ]; + const validationResult = validateProperties(event, requiredProperties); + if (isErr(validationResult)) { + return validationResult; + } + + const [appName, appPassword] = await Promise.all([ + getModicaAppName(accountSid), + getModicaAppPassword(accountSid), + ]); + + const client = await getTwilioClient(accountSid); + result = await redirectConversationMessageToExternalChat(client, { + event, + recipientId, + sendExternalMessage: sendModicaMessage(appName, appPassword), + }); + + switch (result.status) { + case 'sent': + return newOk(result.response); + case 'ignored': + return newOk({ message: 'Ignored event' }); + default: + throw new Error('Reached unexpected default case'); + } +}; diff --git a/lambdas/account-scoped/src/customChannels/modica/modicaToFlex.ts b/lambdas/account-scoped/src/customChannels/modica/modicaToFlex.ts new file mode 100644 index 0000000000..030d074996 --- /dev/null +++ b/lambdas/account-scoped/src/customChannels/modica/modicaToFlex.ts @@ -0,0 +1,78 @@ +/** + * 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 { AccountSID } from '@tech-matters/twilio-types'; +import { + AseloCustomChannel, + sendConversationMessageToFlex, +} from '../customChannelToFlex'; +import { AccountScopedHandler, HttpRequest } from '../../httpTypes'; +import { newErr, newOk } from '../../Result'; +import { getChannelStudioFlowSid } from '../configuration'; + +export type Body = { + source: string; // The child's phone number + destination: string; // The helpline short code + content: string; // The message text +}; + +export const modicaToFlexHandler: AccountScopedHandler = async ( + { body }: HttpRequest, + accountSid: AccountSID, +) => { + console.info('==== ModicaToFlex handler ===='); + console.info('Received event:', body); + + const event: Body = body; + const { source, destination, content } = event; + + const messageText = content; + const channelType = AseloCustomChannel.Modica; + const subscribedExternalId = destination; // The helpline short code + const senderExternalId = source; // The child phone number + const chatFriendlyName = senderExternalId; + const uniqueUserName = `${channelType}:${senderExternalId}`; + const senderScreenName = 'child'; + const onMessageSentWebhookUrl = `${process.env.WEBHOOK_BASE_URL}/lambda/twilio/account-scoped/${accountSid}/customChannels/modica/flexToModica?recipientId=${senderExternalId}`; + const studioFlowSid = await getChannelStudioFlowSid( + accountSid, + AseloCustomChannel.Modica, + ); + + const result = await sendConversationMessageToFlex(accountSid, { + studioFlowSid, + channelType, + uniqueUserName, + senderScreenName, + onMessageSentWebhookUrl, + messageText, + senderExternalId, + customSubscribedExternalId: subscribedExternalId, + conversationFriendlyName: chatFriendlyName, + }); + + switch (result.status) { + case 'sent': + return newOk(result.response); + case 'ignored': + return newOk({ message: `Ignored event.` }); + default: + return newErr({ + message: 'Reached unexpected default case', + error: { statusCode: 500, error: new Error('Reached unexpected default case') }, + }); + } +}; diff --git a/lambdas/account-scoped/src/customChannels/telegram/flexToTelegram.ts b/lambdas/account-scoped/src/customChannels/telegram/flexToTelegram.ts new file mode 100644 index 0000000000..d5c7b2883c --- /dev/null +++ b/lambdas/account-scoped/src/customChannels/telegram/flexToTelegram.ts @@ -0,0 +1,116 @@ +/** + * 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 { + ConversationWebhookEvent, + ExternalSendResult, + redirectConversationMessageToExternalChat, + RedirectResult, + TEST_SEND_URL, +} from '../flexToCustomChannel'; +import { AccountScopedHandler, HttpError, HttpRequest } from '../../httpTypes'; +import { isErr, newOk, Result } from '../../Result'; +import { newMissingParameterResult } from '../../httpErrors'; +import { getTwilioClient } from '@tech-matters/twilio-configuration'; +import { getTelegramFlexBotToken } from '../configuration'; +import { AccountSID } from '@tech-matters/twilio-types'; + +export type Body = ConversationWebhookEvent & { + recipientId: string; // The Telegram id of the user that started the conversation. Provided as query parameter +}; + +const sendTelegramMessage = + (telegramFlexBotToken: string) => + async ( + recipientId: string, + messageText: string, + testSessionId?: string, + ): Promise => { + const telegramSendMessageUrl = `${testSessionId ? TEST_SEND_URL : `https://api.telegram.org/bot${telegramFlexBotToken}/sendMessage`}`; + + const payload = { + chat_id: recipientId, + text: messageText, + }; + const response = await fetch(telegramSendMessageUrl, { + method: 'post', + body: JSON.stringify(payload), + headers: { + 'Content-Type': 'application/json', + ...(testSessionId ? { 'x-webhook-receiver-session-id': testSessionId } : {}), + }, + }); + return { + ok: response.ok, + resultCode: response.status, + body: await response.json(), + meta: Object.fromEntries(Object.entries(response.headers)), + }; + }; + +const validateProperties = ( + event: any, + requiredProperties: string[], +): Result => { + for (const prop of requiredProperties) { + if (event[prop] === undefined) { + return newMissingParameterResult(prop); + } + } + return newOk(true); +}; + +export const flexToTelegramHandler: AccountScopedHandler = async ( + { body: event, query: { recipientId } }: HttpRequest, + accountSid: AccountSID, +) => { + console.info('==== FlexToTelegram handler ===='); + console.info('Received event:', event); + + if (!recipientId) { + return newMissingParameterResult('recipientId'); + } + + let result: RedirectResult; + const requiredProperties: (keyof ConversationWebhookEvent)[] = [ + 'ConversationSid', + 'Body', + 'Author', + 'EventType', + 'Source', + ]; + const validationResult = validateProperties(event, requiredProperties); + if (isErr(validationResult)) { + return validationResult; + } + + const telegramFlexBotToken = await getTelegramFlexBotToken(accountSid); + const client = await getTwilioClient(accountSid); + result = await redirectConversationMessageToExternalChat(client, { + event, + recipientId, + sendExternalMessage: sendTelegramMessage(telegramFlexBotToken), + }); + + switch (result.status) { + case 'sent': + return newOk(result.response); + case 'ignored': + return newOk({ message: 'Ignored event' }); + default: + throw new Error('Reached unexpected default case'); + } +}; diff --git a/lambdas/account-scoped/src/customChannels/telegram/telegramToFlex.ts b/lambdas/account-scoped/src/customChannels/telegram/telegramToFlex.ts new file mode 100644 index 0000000000..557c988f30 --- /dev/null +++ b/lambdas/account-scoped/src/customChannels/telegram/telegramToFlex.ts @@ -0,0 +1,94 @@ +/** + * 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 { AccountSID } from '@tech-matters/twilio-types'; +import { + AseloCustomChannel, + sendConversationMessageToFlex, +} from '../customChannelToFlex'; +import { AccountScopedHandler, HttpRequest } from '../../httpTypes'; +import { newErr, newOk } from '../../Result'; +import { getChannelStudioFlowSid, getTelegramBotApiSecretToken } from '../configuration'; + +export const TELEGRAM_BOT_API_SECRET_TOKEN_HEADER = 'x-telegram-bot-api-secret-token'; + +export type Body = { + message: { + chat: { id: string; first_name?: string; username?: string }; + text: string; + }; +}; + +const isValidTelegramPayload = ( + headers: Record, + botApiSecretToken: string, +): boolean => + Boolean(headers[TELEGRAM_BOT_API_SECRET_TOKEN_HEADER] === botApiSecretToken); + +export const telegramToFlexHandler: AccountScopedHandler = async ( + { body, headers }: HttpRequest, + accountSid: AccountSID, +) => { + console.info('==== TelegramToFlex handler ===='); + + const botApiSecretToken = await getTelegramBotApiSecretToken(accountSid); + + if (!isValidTelegramPayload(headers, botApiSecretToken)) { + return newErr({ + message: 'Forbidden', + error: { statusCode: 403 }, + }); + } + + const event: Body = body; + const { + text: messageText, + chat: { id: senderExternalId, username, first_name: firstName }, + } = event.message; + + const channelType = AseloCustomChannel.Telegram; + const chatFriendlyName = username || `${channelType}:${senderExternalId}`; + const uniqueUserName = `${channelType}:${senderExternalId}`; + const senderScreenName = firstName || username || 'child'; + const onMessageSentWebhookUrl = `${process.env.WEBHOOK_BASE_URL}/lambda/twilio/account-scoped/${accountSid}/customChannels/telegram/flexToTelegram?recipientId=${senderExternalId}`; + const studioFlowSid = await getChannelStudioFlowSid( + accountSid, + AseloCustomChannel.Telegram, + ); + + const result = await sendConversationMessageToFlex(accountSid, { + studioFlowSid, + channelType, + uniqueUserName, + senderScreenName, + onMessageSentWebhookUrl, + messageText, + senderExternalId, + conversationFriendlyName: chatFriendlyName, + }); + + switch (result.status) { + case 'sent': + return newOk(result.response); + case 'ignored': + return newOk({ message: `Ignored event.` }); + default: + return newErr({ + message: 'Reached unexpected default case', + error: { statusCode: 500, error: new Error('Reached unexpected default case') }, + }); + } +}; diff --git a/lambdas/account-scoped/src/router.ts b/lambdas/account-scoped/src/router.ts index 9a1ced93eb..09a4e3a58e 100644 --- a/lambdas/account-scoped/src/router.ts +++ b/lambdas/account-scoped/src/router.ts @@ -44,6 +44,12 @@ import { conferenceStatusCallbackHandler } from './conference/conferenceStatusCa import './conference/stopRecordingWhenLastAgentLeaves'; import { instagramToFlexHandler } from './customChannels/instagram/instagramToFlex'; import { flexToInstagramHandler } from './customChannels/instagram/flexToInstagram'; +import { telegramToFlexHandler } from './customChannels/telegram/telegramToFlex'; +import { flexToTelegramHandler } from './customChannels/telegram/flexToTelegram'; +import { modicaToFlexHandler } from './customChannels/modica/modicaToFlex'; +import { flexToModicaHandler } from './customChannels/modica/flexToModica'; +import { lineToFlexHandler } from './customChannels/line/lineToFlex'; +import { flexToLineHandler } from './customChannels/line/flexToLine'; import { handleConversationEvent } from './conversation'; import { getTaskAndReservationsHandler } from './task/getTaskAndReservations'; import { checkTaskAssignmentHandler } from './task/checkTaskAssignment'; @@ -134,6 +140,30 @@ const ACCOUNTSID_ROUTES: Record< requestPipeline: [validateWebhookRequest], handler: flexToInstagramHandler, }, + 'customChannels/telegram/telegramToFlex': { + requestPipeline: [], + handler: telegramToFlexHandler, + }, + 'customChannels/telegram/flexToTelegram': { + requestPipeline: [validateWebhookRequest], + handler: flexToTelegramHandler, + }, + 'customChannels/modica/modicaToFlex': { + requestPipeline: [], + handler: modicaToFlexHandler, + }, + 'customChannels/modica/flexToModica': { + requestPipeline: [validateWebhookRequest], + handler: flexToModicaHandler, + }, + 'customChannels/line/lineToFlex': { + requestPipeline: [], + handler: lineToFlexHandler, + }, + 'customChannels/line/flexToLine': { + requestPipeline: [validateWebhookRequest], + handler: flexToLineHandler, + }, 'webchatAuth/initWebchat': { requestPipeline: [], handler: initWebchatHandler, diff --git a/lambdas/account-scoped/tests/unit/customChannels/line/flexToLine.test.ts b/lambdas/account-scoped/tests/unit/customChannels/line/flexToLine.test.ts new file mode 100644 index 0000000000..6d3f925882 --- /dev/null +++ b/lambdas/account-scoped/tests/unit/customChannels/line/flexToLine.test.ts @@ -0,0 +1,234 @@ +/** + * 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 each from 'jest-each'; +import { flexToLineHandler } from '../../../../src/customChannels/line/flexToLine'; +import { AccountSID } from '@tech-matters/twilio-types'; +import { getTwilioClient } from '@tech-matters/twilio-configuration'; +import { HttpRequest } from '../../../../src/httpTypes'; +import { isErr, isOk } from '../../../../src/Result'; +import { AssertionError } from 'node:assert'; + +global.fetch = jest.fn(); +const mockFetch = fetch as jest.MockedFunction; + +jest.mock('@tech-matters/twilio-configuration', () => ({ + getTwilioClient: jest.fn(), +})); + +jest.mock('../../../../src/customChannels/configuration', () => ({ + getLineChannelAccessToken: jest.fn().mockResolvedValue('test-channel-access-token'), +})); + +const conversations: { [x: string]: any } = { + CONVERSATION_SID: { + sid: 'CONVERSATION_SID', + attributes: JSON.stringify({ from: 'channel-from' }), + participants: { + list: () => Promise.resolve([{ dateCreated: 0, sid: 'first convo participant' }]), + }, + }, +}; + +const mockGetTwilioClient = getTwilioClient as jest.MockedFunction< + typeof getTwilioClient +>; +let mockTwilioClient: any; + +const ACCOUNT_SID: AccountSID = 'ACxx'; +const LINE_SEND_MESSAGE_URL = 'https://api.line.me/v2/bot/message/push'; +const LINE_CHANNEL_ACCESS_TOKEN = 'test-channel-access-token'; + +const validEvent = ({ Author = 'senderId', Source = 'API' } = {}) => ({ + Source, + Body: 'the message text', + EventType: 'onMessageAdded', + ConversationSid: 'CONVERSATION_SID', + Author, +}); + +describe('FlexToLine', () => { + beforeEach(() => { + mockTwilioClient = { + conversations: { + v1: { + conversations: { + get: (channelSid: string) => { + if (!conversations[channelSid]) + throw new Error('Conversation does not exist.'); + return { + fetch: async () => conversations[channelSid], + ...conversations[channelSid], + }; + }, + }, + }, + }, + }; + mockGetTwilioClient.mockResolvedValue(mockTwilioClient); + }); + + each([ + { + conditionDescription: 'the recipientId parameter is not provided', + event: validEvent(), + expectedStatus: 400, + expectedMessage: 'recipientId missing', + recipientId: null, + }, + { + conditionDescription: 'the Body parameter is not provided', + event: { ...validEvent(), Body: undefined }, + expectedStatus: 400, + expectedMessage: 'Body missing', + }, + { + conditionDescription: 'the ConversationSid parameter is not provided', + event: { ...validEvent(), ConversationSid: undefined }, + expectedStatus: 400, + expectedMessage: 'ConversationSid missing', + }, + { + conditionDescription: 'the Line endpoint throws an error', + event: validEvent(), + endpointImpl: () => { + throw new Error('BOOM'); + }, + expectedStatus: 500, + expectedMessage: 'BOOM', + }, + ]).test( + "Should return $expectedStatus '$expectedMessage' error when $conditionDescription.", + async ({ + event, + endpointImpl = async () => ({ status: 200, data: 'OK' }), + expectedStatus, + expectedMessage, + recipientId = 'recipientId', + }) => { + mockFetch.mockImplementation(endpointImpl); + const request = { + body: event, + query: { ...(recipientId ? { recipientId } : {}) }, + } as HttpRequest; + if (expectedStatus === 500) { + await expect(flexToLineHandler(request, ACCOUNT_SID)).rejects.toThrow( + new Error('BOOM'), + ); + return; + } + + const result = await flexToLineHandler(request, ACCOUNT_SID); + + if (isErr(result)) { + expect({ + status: result.error.statusCode, + message: result.message, + }).toMatchObject({ + status: expectedStatus, + message: expect.stringContaining(expectedMessage), + }); + } else { + expect({ + status: 200, + message: result.data.message, + }).toMatchObject({ + status: expectedStatus, + message: expect.stringContaining(expectedMessage), + }); + } + }, + ); + + each([ + { + conditionDescription: 'the event source is not supported', + event: validEvent({ Source: 'not supported' }), + shouldBeIgnored: true, + }, + { + conditionDescription: + "event 'Author' property matches first conversation participant sid", + event: validEvent({ Author: 'first convo participant' }), + shouldBeIgnored: true, + }, + { + conditionDescription: "event 'Source' property is set to 'API'", + event: validEvent(), + shouldBeIgnored: false, + }, + { + conditionDescription: "event 'Source' property is set to 'SDK'", + event: validEvent({ Source: 'SDK' }), + shouldBeIgnored: false, + }, + ]).test( + 'Should return status 200 success (ignored: $shouldBeIgnored) when $conditionDescription.', + async ({ event, shouldBeIgnored }) => { + mockFetch.mockClear(); + mockFetch.mockResolvedValue({ + status: 200, + ok: true, + json: async () => Promise.resolve({}), + headers: { some: 'header' } as any, + } as Response); + + const result = await flexToLineHandler( + { + body: event, + query: { recipientId: 'Uabcdef1234567890' } as Record, + } as HttpRequest, + ACCOUNT_SID, + ); + + if (shouldBeIgnored) { + expect(mockFetch).not.toHaveBeenCalled(); + } else { + expect(mockFetch).toBeCalledWith( + LINE_SEND_MESSAGE_URL, + expect.objectContaining({ + method: 'post', + body: JSON.stringify({ + to: 'Uabcdef1234567890', + messages: [{ type: 'text', text: event.Body }], + }), + headers: expect.objectContaining({ + 'Content-Type': 'application/json', + Authorization: `Bearer ${LINE_CHANNEL_ACCESS_TOKEN}`, + 'X-Line-Retry-Key': expect.any(String), + }), + }), + ); + } + + if (isOk(result)) { + if (shouldBeIgnored) { + expect(result.data.message).toContain('Ignored event'); + } else { + expect(result.data).toMatchObject({ + ok: true, + resultCode: 200, + }); + } + } else + throw new AssertionError({ + message: `Expected flexToLineHandler to return an OK result, but returned this error: ${JSON.stringify(result)}`, + expected: 'OK', + actual: result, + }); + }, + ); +}); diff --git a/lambdas/account-scoped/tests/unit/customChannels/line/lineToFlex.test.ts b/lambdas/account-scoped/tests/unit/customChannels/line/lineToFlex.test.ts new file mode 100644 index 0000000000..43e8dd9f6f --- /dev/null +++ b/lambdas/account-scoped/tests/unit/customChannels/line/lineToFlex.test.ts @@ -0,0 +1,230 @@ +/** + * 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 each from 'jest-each'; +import * as crypto from 'crypto'; +import { + lineToFlexHandler, + Body, + LINE_SIGNATURE_HEADER, +} from '../../../../src/customChannels/line/lineToFlex'; +import { HttpRequest } from '../../../../src/httpTypes'; +import { AccountSID } from '@tech-matters/twilio-types'; +import { isOk } from '../../../../src/Result'; +import { getTwilioClient } from '@tech-matters/twilio-configuration'; + +const MOCK_LINE_CHANNEL_SECRET = 'test-line-channel-secret'; + +jest.mock('../../../../src/customChannels/configuration', () => ({ + getLineChannelSecret: jest.fn().mockResolvedValue('test-line-channel-secret'), + getChannelStudioFlowSid: jest.fn().mockResolvedValue('LINE_STUDIO_FLOW_SID'), +})); + +jest.mock('@tech-matters/twilio-configuration', () => ({ + getTwilioClient: jest.fn(), +})); + +const MOCK_CHANNEL_TYPE = 'line'; +const MOCK_USER_ID = 'Uabcdef1234567890'; +const MOCK_DESTINATION = 'Udeadbeef1234567890'; +const MOCK_SENDER_CHANNEL_SID = `${MOCK_CHANNEL_TYPE}:${MOCK_USER_ID}`; + +const newConversation = (sid: string) => ({ + attributes: '{}', + sid, + messages: { + create: jest.fn().mockResolvedValue(`Message sent in channel ${sid}.`), + list: async () => [], + }, + webhooks: { + create: async () => {}, + }, + update: async () => {}, +}); + +const conversations: { [x: string]: any } = { + [MOCK_SENDER_CHANNEL_SID]: newConversation(MOCK_SENDER_CHANNEL_SID), +}; + +const mockGetTwilioClient = getTwilioClient as jest.MockedFunction< + typeof getTwilioClient +>; +let mockTwilioClient: any; + +const ACCOUNT_SID: AccountSID = 'ACxx'; + +const validBody = ({ + userId = MOCK_USER_ID, + destination = MOCK_DESTINATION, + text = 'Hello from Line!', +}: { + userId?: string; + destination?: string; + text?: string; +} = {}): Body => ({ + destination, + events: [ + { + type: 'message', + message: { type: 'text', id: 'msg001', text }, + timestamp: 1234567890, + replyToken: 'reply-token-001', + source: { type: 'user', userId }, + }, + ], +}); + +const computeLineSignature = (body: Body): string => { + const payloadAsString = JSON.stringify(body); + return crypto + .createHmac('sha256', MOCK_LINE_CHANNEL_SECRET) + .update(payloadAsString) + .digest('base64'); +}; + +const validHeaders = (body: Body) => ({ + [LINE_SIGNATURE_HEADER]: computeLineSignature(body), +}); + +describe('LineToFlex', () => { + beforeEach(() => { + mockTwilioClient = { + conversations: { + v1: { + conversations: { + get: (conversationSid: string) => { + if (!conversations[conversationSid]) + throw new Error('Channel does not exist.'); + return { + fetch: async () => conversations[conversationSid], + ...conversations[conversationSid], + }; + }, + }, + participantConversations: { + list: () => [{ conversationState: 'active' }], + }, + }, + }, + }; + mockGetTwilioClient.mockResolvedValue(mockTwilioClient); + }); + + each([ + { + conditionDescription: 'the x-line-signature header is missing', + body: validBody(), + headers: {}, + expectedStatus: 403, + expectedMessage: 'Forbidden', + }, + { + conditionDescription: 'the x-line-signature header is incorrect', + body: validBody(), + headers: { [LINE_SIGNATURE_HEADER]: 'invalid-signature' }, + expectedStatus: 403, + expectedMessage: 'Forbidden', + }, + ]).test( + "Should return $expectedStatus '$expectedMessage' when $conditionDescription", + async ({ body, headers, expectedStatus, expectedMessage }) => { + const result = await lineToFlexHandler( + { body, headers } as unknown as HttpRequest, + ACCOUNT_SID, + ); + + expect(result).toBeDefined(); + if (!isOk(result)) { + expect(result.error.statusCode).toBe(expectedStatus); + expect(result.message).toContain(expectedMessage); + } else { + throw new Error(`Expected error result but got OK`); + } + }, + ); + + test('Should return success when there are no message events', async () => { + const body: Body = { destination: MOCK_DESTINATION, events: [] }; + const headers = { [LINE_SIGNATURE_HEADER]: computeLineSignature(body) }; + + const result = await lineToFlexHandler( + { body, headers } as unknown as HttpRequest, + ACCOUNT_SID, + ); + + expect(result).toBeDefined(); + expect(isOk(result)).toBe(true); + if (isOk(result)) { + expect(result.data).toMatchObject({ message: 'No messages to send' }); + } + }); + + test('Should return success when there are only non-message events', async () => { + const body: Body = { + destination: MOCK_DESTINATION, + events: [ + { + type: 'follow', + message: { type: 'text', id: 'msg001', text: '' }, + timestamp: 1234567890, + replyToken: 'reply-token-001', + source: { type: 'user', userId: MOCK_USER_ID }, + }, + ], + }; + const headers = { [LINE_SIGNATURE_HEADER]: computeLineSignature(body) }; + + const result = await lineToFlexHandler( + { body, headers } as unknown as HttpRequest, + ACCOUNT_SID, + ); + + expect(result).toBeDefined(); + expect(isOk(result)).toBe(true); + if (isOk(result)) { + expect(result.data).toMatchObject({ message: 'No messages to send' }); + } + }); + + test('Should process a valid Line message and send it to Flex', async () => { + mockTwilioClient.conversations.v1.participantConversations.list = jest + .fn() + .mockResolvedValue([]); + mockTwilioClient.conversations.v1.conversations.create = jest + .fn() + .mockResolvedValue({ sid: MOCK_SENDER_CHANNEL_SID }); + mockTwilioClient.conversations.v1.conversations.get = jest.fn().mockReturnValue({ + fetch: jest.fn().mockResolvedValue({ attributes: '{}' }), + messages: { + create: jest.fn().mockResolvedValue({ sid: 'msg_001' }), + }, + participants: { create: jest.fn().mockResolvedValue({}) }, + webhooks: { create: jest.fn().mockResolvedValue({}) }, + update: jest.fn().mockResolvedValue({}), + }); + + const body = validBody(); + const headers = validHeaders(body); + + const result = await lineToFlexHandler( + { body, headers } as unknown as HttpRequest, + ACCOUNT_SID, + ); + + expect(result).toBeDefined(); + expect(isOk(result)).toBe(true); + }); +}); diff --git a/lambdas/account-scoped/tests/unit/customChannels/modica/flexToModica.test.ts b/lambdas/account-scoped/tests/unit/customChannels/modica/flexToModica.test.ts new file mode 100644 index 0000000000..b73507195b --- /dev/null +++ b/lambdas/account-scoped/tests/unit/customChannels/modica/flexToModica.test.ts @@ -0,0 +1,267 @@ +/** + * 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 each from 'jest-each'; +import { flexToModicaHandler } from '../../../../src/customChannels/modica/flexToModica'; +import { AccountSID } from '@tech-matters/twilio-types'; +import { getTwilioClient } from '@tech-matters/twilio-configuration'; +import { HttpRequest } from '../../../../src/httpTypes'; +import { isErr, isOk } from '../../../../src/Result'; +import { AssertionError } from 'node:assert'; + +global.fetch = jest.fn(); +const mockFetch = fetch as jest.MockedFunction; + +jest.mock('@tech-matters/twilio-configuration', () => ({ + getTwilioClient: jest.fn(), +})); + +jest.mock('../../../../src/customChannels/configuration', () => ({ + getModicaAppName: jest.fn().mockResolvedValue('test-app-name'), + getModicaAppPassword: jest.fn().mockResolvedValue('test-app-password'), +})); + +const conversations: { [x: string]: any } = { + CONVERSATION_SID: { + sid: 'CONVERSATION_SID', + attributes: JSON.stringify({ from: 'channel-from' }), + participants: { + list: () => Promise.resolve([{ dateCreated: 0, sid: 'first convo participant' }]), + }, + }, +}; + +const mockGetTwilioClient = getTwilioClient as jest.MockedFunction< + typeof getTwilioClient +>; +let mockTwilioClient: any; + +const ACCOUNT_SID: AccountSID = 'ACxx'; +const MODICA_APP_NAME = 'test-app-name'; +const MODICA_APP_PASSWORD = 'test-app-password'; +const MODICA_SEND_URL = 'https://api.modicagroup.com/rest/gateway/messages'; + +const validEvent = ({ Author = 'senderId', Source = 'API' } = {}) => ({ + Source, + Body: 'the message text', + EventType: 'onMessageAdded', + ConversationSid: 'CONVERSATION_SID', + Author, +}); + +describe('FlexToModica', () => { + beforeEach(() => { + mockTwilioClient = { + conversations: { + v1: { + conversations: { + get: (channelSid: string) => { + if (!conversations[channelSid]) + throw new Error('Conversation does not exist.'); + return { + fetch: async () => conversations[channelSid], + ...conversations[channelSid], + }; + }, + }, + }, + }, + }; + mockGetTwilioClient.mockResolvedValue(mockTwilioClient); + }); + + each([ + { + conditionDescription: 'the recipientId parameter is not provided', + event: validEvent(), + expectedStatus: 400, + expectedMessage: 'recipientId missing', + recipientId: null, + }, + { + conditionDescription: 'the Body parameter is not provided', + event: { ...validEvent(), Body: undefined }, + expectedStatus: 400, + expectedMessage: 'Body missing', + }, + { + conditionDescription: 'the ConversationSid parameter is not provided', + event: { ...validEvent(), ConversationSid: undefined }, + expectedStatus: 400, + expectedMessage: 'ConversationSid missing', + }, + { + conditionDescription: 'the Modica endpoint throws an error', + event: validEvent(), + endpointImpl: () => { + throw new Error('BOOM'); + }, + expectedStatus: 500, + expectedMessage: 'BOOM', + }, + ]).test( + "Should return $expectedStatus '$expectedMessage' error when $conditionDescription.", + async ({ + event, + endpointImpl = async () => ({ status: 200, data: 'OK' }), + expectedStatus, + expectedMessage, + recipientId = 'recipientId', + }) => { + mockFetch.mockImplementation(endpointImpl); + const request = { + body: event, + query: { ...(recipientId ? { recipientId } : {}) }, + } as HttpRequest; + if (expectedStatus === 500) { + await expect(flexToModicaHandler(request, ACCOUNT_SID)).rejects.toThrow( + new Error('BOOM'), + ); + return; + } + + const result = await flexToModicaHandler(request, ACCOUNT_SID); + + if (isErr(result)) { + expect({ + status: result.error.statusCode, + message: result.message, + }).toMatchObject({ + status: expectedStatus, + message: expect.stringContaining(expectedMessage), + }); + } else { + expect({ + status: 200, + message: result.data.message, + }).toMatchObject({ + status: expectedStatus, + message: expect.stringContaining(expectedMessage), + }); + } + }, + ); + + each([ + { + conditionDescription: 'the event source is not supported', + event: validEvent({ Source: 'not supported' }), + shouldBeIgnored: true, + }, + { + conditionDescription: + "event 'Author' property matches first conversation participant sid", + event: validEvent({ Author: 'first convo participant' }), + shouldBeIgnored: true, + }, + { + conditionDescription: "event 'Source' property is set to 'API'", + event: validEvent(), + shouldBeIgnored: false, + }, + { + conditionDescription: "event 'Source' property is set to 'SDK'", + event: validEvent({ Source: 'SDK' }), + shouldBeIgnored: false, + }, + ]).test( + 'Should return status 200 success (ignored: $shouldBeIgnored) when $conditionDescription.', + async ({ event, shouldBeIgnored }) => { + mockFetch.mockClear(); + mockFetch.mockResolvedValue({ + status: 200, + ok: true, + text: async () => Promise.resolve(JSON.stringify({ success: true })), + headers: { some: 'header' } as any, + } as any); + + const result = await flexToModicaHandler( + { + body: event, + query: { recipientId: '+64211234567' } as Record, + } as HttpRequest, + ACCOUNT_SID, + ); + + const expectedBase64Credentials = Buffer.from( + `${MODICA_APP_NAME}:${MODICA_APP_PASSWORD}`, + ).toString('base64'); + + if (shouldBeIgnored) { + expect(mockFetch).not.toHaveBeenCalled(); + } else { + expect(mockFetch).toBeCalledWith( + MODICA_SEND_URL, + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ + destination: '+64211234567', + content: event.Body, + }), + headers: { + 'Content-Type': 'application/json', + Authorization: `Basic ${expectedBase64Credentials}`, + }, + }), + ); + } + + if (isOk(result)) { + if (shouldBeIgnored) { + expect(result.data.message).toContain('Ignored event'); + } else { + expect(result.data).toMatchObject({ + ok: true, + resultCode: 200, + }); + } + } else + throw new AssertionError({ + message: `Expected flexToModicaHandler to return an OK result, but returned this error: ${JSON.stringify(result)}`, + expected: 'OK', + actual: result, + }); + }, + ); + + test('Should sanitize recipientId by adding + prefix when missing', async () => { + mockFetch.mockClear(); + mockFetch.mockResolvedValue({ + status: 200, + ok: true, + text: async () => Promise.resolve(JSON.stringify({ success: true })), + headers: {} as any, + } as any); + + await flexToModicaHandler( + { + body: validEvent(), + query: { recipientId: '64211234567' } as Record, + } as HttpRequest, + ACCOUNT_SID, + ); + + expect(mockFetch).toBeCalledWith( + MODICA_SEND_URL, + expect.objectContaining({ + body: JSON.stringify({ + destination: '+64211234567', + content: validEvent().Body, + }), + }), + ); + }); +}); diff --git a/lambdas/account-scoped/tests/unit/customChannels/modica/modicaToFlex.test.ts b/lambdas/account-scoped/tests/unit/customChannels/modica/modicaToFlex.test.ts new file mode 100644 index 0000000000..d7bb3c8c5e --- /dev/null +++ b/lambdas/account-scoped/tests/unit/customChannels/modica/modicaToFlex.test.ts @@ -0,0 +1,146 @@ +/** + * 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 each from 'jest-each'; +import { + modicaToFlexHandler, + Body, +} from '../../../../src/customChannels/modica/modicaToFlex'; +import { HttpRequest } from '../../../../src/httpTypes'; +import { AccountSID } from '@tech-matters/twilio-types'; +import { isOk } from '../../../../src/Result'; +import { getTwilioClient } from '@tech-matters/twilio-configuration'; + +jest.mock('../../../../src/customChannels/configuration', () => ({ + getChannelStudioFlowSid: jest.fn().mockResolvedValue('MODICA_STUDIO_FLOW_SID'), +})); + +jest.mock('@tech-matters/twilio-configuration', () => ({ + getTwilioClient: jest.fn(), +})); + +const MOCK_CHANNEL_TYPE = 'modica'; +const MOCK_SENDER_ID = '+64211234567'; +const MOCK_DESTINATION = 'helpline-short-code'; +const MOCK_SENDER_CHANNEL_SID = `${MOCK_CHANNEL_TYPE}:${MOCK_SENDER_ID}`; + +const newConversation = (sid: string) => ({ + attributes: '{}', + sid, + messages: { + create: jest.fn().mockResolvedValue(`Message sent in channel ${sid}.`), + list: async () => [], + }, + webhooks: { + create: async () => {}, + }, + update: async () => {}, +}); + +const conversations: { [x: string]: any } = { + [MOCK_SENDER_CHANNEL_SID]: newConversation(MOCK_SENDER_CHANNEL_SID), +}; + +const mockGetTwilioClient = getTwilioClient as jest.MockedFunction< + typeof getTwilioClient +>; +let mockTwilioClient: any; + +const ACCOUNT_SID: AccountSID = 'ACxx'; + +const validBody = ({ + source = MOCK_SENDER_ID, + destination = MOCK_DESTINATION, + content = 'Hello from Modica!', +}: Partial = {}): Body => ({ + source, + destination, + content, +}); + +describe('ModicaToFlex', () => { + beforeEach(() => { + mockTwilioClient = { + conversations: { + v1: { + conversations: { + get: (conversationSid: string) => { + if (!conversations[conversationSid]) + throw new Error('Channel does not exist.'); + return { + fetch: async () => conversations[conversationSid], + ...conversations[conversationSid], + }; + }, + }, + participantConversations: { + list: () => [{ conversationState: 'active' }], + }, + }, + }, + }; + mockGetTwilioClient.mockResolvedValue(mockTwilioClient); + }); + + test('Should process a valid Modica message and send it to Flex', async () => { + mockTwilioClient.conversations.v1.participantConversations.list = jest + .fn() + .mockResolvedValue([]); + mockTwilioClient.conversations.v1.conversations.create = jest + .fn() + .mockResolvedValue({ sid: MOCK_SENDER_CHANNEL_SID }); + mockTwilioClient.conversations.v1.conversations.get = jest.fn().mockReturnValue({ + fetch: jest.fn().mockResolvedValue({ attributes: '{}' }), + messages: { + create: jest.fn().mockResolvedValue({ sid: 'msg_001' }), + }, + participants: { create: jest.fn().mockResolvedValue({}) }, + webhooks: { create: jest.fn().mockResolvedValue({}) }, + update: jest.fn().mockResolvedValue({}), + }); + + const result = await modicaToFlexHandler( + { body: validBody() } as HttpRequest, + ACCOUNT_SID, + ); + + expect(result).toBeDefined(); + expect(isOk(result)).toBe(true); + }); + + each([ + { + conditionDescription: + 'sender is the same as subscriber (self-message should be ignored)', + body: validBody({ source: MOCK_DESTINATION, destination: MOCK_DESTINATION }), + expectedIsOk: true, + expectedMessage: 'Ignored event.', + }, + ]).test( + 'Should return 200 ($expectedMessage) when $conditionDescription', + async ({ body, expectedIsOk, expectedMessage }) => { + const result = await modicaToFlexHandler({ body } as HttpRequest, ACCOUNT_SID); + + expect(result).toBeDefined(); + expect(isOk(result)).toBe(expectedIsOk); + if (isOk(result)) { + expect(result.data).toMatchObject({ + message: expect.stringContaining(expectedMessage), + }); + } + }, + ); +}); diff --git a/lambdas/account-scoped/tests/unit/customChannels/telegram/flexToTelegram.test.ts b/lambdas/account-scoped/tests/unit/customChannels/telegram/flexToTelegram.test.ts new file mode 100644 index 0000000000..f9960d436b --- /dev/null +++ b/lambdas/account-scoped/tests/unit/customChannels/telegram/flexToTelegram.test.ts @@ -0,0 +1,231 @@ +/** + * 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 each from 'jest-each'; +import { flexToTelegramHandler } from '../../../../src/customChannels/telegram/flexToTelegram'; +import { AccountSID } from '@tech-matters/twilio-types'; +import { getTwilioClient } from '@tech-matters/twilio-configuration'; +import { HttpRequest } from '../../../../src/httpTypes'; +import { isErr, isOk } from '../../../../src/Result'; +import { AssertionError } from 'node:assert'; + +global.fetch = jest.fn(); +const mockFetch = fetch as jest.MockedFunction; + +jest.mock('@tech-matters/twilio-configuration', () => ({ + getTwilioClient: jest.fn(), +})); + +jest.mock('../../../../src/customChannels/configuration', () => ({ + getTelegramFlexBotToken: jest.fn().mockResolvedValue('test-bot-token'), +})); + +const conversations: { [x: string]: any } = { + CONVERSATION_SID: { + sid: 'CONVERSATION_SID', + attributes: JSON.stringify({ from: 'channel-from' }), + participants: { + list: () => Promise.resolve([{ dateCreated: 0, sid: 'first convo participant' }]), + }, + }, +}; + +const mockGetTwilioClient = getTwilioClient as jest.MockedFunction< + typeof getTwilioClient +>; +let mockTwilioClient: any; + +const ACCOUNT_SID: AccountSID = 'ACxx'; +const TELEGRAM_FLEX_BOT_TOKEN = 'test-bot-token'; + +const validEvent = ({ Author = 'senderId', Source = 'API' } = {}) => ({ + Source, + Body: 'the message text', + EventType: 'onMessageAdded', + ConversationSid: 'CONVERSATION_SID', + Author, +}); + +describe('FlexToTelegram', () => { + beforeEach(() => { + mockTwilioClient = { + conversations: { + v1: { + conversations: { + get: (channelSid: string) => { + if (!conversations[channelSid]) + throw new Error('Conversation does not exist.'); + return { + fetch: async () => conversations[channelSid], + ...conversations[channelSid], + }; + }, + }, + }, + }, + }; + mockGetTwilioClient.mockResolvedValue(mockTwilioClient); + }); + + each([ + { + conditionDescription: 'the recipientId parameter is not provided', + event: validEvent(), + expectedStatus: 400, + expectedMessage: 'recipientId missing', + recipientId: null, + }, + { + conditionDescription: 'the Body parameter is not provided', + event: { ...validEvent(), Body: undefined }, + expectedStatus: 400, + expectedMessage: 'Body missing', + }, + { + conditionDescription: 'the ConversationSid parameter is not provided', + event: { ...validEvent(), ConversationSid: undefined }, + expectedStatus: 400, + expectedMessage: 'ConversationSid missing', + }, + { + conditionDescription: 'the Telegram endpoint throws an error', + event: validEvent(), + endpointImpl: () => { + throw new Error('BOOM'); + }, + expectedStatus: 500, + expectedMessage: 'BOOM', + }, + ]).test( + "Should return $expectedStatus '$expectedMessage' error when $conditionDescription.", + async ({ + event, + endpointImpl = async () => ({ status: 200, data: 'OK' }), + expectedStatus, + expectedMessage, + recipientId = 'recipientId', + }) => { + mockFetch.mockImplementation(endpointImpl); + const request = { + body: event, + query: { ...(recipientId ? { recipientId } : {}) }, + } as HttpRequest; + if (expectedStatus === 500) { + await expect(flexToTelegramHandler(request, ACCOUNT_SID)).rejects.toThrow( + new Error('BOOM'), + ); + return; + } + + const result = await flexToTelegramHandler(request, ACCOUNT_SID); + + if (isErr(result)) { + expect({ + status: result.error.statusCode, + message: result.message, + }).toMatchObject({ + status: expectedStatus, + message: expect.stringContaining(expectedMessage), + }); + } else { + expect({ + status: 200, + message: result.data.message, + }).toMatchObject({ + status: expectedStatus, + message: expect.stringContaining(expectedMessage), + }); + } + }, + ); + + each([ + { + conditionDescription: 'the event source is not supported', + event: validEvent({ Source: 'not supported' }), + shouldBeIgnored: true, + }, + { + conditionDescription: + "event 'Author' property matches first conversation participant sid", + event: validEvent({ Author: 'first convo participant' }), + shouldBeIgnored: true, + }, + { + conditionDescription: "event 'Source' property is set to 'API'", + event: validEvent(), + shouldBeIgnored: false, + }, + { + conditionDescription: "event 'Source' property is set to 'SDK'", + event: validEvent({ Source: 'SDK' }), + shouldBeIgnored: false, + }, + ]).test( + 'Should return status 200 success (ignored: $shouldBeIgnored) when $conditionDescription.', + async ({ event, shouldBeIgnored }) => { + mockFetch.mockClear(); + mockFetch.mockResolvedValue({ + status: 200, + ok: true, + json: async () => Promise.resolve({}), + headers: { some: 'header' } as any, + } as Response); + + const result = await flexToTelegramHandler( + { + body: event, + query: { recipientId: 'recipientId' } as Record, + } as HttpRequest, + ACCOUNT_SID, + ); + + if (shouldBeIgnored) { + expect(mockFetch).not.toHaveBeenCalled(); + } else { + expect(mockFetch).toBeCalledWith( + `https://api.telegram.org/bot${TELEGRAM_FLEX_BOT_TOKEN}/sendMessage`, + expect.objectContaining({ + method: 'post', + body: JSON.stringify({ + chat_id: 'recipientId', + text: event.Body, + }), + headers: { + 'Content-Type': 'application/json', + }, + }), + ); + } + + if (isOk(result)) { + if (shouldBeIgnored) { + expect(result.data.message).toContain('Ignored event'); + } else { + expect(result.data).toMatchObject({ + ok: true, + resultCode: 200, + }); + } + } else + throw new AssertionError({ + message: `Expected flexToTelegramHandler to return an OK result, but returned this error: ${JSON.stringify(result)}`, + expected: 'OK', + actual: result, + }); + }, + ); +}); diff --git a/lambdas/account-scoped/tests/unit/customChannels/telegram/telegramToFlex.test.ts b/lambdas/account-scoped/tests/unit/customChannels/telegram/telegramToFlex.test.ts new file mode 100644 index 0000000000..d8b3eac2b7 --- /dev/null +++ b/lambdas/account-scoped/tests/unit/customChannels/telegram/telegramToFlex.test.ts @@ -0,0 +1,170 @@ +/** + * 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 each from 'jest-each'; +import { + telegramToFlexHandler, + Body, + TELEGRAM_BOT_API_SECRET_TOKEN_HEADER, +} from '../../../../src/customChannels/telegram/telegramToFlex'; +import { HttpRequest } from '../../../../src/httpTypes'; +import { AccountSID } from '@tech-matters/twilio-types'; +import { isOk } from '../../../../src/Result'; +import { getTwilioClient } from '@tech-matters/twilio-configuration'; + +const MOCK_BOT_API_SECRET_TOKEN = 'test-bot-api-secret-token'; + +jest.mock('../../../../src/customChannels/configuration', () => ({ + getTelegramBotApiSecretToken: jest.fn().mockResolvedValue('test-bot-api-secret-token'), + getChannelStudioFlowSid: jest.fn().mockResolvedValue('TELEGRAM_STUDIO_FLOW_SID'), +})); + +jest.mock('@tech-matters/twilio-configuration', () => ({ + getTwilioClient: jest.fn(), +})); + +const MOCK_CHANNEL_TYPE = 'telegram'; +const MOCK_SENDER_ID = '123456789'; +const MOCK_SENDER_CHANNEL_SID = `${MOCK_CHANNEL_TYPE}:${MOCK_SENDER_ID}`; + +const newConversation = (sid: string) => ({ + attributes: '{}', + sid, + messages: { + create: jest.fn().mockResolvedValue(`Message sent in channel ${sid}.`), + list: async () => [], + }, + webhooks: { + create: async () => {}, + }, + update: async () => {}, +}); + +const conversations: { [x: string]: any } = { + [MOCK_SENDER_CHANNEL_SID]: newConversation(MOCK_SENDER_CHANNEL_SID), +}; + +const mockGetTwilioClient = getTwilioClient as jest.MockedFunction< + typeof getTwilioClient +>; +let mockTwilioClient: any; + +const ACCOUNT_SID: AccountSID = 'ACxx'; + +const validBody = ({ + senderId = MOCK_SENDER_ID, + username = 'testuser', + firstName = 'Test', + text = 'Hello!', +}: { + senderId?: string; + username?: string; + firstName?: string; + text?: string; +} = {}): Body => ({ + message: { + chat: { id: senderId, first_name: firstName, username }, + text, + }, +}); + +const validHeaders = () => ({ + [TELEGRAM_BOT_API_SECRET_TOKEN_HEADER]: MOCK_BOT_API_SECRET_TOKEN, +}); + +describe('TelegramToFlex', () => { + beforeEach(() => { + mockTwilioClient = { + conversations: { + v1: { + conversations: { + get: (conversationSid: string) => { + if (!conversations[conversationSid]) + throw new Error('Channel does not exist.'); + return { + fetch: async () => conversations[conversationSid], + ...conversations[conversationSid], + }; + }, + }, + participantConversations: { + list: () => [{ conversationState: 'active' }], + }, + }, + }, + }; + mockGetTwilioClient.mockResolvedValue(mockTwilioClient); + }); + + each([ + { + conditionDescription: 'the bot API secret token header is missing', + body: validBody(), + headers: {}, + expectedStatus: 403, + expectedMessage: 'Forbidden', + }, + { + conditionDescription: 'the bot API secret token header is incorrect', + body: validBody(), + headers: { [TELEGRAM_BOT_API_SECRET_TOKEN_HEADER]: 'wrong-token' }, + expectedStatus: 403, + expectedMessage: 'Forbidden', + }, + ]).test( + "Should return $expectedStatus '$expectedMessage' when $conditionDescription", + async ({ body, headers, expectedStatus, expectedMessage }) => { + const result = await telegramToFlexHandler( + { body, headers } as unknown as HttpRequest, + ACCOUNT_SID, + ); + + expect(result).toBeDefined(); + if (!isOk(result)) { + expect(result.error.statusCode).toBe(expectedStatus); + expect(result.message).toContain(expectedMessage); + } else { + throw new Error(`Expected error result but got OK`); + } + }, + ); + + test('Should process a valid Telegram message and call sendConversationMessageToFlex', async () => { + mockTwilioClient.conversations.v1.participantConversations.list = jest + .fn() + .mockResolvedValue([]); + mockTwilioClient.conversations.v1.conversations.create = jest + .fn() + .mockResolvedValue({ sid: MOCK_SENDER_CHANNEL_SID }); + mockTwilioClient.conversations.v1.conversations.get = jest.fn().mockReturnValue({ + fetch: jest.fn().mockResolvedValue({ attributes: '{}' }), + messages: { + create: jest.fn().mockResolvedValue({ sid: 'msg_001' }), + }, + participants: { create: jest.fn().mockResolvedValue({}) }, + webhooks: { create: jest.fn().mockResolvedValue({}) }, + update: jest.fn().mockResolvedValue({}), + }); + + const result = await telegramToFlexHandler( + { body: validBody(), headers: validHeaders() } as unknown as HttpRequest, + ACCOUNT_SID, + ); + + expect(result).toBeDefined(); + expect(isOk(result)).toBe(true); + }); +}); From 3fc1425459eaf7e3c941c7b753e5c6efeea177da Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 26 Feb 2026 10:49:15 +0000 Subject: [PATCH 3/6] Add extra debug logging to new custom channel handlers, change received event logs to debug level Co-authored-by: stephenhand <1694716+stephenhand@users.noreply.github.com> --- .../src/customChannels/line/flexToLine.ts | 4 +++- .../src/customChannels/line/lineToFlex.ts | 21 +++++++++++++++++++ .../src/customChannels/modica/flexToModica.ts | 4 +++- .../src/customChannels/modica/modicaToFlex.ts | 9 +++++++- .../customChannels/telegram/flexToTelegram.ts | 4 +++- .../customChannels/telegram/telegramToFlex.ts | 8 +++++++ 6 files changed, 46 insertions(+), 4 deletions(-) diff --git a/lambdas/account-scoped/src/customChannels/line/flexToLine.ts b/lambdas/account-scoped/src/customChannels/line/flexToLine.ts index 24ef9b3b7e..ccfce7344b 100644 --- a/lambdas/account-scoped/src/customChannels/line/flexToLine.ts +++ b/lambdas/account-scoped/src/customChannels/line/flexToLine.ts @@ -91,7 +91,7 @@ export const flexToLineHandler: AccountScopedHandler = async ( accountSid: AccountSID, ) => { console.info('==== FlexToLine handler ===='); - console.info('Received event:', event); + console.debug('FlexToLine: received event:', event); if (!recipientId) { return newMissingParameterResult('recipientId'); @@ -112,12 +112,14 @@ export const flexToLineHandler: AccountScopedHandler = async ( const lineChannelAccessToken = await getLineChannelAccessToken(accountSid); const client = await getTwilioClient(accountSid); + console.debug('FlexToLine: redirecting message to recipientId:', recipientId); result = await redirectConversationMessageToExternalChat(client, { event, recipientId, sendExternalMessage: sendLineMessage(lineChannelAccessToken), }); + console.debug('FlexToLine: result status:', result.status); switch (result.status) { case 'sent': return newOk(result.response); diff --git a/lambdas/account-scoped/src/customChannels/line/lineToFlex.ts b/lambdas/account-scoped/src/customChannels/line/lineToFlex.ts index 8941684920..0567e64bd8 100644 --- a/lambdas/account-scoped/src/customChannels/line/lineToFlex.ts +++ b/lambdas/account-scoped/src/customChannels/line/lineToFlex.ts @@ -105,9 +105,18 @@ export const lineToFlexHandler: AccountScopedHandler = async ( } const event: Body = body; + console.debug('LineToFlex: validated event body:', event); const { destination, events } = event; const messageEvents = events.filter(e => e.type === 'message'); + console.debug( + 'LineToFlex: destination:', + destination, + '- message events count:', + messageEvents.length, + '/ total events:', + events.length, + ); if (messageEvents.length === 0) { return newOk({ message: 'No messages to send' }); @@ -128,6 +137,12 @@ export const lineToFlexHandler: AccountScopedHandler = async ( const uniqueUserName = `${channelType}:${senderExternalId}`; const senderScreenName = 'child'; const onMessageSentWebhookUrl = `${process.env.WEBHOOK_BASE_URL}/lambda/twilio/account-scoped/${accountSid}/customChannels/line/flexToLine?recipientId=${senderExternalId}`; + console.debug( + 'LineToFlex: sending message from', + uniqueUserName, + 'to studio flow', + studioFlowSid, + ); // eslint-disable-next-line no-await-in-loop const result = await sendConversationMessageToFlex(accountSid, { @@ -142,6 +157,12 @@ export const lineToFlexHandler: AccountScopedHandler = async ( conversationFriendlyName: chatFriendlyName, }); + console.debug( + 'LineToFlex: result status:', + result.status, + 'for sender', + uniqueUserName, + ); switch (result.status) { case 'sent': responses.push(result.response); diff --git a/lambdas/account-scoped/src/customChannels/modica/flexToModica.ts b/lambdas/account-scoped/src/customChannels/modica/flexToModica.ts index 40316aaf0d..3c5d8f7e04 100644 --- a/lambdas/account-scoped/src/customChannels/modica/flexToModica.ts +++ b/lambdas/account-scoped/src/customChannels/modica/flexToModica.ts @@ -95,7 +95,7 @@ export const flexToModicaHandler: AccountScopedHandler = async ( accountSid: AccountSID, ) => { console.info('==== FlexToModica handler ===='); - console.info('Received event:', event); + console.debug('FlexToModica: received event:', event); if (!recipientId) { return newMissingParameterResult('recipientId'); @@ -120,12 +120,14 @@ export const flexToModicaHandler: AccountScopedHandler = async ( ]); const client = await getTwilioClient(accountSid); + console.debug('FlexToModica: redirecting message to recipientId:', recipientId); result = await redirectConversationMessageToExternalChat(client, { event, recipientId, sendExternalMessage: sendModicaMessage(appName, appPassword), }); + console.debug('FlexToModica: result status:', result.status); switch (result.status) { case 'sent': return newOk(result.response); diff --git a/lambdas/account-scoped/src/customChannels/modica/modicaToFlex.ts b/lambdas/account-scoped/src/customChannels/modica/modicaToFlex.ts index 030d074996..a0fed2ab94 100644 --- a/lambdas/account-scoped/src/customChannels/modica/modicaToFlex.ts +++ b/lambdas/account-scoped/src/customChannels/modica/modicaToFlex.ts @@ -34,7 +34,7 @@ export const modicaToFlexHandler: AccountScopedHandler = async ( accountSid: AccountSID, ) => { console.info('==== ModicaToFlex handler ===='); - console.info('Received event:', body); + console.debug('ModicaToFlex: received event:', body); const event: Body = body; const { source, destination, content } = event; @@ -51,6 +51,12 @@ export const modicaToFlexHandler: AccountScopedHandler = async ( accountSid, AseloCustomChannel.Modica, ); + console.debug( + 'ModicaToFlex: sending message from', + uniqueUserName, + 'to studio flow', + studioFlowSid, + ); const result = await sendConversationMessageToFlex(accountSid, { studioFlowSid, @@ -64,6 +70,7 @@ export const modicaToFlexHandler: AccountScopedHandler = async ( conversationFriendlyName: chatFriendlyName, }); + console.debug('ModicaToFlex: result status:', result.status); switch (result.status) { case 'sent': return newOk(result.response); diff --git a/lambdas/account-scoped/src/customChannels/telegram/flexToTelegram.ts b/lambdas/account-scoped/src/customChannels/telegram/flexToTelegram.ts index d5c7b2883c..991d345893 100644 --- a/lambdas/account-scoped/src/customChannels/telegram/flexToTelegram.ts +++ b/lambdas/account-scoped/src/customChannels/telegram/flexToTelegram.ts @@ -78,7 +78,7 @@ export const flexToTelegramHandler: AccountScopedHandler = async ( accountSid: AccountSID, ) => { console.info('==== FlexToTelegram handler ===='); - console.info('Received event:', event); + console.debug('FlexToTelegram: received event:', event); if (!recipientId) { return newMissingParameterResult('recipientId'); @@ -99,12 +99,14 @@ export const flexToTelegramHandler: AccountScopedHandler = async ( const telegramFlexBotToken = await getTelegramFlexBotToken(accountSid); const client = await getTwilioClient(accountSid); + console.debug('FlexToTelegram: redirecting message to recipientId:', recipientId); result = await redirectConversationMessageToExternalChat(client, { event, recipientId, sendExternalMessage: sendTelegramMessage(telegramFlexBotToken), }); + console.debug('FlexToTelegram: result status:', result.status); switch (result.status) { case 'sent': return newOk(result.response); diff --git a/lambdas/account-scoped/src/customChannels/telegram/telegramToFlex.ts b/lambdas/account-scoped/src/customChannels/telegram/telegramToFlex.ts index 557c988f30..473ef819e5 100644 --- a/lambdas/account-scoped/src/customChannels/telegram/telegramToFlex.ts +++ b/lambdas/account-scoped/src/customChannels/telegram/telegramToFlex.ts @@ -54,6 +54,7 @@ export const telegramToFlexHandler: AccountScopedHandler = async ( } const event: Body = body; + console.debug('TelegramToFlex: validated event body:', event); const { text: messageText, chat: { id: senderExternalId, username, first_name: firstName }, @@ -68,6 +69,12 @@ export const telegramToFlexHandler: AccountScopedHandler = async ( accountSid, AseloCustomChannel.Telegram, ); + console.debug( + 'TelegramToFlex: sending message from', + uniqueUserName, + 'to studio flow', + studioFlowSid, + ); const result = await sendConversationMessageToFlex(accountSid, { studioFlowSid, @@ -80,6 +87,7 @@ export const telegramToFlexHandler: AccountScopedHandler = async ( conversationFriendlyName: chatFriendlyName, }); + console.debug('TelegramToFlex: result status:', result.status); switch (result.status) { case 'sent': return newOk(result.response); From 6d0d0b1671e8c90892c512f6ce81717adc7c6518 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 26 Feb 2026 18:10:49 +0000 Subject: [PATCH 4/6] Fix SSM parameter paths for Telegram, Modica, and Line channels to match serverless naming convention Co-authored-by: stephenhand <1694716+stephenhand@users.noreply.github.com> --- .../src/customChannels/configuration.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/lambdas/account-scoped/src/customChannels/configuration.ts b/lambdas/account-scoped/src/customChannels/configuration.ts index 1b6c1f31ec..9e9cb10684 100644 --- a/lambdas/account-scoped/src/customChannels/configuration.ts +++ b/lambdas/account-scoped/src/customChannels/configuration.ts @@ -97,24 +97,24 @@ export const getChannelStudioFlowSid = ( export const getTelegramBotApiSecretToken = (accountSid: AccountSID): Promise => getSsmParameter( - `/${process.env.NODE_ENV}/twilio/${accountSid}/telegram_bot_api_secret_token`, + `/${process.env.NODE_ENV}/telegram/${accountSid}/bot_api_secret_token`, ); export const getTelegramFlexBotToken = (accountSid: AccountSID): Promise => getSsmParameter( - `/${process.env.NODE_ENV}/twilio/${accountSid}/telegram_flex_bot_token`, + `/${process.env.NODE_ENV}/telegram/${accountSid}/flex_bot_token`, ); export const getModicaAppName = (accountSid: AccountSID): Promise => - getSsmParameter(`/${process.env.NODE_ENV}/twilio/${accountSid}/modica_app_name`); + getSsmParameter(`/${process.env.NODE_ENV}/modica/${accountSid}/app_name`); export const getModicaAppPassword = (accountSid: AccountSID): Promise => - getSsmParameter(`/${process.env.NODE_ENV}/twilio/${accountSid}/modica_app_password`); + getSsmParameter(`/${process.env.NODE_ENV}/modica/${accountSid}/app_password`); export const getLineChannelSecret = (accountSid: AccountSID): Promise => - getSsmParameter(`/${process.env.NODE_ENV}/twilio/${accountSid}/line_channel_secret`); + getSsmParameter(`/${process.env.NODE_ENV}/line/${accountSid}/channel_secret`); export const getLineChannelAccessToken = (accountSid: AccountSID): Promise => getSsmParameter( - `/${process.env.NODE_ENV}/twilio/${accountSid}/line_channel_access_token`, + `/${process.env.NODE_ENV}/line/${accountSid}/channel_access_token`, ); From 03aa1df15514f541c329fe922c4b32e5bed3cc0f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 26 Feb 2026 21:45:13 +0000 Subject: [PATCH 5/6] Fix prettier formatting in configuration.ts SSM parameter functions Co-authored-by: stephenhand <1694716+stephenhand@users.noreply.github.com> --- .../src/customChannels/configuration.ts | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/lambdas/account-scoped/src/customChannels/configuration.ts b/lambdas/account-scoped/src/customChannels/configuration.ts index 9e9cb10684..55f42a7c64 100644 --- a/lambdas/account-scoped/src/customChannels/configuration.ts +++ b/lambdas/account-scoped/src/customChannels/configuration.ts @@ -96,14 +96,10 @@ export const getChannelStudioFlowSid = ( ); export const getTelegramBotApiSecretToken = (accountSid: AccountSID): Promise => - getSsmParameter( - `/${process.env.NODE_ENV}/telegram/${accountSid}/bot_api_secret_token`, - ); + getSsmParameter(`/${process.env.NODE_ENV}/telegram/${accountSid}/bot_api_secret_token`); export const getTelegramFlexBotToken = (accountSid: AccountSID): Promise => - getSsmParameter( - `/${process.env.NODE_ENV}/telegram/${accountSid}/flex_bot_token`, - ); + getSsmParameter(`/${process.env.NODE_ENV}/telegram/${accountSid}/flex_bot_token`); export const getModicaAppName = (accountSid: AccountSID): Promise => getSsmParameter(`/${process.env.NODE_ENV}/modica/${accountSid}/app_name`); @@ -115,6 +111,4 @@ export const getLineChannelSecret = (accountSid: AccountSID): Promise => getSsmParameter(`/${process.env.NODE_ENV}/line/${accountSid}/channel_secret`); export const getLineChannelAccessToken = (accountSid: AccountSID): Promise => - getSsmParameter( - `/${process.env.NODE_ENV}/line/${accountSid}/channel_access_token`, - ); + getSsmParameter(`/${process.env.NODE_ENV}/line/${accountSid}/channel_access_token`); From 2c8a8065ee99c1e4bde6c1cb17bb0fb9c1a92417 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 27 Feb 2026 16:22:10 +0000 Subject: [PATCH 6/6] Add testSessionId support to Telegram, Modica, and Line channels Co-authored-by: stephenhand <1694716+stephenhand@users.noreply.github.com> --- .../src/customChannels/line/lineToFlex.ts | 2 + .../src/customChannels/modica/flexToModica.ts | 11 ++++- .../src/customChannels/modica/modicaToFlex.ts | 2 + .../customChannels/telegram/telegramToFlex.ts | 2 + .../modica/flexToModica.test.ts | 49 +++++++++++++++++++ 5 files changed, 64 insertions(+), 2 deletions(-) diff --git a/lambdas/account-scoped/src/customChannels/line/lineToFlex.ts b/lambdas/account-scoped/src/customChannels/line/lineToFlex.ts index 0567e64bd8..9269d531e0 100644 --- a/lambdas/account-scoped/src/customChannels/line/lineToFlex.ts +++ b/lambdas/account-scoped/src/customChannels/line/lineToFlex.ts @@ -48,6 +48,7 @@ type LineEvent = { export type Body = { destination: string; events: LineEvent[]; + testSessionId?: string; // Only used in Aselo integration tests, not sent from Line }; // Line seems to have generated signatures using escaped unicode for emoji characters @@ -155,6 +156,7 @@ export const lineToFlexHandler: AccountScopedHandler = async ( senderExternalId, customSubscribedExternalId: subscribedExternalId, conversationFriendlyName: chatFriendlyName, + testSessionId: event.testSessionId, }); console.debug( diff --git a/lambdas/account-scoped/src/customChannels/modica/flexToModica.ts b/lambdas/account-scoped/src/customChannels/modica/flexToModica.ts index 3c5d8f7e04..57212497e4 100644 --- a/lambdas/account-scoped/src/customChannels/modica/flexToModica.ts +++ b/lambdas/account-scoped/src/customChannels/modica/flexToModica.ts @@ -19,6 +19,7 @@ import { ExternalSendResult, redirectConversationMessageToExternalChat, RedirectResult, + TEST_SEND_URL, } from '../flexToCustomChannel'; import { AccountScopedHandler, HttpError, HttpRequest } from '../../httpTypes'; import { isErr, newOk, Result } from '../../Result'; @@ -46,18 +47,24 @@ const sanitizeRecipientId = (recipientIdRaw: string) => { const sendModicaMessage = (appName: string, appPassword: string) => - async (recipientId: string, messageText: string): Promise => { + async ( + recipientId: string, + messageText: string, + testSessionId?: string, + ): Promise => { const payload = { destination: sanitizeRecipientId(recipientId), content: messageText, }; const base64Credentials = Buffer.from(`${appName}:${appPassword}`).toString('base64'); - const response = await fetch(DEFAULT_MODICA_SEND_MESSAGE_URL, { + const sendUrl = testSessionId ? TEST_SEND_URL : DEFAULT_MODICA_SEND_MESSAGE_URL; + const response = await fetch(sendUrl, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Basic ${base64Credentials}`, + ...(testSessionId ? { 'x-webhook-receiver-session-id': testSessionId } : {}), }, body: JSON.stringify(payload), }); diff --git a/lambdas/account-scoped/src/customChannels/modica/modicaToFlex.ts b/lambdas/account-scoped/src/customChannels/modica/modicaToFlex.ts index a0fed2ab94..ee7f39476b 100644 --- a/lambdas/account-scoped/src/customChannels/modica/modicaToFlex.ts +++ b/lambdas/account-scoped/src/customChannels/modica/modicaToFlex.ts @@ -27,6 +27,7 @@ export type Body = { source: string; // The child's phone number destination: string; // The helpline short code content: string; // The message text + testSessionId?: string; // Only used in Aselo integration tests, not sent from Modica }; export const modicaToFlexHandler: AccountScopedHandler = async ( @@ -68,6 +69,7 @@ export const modicaToFlexHandler: AccountScopedHandler = async ( senderExternalId, customSubscribedExternalId: subscribedExternalId, conversationFriendlyName: chatFriendlyName, + testSessionId: event.testSessionId, }); console.debug('ModicaToFlex: result status:', result.status); diff --git a/lambdas/account-scoped/src/customChannels/telegram/telegramToFlex.ts b/lambdas/account-scoped/src/customChannels/telegram/telegramToFlex.ts index 473ef819e5..6fab222242 100644 --- a/lambdas/account-scoped/src/customChannels/telegram/telegramToFlex.ts +++ b/lambdas/account-scoped/src/customChannels/telegram/telegramToFlex.ts @@ -30,6 +30,7 @@ export type Body = { chat: { id: string; first_name?: string; username?: string }; text: string; }; + testSessionId?: string; // Only used in Aselo integration tests, not sent from Telegram }; const isValidTelegramPayload = ( @@ -85,6 +86,7 @@ export const telegramToFlexHandler: AccountScopedHandler = async ( messageText, senderExternalId, conversationFriendlyName: chatFriendlyName, + testSessionId: event.testSessionId, }); console.debug('TelegramToFlex: result status:', result.status); diff --git a/lambdas/account-scoped/tests/unit/customChannels/modica/flexToModica.test.ts b/lambdas/account-scoped/tests/unit/customChannels/modica/flexToModica.test.ts index b73507195b..9d2d36302a 100644 --- a/lambdas/account-scoped/tests/unit/customChannels/modica/flexToModica.test.ts +++ b/lambdas/account-scoped/tests/unit/customChannels/modica/flexToModica.test.ts @@ -264,4 +264,53 @@ describe('FlexToModica', () => { }), ); }); + + test('Should send to TEST_SEND_URL with testSessionId header when testSessionId is set in conversation attributes', async () => { + const TEST_SESSION_ID = 'test-session-123'; + const TEST_SEND_URL = `${process.env.WEBHOOK_BASE_URL}/lambda/integrationTestRunner`; + + mockTwilioClient.conversations.v1.conversations.get = () => ({ + fetch: async () => ({ + ...conversations.CONVERSATION_SID, + attributes: JSON.stringify({ testSessionId: TEST_SESSION_ID }), + }), + ...conversations.CONVERSATION_SID, + }); + + mockFetch.mockClear(); + mockFetch.mockResolvedValue({ + status: 200, + ok: true, + text: async () => Promise.resolve(JSON.stringify({ success: true })), + headers: {} as any, + } as any); + + await flexToModicaHandler( + { + body: validEvent(), + query: { recipientId: '+64211234567' } as Record, + } as HttpRequest, + ACCOUNT_SID, + ); + + const expectedBase64Credentials = Buffer.from( + `${MODICA_APP_NAME}:${MODICA_APP_PASSWORD}`, + ).toString('base64'); + + expect(mockFetch).toBeCalledWith( + TEST_SEND_URL, + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ + destination: '+64211234567', + content: validEvent().Body, + }), + headers: { + 'Content-Type': 'application/json', + Authorization: `Basic ${expectedBase64Credentials}`, + 'x-webhook-receiver-session-id': TEST_SESSION_ID, + }, + }), + ); + }); });