From ce17e5ea8b27f7ae0214d513b94f1027a955a0f5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 27 Feb 2026 12:23:08 +0000 Subject: [PATCH 1/4] Initial plan From b6c46c17c1213176bdf8d2ee56ca51d2e60c4cd5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 27 Feb 2026 12:37:44 +0000 Subject: [PATCH 2/4] Migrate send message serverless endpoints to lambdas/account-scoped conversation folder Co-authored-by: stephenhand <1694716+stephenhand@users.noreply.github.com> --- .../conversation/sendMessageAndRunJanitor.ts | 84 ++++++ .../src/conversation/sendStudioMessage.ts | 54 ++++ .../src/conversation/sendSystemMessage.ts | 126 +++++++++ lambdas/account-scoped/src/router.ts | 15 + .../sendMessageAndRunJanitor.test.ts | 215 ++++++++++++++ .../conversation/sendStudioMessage.test.ts | 139 +++++++++ .../conversation/sendSystemMessage.test.ts | 266 ++++++++++++++++++ 7 files changed, 899 insertions(+) create mode 100644 lambdas/account-scoped/src/conversation/sendMessageAndRunJanitor.ts create mode 100644 lambdas/account-scoped/src/conversation/sendStudioMessage.ts create mode 100644 lambdas/account-scoped/src/conversation/sendSystemMessage.ts create mode 100644 lambdas/account-scoped/tests/unit/conversation/sendMessageAndRunJanitor.test.ts create mode 100644 lambdas/account-scoped/tests/unit/conversation/sendStudioMessage.test.ts create mode 100644 lambdas/account-scoped/tests/unit/conversation/sendSystemMessage.test.ts diff --git a/lambdas/account-scoped/src/conversation/sendMessageAndRunJanitor.ts b/lambdas/account-scoped/src/conversation/sendMessageAndRunJanitor.ts new file mode 100644 index 0000000000..c449b6a72b --- /dev/null +++ b/lambdas/account-scoped/src/conversation/sendMessageAndRunJanitor.ts @@ -0,0 +1,84 @@ +/** + * 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 { getChatServiceSid, getTwilioClient } from '@tech-matters/twilio-configuration'; +import { newErr } from '../Result'; +import type { AccountScopedHandler, HttpRequest } from '../httpTypes'; +import { sendSystemMessage } from './sendSystemMessage'; +import { chatChannelJanitor } from './chatChannelJanitor'; + +export const sendMessageAndRunJanitorHandler: AccountScopedHandler = async ( + { body }: HttpRequest, + accountSid: AccountSID, +) => { + try { + const { channelSid, conversationSid } = body; + + if (channelSid === undefined && conversationSid === undefined) { + return newErr({ + message: 'none of channelSid and conversationSid provided, exactly one expected.', + error: { statusCode: 400 }, + }); + } + + const client = await getTwilioClient(accountSid); + + if (conversationSid) { + const conversationWebhooks = await client.conversations.v1.conversations + .get(conversationSid) + .webhooks.list(); + + // Remove the studio trigger webhooks to prevent this channel from triggering subsequent Studio flows executions + await Promise.all( + conversationWebhooks.map(async w => { + if (w.target === 'studio') { + await w.remove(); + } + }), + ); + + const result = await sendSystemMessage(accountSid, body); + + await chatChannelJanitor(accountSid, { conversationSid }); + + return result; + } else { + const chatServiceSid = await getChatServiceSid(accountSid); + const channelWebhooks = await client.chat.v2.services + .get(chatServiceSid) + .channels.get(channelSid) + .webhooks.list(); + + // Remove the studio trigger webhooks to prevent this channel from triggering subsequent Studio flows executions + await Promise.all( + channelWebhooks.map(async w => { + if (w.type === 'studio') { + await w.remove(); + } + }), + ); + + const result = await sendSystemMessage(accountSid, body); + + await chatChannelJanitor(accountSid, { channelSid }); + + return result; + } + } catch (err: any) { + return newErr({ message: err.message, error: { statusCode: 500, cause: err } }); + } +}; diff --git a/lambdas/account-scoped/src/conversation/sendStudioMessage.ts b/lambdas/account-scoped/src/conversation/sendStudioMessage.ts new file mode 100644 index 0000000000..6462be4ebd --- /dev/null +++ b/lambdas/account-scoped/src/conversation/sendStudioMessage.ts @@ -0,0 +1,54 @@ +/** + * 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, ChatChannelSID } from '@tech-matters/twilio-types'; +import { getChatServiceSid, getTwilioClient } from '@tech-matters/twilio-configuration'; +import { newErr } from '../Result'; +import type { AccountScopedHandler, HttpRequest } from '../httpTypes'; +import { sendSystemMessage } from './sendSystemMessage'; + +const removeStudioWebhook = async ( + accountSid: AccountSID, + channelSid: ChatChannelSID, +): Promise => { + const client = await getTwilioClient(accountSid); + const chatServiceSid = await getChatServiceSid(accountSid); + const webhooks = await client.chat.v2.services + .get(chatServiceSid) + .channels.get(channelSid) + .webhooks.list(); + + const studioWebhook = webhooks.find(w => w.type === 'studio'); + await studioWebhook?.remove(); +}; + +export const sendStudioMessageHandler: AccountScopedHandler = async ( + { body }: HttpRequest, + accountSid: AccountSID, +) => { + try { + const { channelSid } = body; + + /** + * We need to remove the studio webhook before calling sendSystemMessage + * because it would trigger another execution of studio. + */ + await removeStudioWebhook(accountSid, channelSid); + return await sendSystemMessage(accountSid, body); + } catch (err: any) { + return newErr({ message: err.message, error: { statusCode: 500, cause: err } }); + } +}; diff --git a/lambdas/account-scoped/src/conversation/sendSystemMessage.ts b/lambdas/account-scoped/src/conversation/sendSystemMessage.ts new file mode 100644 index 0000000000..7fc8fd3842 --- /dev/null +++ b/lambdas/account-scoped/src/conversation/sendSystemMessage.ts @@ -0,0 +1,126 @@ +/** + * 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, + ChatChannelSID, + ConversationSID, +} from '@tech-matters/twilio-types'; +import { + getChatServiceSid, + getTwilioClient, + getWorkspaceSid, +} from '@tech-matters/twilio-configuration'; +import { newErr, newOk } from '../Result'; +import type { Result } from '../Result'; +import type { HttpError } from '../httpTypes'; +import type { FlexValidatedHandler } from '../validation/flexToken'; + +export type SendSystemMessageBody = ( + | { channelSid: ChatChannelSID; conversationSid?: ConversationSID; taskSid?: string } + | { channelSid?: ChatChannelSID; conversationSid: ConversationSID; taskSid?: string } + | { channelSid?: ChatChannelSID; conversationSid?: ConversationSID; taskSid: string } +) & { + message?: string; + from?: string; +}; + +export const sendSystemMessage = async ( + accountSid: AccountSID, + event: SendSystemMessageBody, +): Promise> => { + const { taskSid, channelSid, conversationSid, message, from } = event; + + console.log('------ sendSystemMessage execution ------'); + + if (!channelSid && !taskSid && !conversationSid) { + return newErr({ + message: 'none of taskSid, channelSid, or conversationSid provided, exactly one expected.', + error: { statusCode: 400 }, + }); + } + + if (message === undefined) { + return newErr({ message: 'missing message.', error: { statusCode: 400 } }); + } + + const client = await getTwilioClient(accountSid); + + let channelSidToMessage: ChatChannelSID | null = null; + let conversationSidToMessage: ConversationSID | null = null; + + if (channelSid) { + channelSidToMessage = channelSid; + } else if (conversationSid) { + conversationSidToMessage = conversationSid; + } else if (taskSid) { + const workspaceSid = await getWorkspaceSid(accountSid); + const task = await client.taskrouter.v1.workspaces + .get(workspaceSid) + .tasks.get(taskSid) + .fetch(); + const taskAttributes = JSON.parse(task.attributes); + const { channelSid: taskChannelSid, conversationSid: taskConversationSid } = + taskAttributes; + channelSidToMessage = taskChannelSid ?? null; + conversationSidToMessage = taskConversationSid ?? null; + } + + if (conversationSidToMessage) { + console.log( + `Adding message "${message}" to conversation ${conversationSidToMessage}`, + ); + const messageResult = await client.conversations.v1.conversations + .get(conversationSidToMessage) + .messages.create({ + body: message, + author: from, + xTwilioWebhookEnabled: 'true', + }); + return newOk(messageResult); + } + + if (channelSidToMessage) { + console.log(`Sending message "${message}" to channel ${channelSidToMessage}`); + const chatServiceSid = await getChatServiceSid(accountSid); + const messageResult = await client.chat.v2.services + .get(chatServiceSid) + .channels.get(channelSidToMessage) + .messages.create({ + body: message, + from, + xTwilioWebhookEnabled: 'true', + }); + return newOk(messageResult); + } + + return newErr({ + message: + 'Conversation or Chat Channel SID were not provided directly or specified on the provided task', + error: { statusCode: 400 }, + }); +}; + +export const sendSystemMessageHandler: FlexValidatedHandler = async ( + { body }, + accountSid, +) => { + try { + return await sendSystemMessage(accountSid, body as SendSystemMessageBody); + } catch (err: any) { + 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 9a1ced93eb..ae66e30f30 100644 --- a/lambdas/account-scoped/src/router.ts +++ b/lambdas/account-scoped/src/router.ts @@ -57,6 +57,9 @@ import { transferStartHandler } from './transfer/transferStart'; import { adjustChatCapacityHandler } from './conversation/adjustChatCapacity'; import { reportToIWFHandler } from './integrations/iwf/reportToIWF'; import { selfReportToIWFHandler } from './integrations/iwf/selfReportToIWF'; +import { sendSystemMessageHandler } from './conversation/sendSystemMessage'; +import { sendStudioMessageHandler } from './conversation/sendStudioMessage'; +import { sendMessageAndRunJanitorHandler } from './conversation/sendMessageAndRunJanitor'; /** * Super simple router sufficient for directly ported Twilio Serverless functions @@ -194,6 +197,18 @@ const ACCOUNTSID_ROUTES: Record< requestPipeline: [validateFlexTokenRequest({ tokenMode: 'agent' })], handler: selfReportToIWFHandler, }, + 'conversation/sendSystemMessage': { + requestPipeline: [validateFlexTokenRequest({ tokenMode: 'agent' })], + handler: sendSystemMessageHandler, + }, + 'conversation/sendStudioMessage': { + requestPipeline: [validateWebhookRequest], + handler: sendStudioMessageHandler, + }, + 'conversation/sendMessageAndRunJanitor': { + requestPipeline: [validateWebhookRequest], + handler: sendMessageAndRunJanitorHandler, + }, }; const ENV_SHORTCODE_ROUTES: Record = { diff --git a/lambdas/account-scoped/tests/unit/conversation/sendMessageAndRunJanitor.test.ts b/lambdas/account-scoped/tests/unit/conversation/sendMessageAndRunJanitor.test.ts new file mode 100644 index 0000000000..32b7d2b2fb --- /dev/null +++ b/lambdas/account-scoped/tests/unit/conversation/sendMessageAndRunJanitor.test.ts @@ -0,0 +1,215 @@ +/** + * 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 { sendMessageAndRunJanitorHandler } from '../../../src/conversation/sendMessageAndRunJanitor'; +import { getChatServiceSid, getTwilioClient } from '@tech-matters/twilio-configuration'; +import { isErr, isOk } from '../../../src/Result'; +import type { HttpRequest } from '../../../src/httpTypes'; +import { + TEST_ACCOUNT_SID, + TEST_CHANNEL_SID, + TEST_CHAT_SERVICE_SID, + TEST_CONVERSATION_SID, +} from '../../testTwilioValues'; + +jest.mock('@tech-matters/twilio-configuration', () => ({ + getTwilioClient: jest.fn(), + getChatServiceSid: jest.fn(), + getWorkspaceSid: jest.fn(), + getFlexProxyServiceSid: jest.fn(), +})); + +// Mock chatChannelJanitor to avoid its complex dependencies +jest.mock('../../../src/conversation/chatChannelJanitor', () => ({ + chatChannelJanitor: jest.fn().mockResolvedValue({ message: 'Deactivation attempted' }), +})); + +import { chatChannelJanitor } from '../../../src/conversation/chatChannelJanitor'; + +const mockGetTwilioClient = getTwilioClient as jest.MockedFunction< + typeof getTwilioClient +>; +const mockGetChatServiceSid = getChatServiceSid as jest.MockedFunction< + typeof getChatServiceSid +>; +const mockChatChannelJanitor = chatChannelJanitor as jest.MockedFunction< + typeof chatChannelJanitor +>; + +const TEST_MESSAGE = 'Test janitor message'; +const TEST_FROM = 'Bot'; + +const mockConversationWebhookRemove = jest.fn(); +const mockConversationWebhookList = jest.fn(); +const mockChannelWebhookRemove = jest.fn(); +const mockChannelWebhookList = jest.fn(); +const mockCreateConversationMessage = jest.fn(); +const mockCreateChannelMessage = jest.fn(); + +const createMockClient = () => ({ + conversations: { + v1: { + conversations: { + get: jest.fn().mockReturnValue({ + webhooks: { + list: mockConversationWebhookList, + }, + messages: { + create: mockCreateConversationMessage, + }, + }), + }, + }, + }, + chat: { + v2: { + services: { + get: jest.fn().mockReturnValue({ + channels: { + get: jest.fn().mockReturnValue({ + webhooks: { + list: mockChannelWebhookList, + }, + messages: { + create: mockCreateChannelMessage, + }, + }), + }, + }), + }, + }, + }, +}); + +const createMockRequest = (body: any): HttpRequest => ({ + method: 'POST', + headers: {}, + path: '/test', + query: {}, + body, +}); + +describe('sendMessageAndRunJanitorHandler', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockGetChatServiceSid.mockResolvedValue(TEST_CHAT_SERVICE_SID as any); + mockGetTwilioClient.mockResolvedValue(createMockClient() as any); + mockCreateConversationMessage.mockResolvedValue({ sid: 'IM_conversation_message' }); + mockCreateChannelMessage.mockResolvedValue({ sid: 'IM_channel_message' }); + mockConversationWebhookList.mockResolvedValue([]); + mockChannelWebhookList.mockResolvedValue([]); + mockChatChannelJanitor.mockResolvedValue({ + message: 'Deactivation attempted', + } as any); + }); + + it('should return 400 when neither channelSid nor conversationSid provided', async () => { + const request = createMockRequest({ message: TEST_MESSAGE }); + + const result = await sendMessageAndRunJanitorHandler(request, TEST_ACCOUNT_SID); + + expect(isErr(result)).toBe(true); + if (isErr(result)) { + expect(result.error.statusCode).toBe(400); + } + }); + + it('should remove studio webhook, send message, and run janitor for conversationSid', async () => { + mockConversationWebhookList.mockResolvedValue([ + { target: 'studio', remove: mockConversationWebhookRemove }, + { target: 'function', remove: jest.fn() }, + ]); + mockConversationWebhookRemove.mockResolvedValue(true); + + const request = createMockRequest({ + conversationSid: TEST_CONVERSATION_SID, + message: TEST_MESSAGE, + from: TEST_FROM, + }); + + const result = await sendMessageAndRunJanitorHandler(request, TEST_ACCOUNT_SID); + + expect(isOk(result)).toBe(true); + expect(mockConversationWebhookRemove).toHaveBeenCalledTimes(1); + expect(mockCreateConversationMessage).toHaveBeenCalledWith({ + body: TEST_MESSAGE, + author: TEST_FROM, + xTwilioWebhookEnabled: 'true', + }); + expect(mockChatChannelJanitor).toHaveBeenCalledWith(TEST_ACCOUNT_SID, { + conversationSid: TEST_CONVERSATION_SID, + }); + }); + + it('should not remove non-studio webhooks for conversationSid', async () => { + mockConversationWebhookList.mockResolvedValue([ + { target: 'function', remove: mockConversationWebhookRemove }, + ]); + + const request = createMockRequest({ + conversationSid: TEST_CONVERSATION_SID, + message: TEST_MESSAGE, + }); + + await sendMessageAndRunJanitorHandler(request, TEST_ACCOUNT_SID); + + expect(mockConversationWebhookRemove).not.toHaveBeenCalled(); + }); + + it('should remove studio webhook, send message, and run janitor for channelSid', async () => { + mockChannelWebhookList.mockResolvedValue([ + { type: 'studio', remove: mockChannelWebhookRemove }, + { type: 'trigger', remove: jest.fn() }, + ]); + mockChannelWebhookRemove.mockResolvedValue(true); + + const request = createMockRequest({ + channelSid: TEST_CHANNEL_SID, + message: TEST_MESSAGE, + from: TEST_FROM, + }); + + const result = await sendMessageAndRunJanitorHandler(request, TEST_ACCOUNT_SID); + + expect(isOk(result)).toBe(true); + expect(mockChannelWebhookRemove).toHaveBeenCalledTimes(1); + expect(mockCreateChannelMessage).toHaveBeenCalledWith({ + body: TEST_MESSAGE, + from: TEST_FROM, + xTwilioWebhookEnabled: 'true', + }); + expect(mockChatChannelJanitor).toHaveBeenCalledWith(TEST_ACCOUNT_SID, { + channelSid: TEST_CHANNEL_SID, + }); + }); + + it('should return 500 when an unexpected error occurs', async () => { + mockGetTwilioClient.mockRejectedValue(new Error('Connection error')); + + const request = createMockRequest({ + conversationSid: TEST_CONVERSATION_SID, + message: TEST_MESSAGE, + }); + + const result = await sendMessageAndRunJanitorHandler(request, TEST_ACCOUNT_SID); + + expect(isErr(result)).toBe(true); + if (isErr(result)) { + expect(result.error.statusCode).toBe(500); + expect(result.message).toContain('Connection error'); + } + }); +}); diff --git a/lambdas/account-scoped/tests/unit/conversation/sendStudioMessage.test.ts b/lambdas/account-scoped/tests/unit/conversation/sendStudioMessage.test.ts new file mode 100644 index 0000000000..7253731306 --- /dev/null +++ b/lambdas/account-scoped/tests/unit/conversation/sendStudioMessage.test.ts @@ -0,0 +1,139 @@ +/** + * 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 { sendStudioMessageHandler } from '../../../src/conversation/sendStudioMessage'; +import { getChatServiceSid, getTwilioClient } from '@tech-matters/twilio-configuration'; +import { isErr, isOk } from '../../../src/Result'; +import type { HttpRequest } from '../../../src/httpTypes'; +import { + TEST_ACCOUNT_SID, + TEST_CHANNEL_SID, + TEST_CHAT_SERVICE_SID, +} from '../../testTwilioValues'; + +jest.mock('@tech-matters/twilio-configuration', () => ({ + getTwilioClient: jest.fn(), + getChatServiceSid: jest.fn(), + getWorkspaceSid: jest.fn(), +})); + +const mockGetTwilioClient = getTwilioClient as jest.MockedFunction< + typeof getTwilioClient +>; +const mockGetChatServiceSid = getChatServiceSid as jest.MockedFunction< + typeof getChatServiceSid +>; + +const TEST_MESSAGE = 'Test studio message'; +const TEST_FROM = 'Bot'; + +const mockWebhookRemove = jest.fn(); +const mockWebhookList = jest.fn(); +const mockCreateChannelMessage = jest.fn(); + +const createMockClient = () => ({ + chat: { + v2: { + services: { + get: jest.fn().mockReturnValue({ + channels: { + get: jest.fn().mockReturnValue({ + webhooks: { + list: mockWebhookList, + }, + messages: { + create: mockCreateChannelMessage, + }, + }), + }, + }), + }, + }, + }, +}); + +const createMockRequest = (body: any): HttpRequest => ({ + method: 'POST', + headers: {}, + path: '/test', + query: {}, + body, +}); + +describe('sendStudioMessageHandler', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockGetChatServiceSid.mockResolvedValue(TEST_CHAT_SERVICE_SID as any); + mockGetTwilioClient.mockResolvedValue(createMockClient() as any); + mockCreateChannelMessage.mockResolvedValue({ sid: 'IM_channel_message' }); + mockWebhookList.mockResolvedValue([]); + }); + + it('should remove studio webhook and send message', async () => { + mockWebhookList.mockResolvedValue([ + { type: 'studio', remove: mockWebhookRemove }, + { type: 'trigger', remove: jest.fn() }, + ]); + mockWebhookRemove.mockResolvedValue(true); + + const request = createMockRequest({ + channelSid: TEST_CHANNEL_SID, + message: TEST_MESSAGE, + from: TEST_FROM, + }); + + const result = await sendStudioMessageHandler(request, TEST_ACCOUNT_SID); + + expect(isOk(result)).toBe(true); + expect(mockWebhookRemove).toHaveBeenCalledTimes(1); + expect(mockCreateChannelMessage).toHaveBeenCalledWith({ + body: TEST_MESSAGE, + from: TEST_FROM, + xTwilioWebhookEnabled: 'true', + }); + }); + + it('should proceed without removing webhook when no studio webhook exists', async () => { + mockWebhookList.mockResolvedValue([{ type: 'trigger', remove: jest.fn() }]); + + const request = createMockRequest({ + channelSid: TEST_CHANNEL_SID, + message: TEST_MESSAGE, + }); + + const result = await sendStudioMessageHandler(request, TEST_ACCOUNT_SID); + + expect(isOk(result)).toBe(true); + expect(mockCreateChannelMessage).toHaveBeenCalled(); + }); + + it('should return 500 when an unexpected error occurs', async () => { + mockGetTwilioClient.mockRejectedValue(new Error('Connection error')); + + const request = createMockRequest({ + channelSid: TEST_CHANNEL_SID, + message: TEST_MESSAGE, + }); + + const result = await sendStudioMessageHandler(request, TEST_ACCOUNT_SID); + + expect(isErr(result)).toBe(true); + if (isErr(result)) { + expect(result.error.statusCode).toBe(500); + expect(result.message).toContain('Connection error'); + } + }); +}); diff --git a/lambdas/account-scoped/tests/unit/conversation/sendSystemMessage.test.ts b/lambdas/account-scoped/tests/unit/conversation/sendSystemMessage.test.ts new file mode 100644 index 0000000000..aaa4b5cf77 --- /dev/null +++ b/lambdas/account-scoped/tests/unit/conversation/sendSystemMessage.test.ts @@ -0,0 +1,266 @@ +/** + * 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 { + sendSystemMessage, + sendSystemMessageHandler, +} from '../../../src/conversation/sendSystemMessage'; +import { + getChatServiceSid, + getTwilioClient, + getWorkspaceSid, +} from '@tech-matters/twilio-configuration'; +import { isErr, isOk } from '../../../src/Result'; +import { FlexValidatedHttpRequest } from '../../../src/validation/flexToken'; +import { + TEST_ACCOUNT_SID, + TEST_CHANNEL_SID, + TEST_CHAT_SERVICE_SID, + TEST_CONVERSATION_SID, + TEST_TASK_SID, + TEST_WORKSPACE_SID, +} from '../../testTwilioValues'; + +jest.mock('@tech-matters/twilio-configuration', () => ({ + getTwilioClient: jest.fn(), + getChatServiceSid: jest.fn(), + getWorkspaceSid: jest.fn(), +})); + +const mockGetTwilioClient = getTwilioClient as jest.MockedFunction< + typeof getTwilioClient +>; +const mockGetChatServiceSid = getChatServiceSid as jest.MockedFunction< + typeof getChatServiceSid +>; +const mockGetWorkspaceSid = getWorkspaceSid as jest.MockedFunction< + typeof getWorkspaceSid +>; + +const TEST_MESSAGE = 'Test system message'; +const TEST_FROM = 'Bot'; + +const mockCreateConversationMessage = jest.fn(); +const mockCreateChannelMessage = jest.fn(); +const mockFetchTask = jest.fn(); + +const createMockClient = () => ({ + conversations: { + v1: { + conversations: { + get: jest.fn().mockReturnValue({ + messages: { + create: mockCreateConversationMessage, + }, + }), + }, + }, + }, + chat: { + v2: { + services: { + get: jest.fn().mockReturnValue({ + channels: { + get: jest.fn().mockReturnValue({ + messages: { + create: mockCreateChannelMessage, + }, + }), + }, + }), + }, + }, + }, + taskrouter: { + v1: { + workspaces: { + get: jest.fn().mockReturnValue({ + tasks: { + get: jest.fn().mockReturnValue({ + fetch: mockFetchTask, + }), + }, + }), + }, + }, + }, +}); + +const createMockRequest = (body: any): FlexValidatedHttpRequest => ({ + method: 'POST', + headers: {}, + path: '/test', + query: {}, + body, + tokenResult: { worker_sid: 'WK1234', roles: ['agent'] }, +}); + +describe('sendSystemMessage', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockGetChatServiceSid.mockResolvedValue(TEST_CHAT_SERVICE_SID as any); + mockGetWorkspaceSid.mockResolvedValue(TEST_WORKSPACE_SID as any); + mockGetTwilioClient.mockResolvedValue(createMockClient() as any); + mockCreateConversationMessage.mockResolvedValue({ sid: 'IM_conversation_message' }); + mockCreateChannelMessage.mockResolvedValue({ sid: 'IM_channel_message' }); + }); + + it('should return 400 when none of channelSid, conversationSid, taskSid provided', async () => { + const result = await sendSystemMessage(TEST_ACCOUNT_SID, { + message: TEST_MESSAGE, + } as any); + + expect(isErr(result)).toBe(true); + if (isErr(result)) { + expect(result.error.statusCode).toBe(400); + expect(result.message).toContain('channelSid'); + } + }); + + it('should return 400 when message is missing', async () => { + const result = await sendSystemMessage(TEST_ACCOUNT_SID, { + conversationSid: TEST_CONVERSATION_SID, + } as any); + + expect(isErr(result)).toBe(true); + if (isErr(result)) { + expect(result.error.statusCode).toBe(400); + expect(result.message).toContain('message'); + } + }); + + it('should send message to conversation when conversationSid provided', async () => { + const result = await sendSystemMessage(TEST_ACCOUNT_SID, { + conversationSid: TEST_CONVERSATION_SID, + message: TEST_MESSAGE, + from: TEST_FROM, + }); + + expect(isOk(result)).toBe(true); + expect(mockCreateConversationMessage).toHaveBeenCalledWith({ + body: TEST_MESSAGE, + author: TEST_FROM, + xTwilioWebhookEnabled: 'true', + }); + }); + + it('should send message to channel when channelSid provided', async () => { + const result = await sendSystemMessage(TEST_ACCOUNT_SID, { + channelSid: TEST_CHANNEL_SID, + message: TEST_MESSAGE, + from: TEST_FROM, + }); + + expect(isOk(result)).toBe(true); + expect(mockCreateChannelMessage).toHaveBeenCalledWith({ + body: TEST_MESSAGE, + from: TEST_FROM, + xTwilioWebhookEnabled: 'true', + }); + }); + + it('should look up task and send to conversation when taskSid provided and task has conversationSid', async () => { + mockFetchTask.mockResolvedValue({ + attributes: JSON.stringify({ conversationSid: TEST_CONVERSATION_SID }), + }); + + const result = await sendSystemMessage(TEST_ACCOUNT_SID, { + taskSid: TEST_TASK_SID, + message: TEST_MESSAGE, + from: TEST_FROM, + }); + + expect(isOk(result)).toBe(true); + expect(mockCreateConversationMessage).toHaveBeenCalledWith({ + body: TEST_MESSAGE, + author: TEST_FROM, + xTwilioWebhookEnabled: 'true', + }); + }); + + it('should look up task and send to channel when taskSid provided and task has channelSid', async () => { + mockFetchTask.mockResolvedValue({ + attributes: JSON.stringify({ channelSid: TEST_CHANNEL_SID }), + }); + + const result = await sendSystemMessage(TEST_ACCOUNT_SID, { + taskSid: TEST_TASK_SID, + message: TEST_MESSAGE, + from: TEST_FROM, + }); + + expect(isOk(result)).toBe(true); + expect(mockCreateChannelMessage).toHaveBeenCalledWith({ + body: TEST_MESSAGE, + from: TEST_FROM, + xTwilioWebhookEnabled: 'true', + }); + }); + + it('should return 400 when task has neither channelSid nor conversationSid', async () => { + mockFetchTask.mockResolvedValue({ + attributes: JSON.stringify({}), + }); + + const result = await sendSystemMessage(TEST_ACCOUNT_SID, { + taskSid: TEST_TASK_SID, + message: TEST_MESSAGE, + }); + + expect(isErr(result)).toBe(true); + if (isErr(result)) { + expect(result.error.statusCode).toBe(400); + } + }); +}); + +describe('sendSystemMessageHandler', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockGetChatServiceSid.mockResolvedValue(TEST_CHAT_SERVICE_SID as any); + mockGetWorkspaceSid.mockResolvedValue(TEST_WORKSPACE_SID as any); + mockGetTwilioClient.mockResolvedValue(createMockClient() as any); + mockCreateConversationMessage.mockResolvedValue({ sid: 'IM_conversation_message' }); + }); + + it('should successfully send message via handler', async () => { + const request = createMockRequest({ + conversationSid: TEST_CONVERSATION_SID, + message: TEST_MESSAGE, + }); + + const result = await sendSystemMessageHandler(request, TEST_ACCOUNT_SID); + + expect(isOk(result)).toBe(true); + }); + + it('should return 500 when an unexpected error occurs', async () => { + mockGetTwilioClient.mockRejectedValue(new Error('Connection error')); + + const request = createMockRequest({ + conversationSid: TEST_CONVERSATION_SID, + message: TEST_MESSAGE, + }); + + const result = await sendSystemMessageHandler(request, TEST_ACCOUNT_SID); + + expect(isErr(result)).toBe(true); + if (isErr(result)) { + expect(result.error.statusCode).toBe(500); + expect(result.message).toContain('Connection error'); + } + }); +}); From 16403562630b1b6086499df22a0af4702f07b6b9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 27 Feb 2026 18:27:06 +0000 Subject: [PATCH 3/4] Fix prettier linter error in sendSystemMessage.ts Co-authored-by: stephenhand <1694716+stephenhand@users.noreply.github.com> --- lambdas/account-scoped/src/conversation/sendSystemMessage.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lambdas/account-scoped/src/conversation/sendSystemMessage.ts b/lambdas/account-scoped/src/conversation/sendSystemMessage.ts index 7fc8fd3842..200c434813 100644 --- a/lambdas/account-scoped/src/conversation/sendSystemMessage.ts +++ b/lambdas/account-scoped/src/conversation/sendSystemMessage.ts @@ -48,7 +48,8 @@ export const sendSystemMessage = async ( if (!channelSid && !taskSid && !conversationSid) { return newErr({ - message: 'none of taskSid, channelSid, or conversationSid provided, exactly one expected.', + message: + 'none of taskSid, channelSid, or conversationSid provided, exactly one expected.', error: { statusCode: 400 }, }); } From 8f88a4ed8358805ddf3be019c34efeedcb8eddab Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 2 Mar 2026 11:06:43 +0000 Subject: [PATCH 4/4] Replace console.log with appropriate log levels and add extra debug logs Co-authored-by: stephenhand <1694716+stephenhand@users.noreply.github.com> --- .../conversation/sendMessageAndRunJanitor.ts | 10 ++++++++++ .../src/conversation/sendStudioMessage.ts | 4 ++++ .../src/conversation/sendSystemMessage.ts | 18 +++++++++++++----- 3 files changed, 27 insertions(+), 5 deletions(-) diff --git a/lambdas/account-scoped/src/conversation/sendMessageAndRunJanitor.ts b/lambdas/account-scoped/src/conversation/sendMessageAndRunJanitor.ts index c449b6a72b..f2022722ff 100644 --- a/lambdas/account-scoped/src/conversation/sendMessageAndRunJanitor.ts +++ b/lambdas/account-scoped/src/conversation/sendMessageAndRunJanitor.ts @@ -27,6 +27,11 @@ export const sendMessageAndRunJanitorHandler: AccountScopedHandler = async ( ) => { try { const { channelSid, conversationSid } = body; + console.debug('sendMessageAndRunJanitor execution', { + accountSid, + conversationSid, + channelSid, + }); if (channelSid === undefined && conversationSid === undefined) { return newErr({ @@ -42,6 +47,7 @@ export const sendMessageAndRunJanitorHandler: AccountScopedHandler = async ( .get(conversationSid) .webhooks.list(); + console.debug(`Removing studio webhooks from conversation ${conversationSid}`); // Remove the studio trigger webhooks to prevent this channel from triggering subsequent Studio flows executions await Promise.all( conversationWebhooks.map(async w => { @@ -50,6 +56,7 @@ export const sendMessageAndRunJanitorHandler: AccountScopedHandler = async ( } }), ); + console.info(`Studio webhooks removed from conversation ${conversationSid}`); const result = await sendSystemMessage(accountSid, body); @@ -63,6 +70,7 @@ export const sendMessageAndRunJanitorHandler: AccountScopedHandler = async ( .channels.get(channelSid) .webhooks.list(); + console.debug(`Removing studio webhooks from channel ${channelSid}`); // Remove the studio trigger webhooks to prevent this channel from triggering subsequent Studio flows executions await Promise.all( channelWebhooks.map(async w => { @@ -71,6 +79,7 @@ export const sendMessageAndRunJanitorHandler: AccountScopedHandler = async ( } }), ); + console.info(`Studio webhooks removed from channel ${channelSid}`); const result = await sendSystemMessage(accountSid, body); @@ -79,6 +88,7 @@ export const sendMessageAndRunJanitorHandler: AccountScopedHandler = async ( return result; } } catch (err: any) { + console.error('sendMessageAndRunJanitor failed', err); return newErr({ message: err.message, error: { statusCode: 500, cause: err } }); } }; diff --git a/lambdas/account-scoped/src/conversation/sendStudioMessage.ts b/lambdas/account-scoped/src/conversation/sendStudioMessage.ts index 6462be4ebd..3fc818a5ac 100644 --- a/lambdas/account-scoped/src/conversation/sendStudioMessage.ts +++ b/lambdas/account-scoped/src/conversation/sendStudioMessage.ts @@ -41,14 +41,18 @@ export const sendStudioMessageHandler: AccountScopedHandler = async ( ) => { try { const { channelSid } = body; + console.debug('sendStudioMessage execution', { accountSid, channelSid }); /** * We need to remove the studio webhook before calling sendSystemMessage * because it would trigger another execution of studio. */ + console.debug(`Removing studio webhook from channel ${channelSid}`); await removeStudioWebhook(accountSid, channelSid); + console.info(`Studio webhook removed from channel ${channelSid}`); return await sendSystemMessage(accountSid, body); } catch (err: any) { + console.error('sendStudioMessage failed', err); return newErr({ message: err.message, error: { statusCode: 500, cause: err } }); } }; diff --git a/lambdas/account-scoped/src/conversation/sendSystemMessage.ts b/lambdas/account-scoped/src/conversation/sendSystemMessage.ts index 200c434813..641ede9f8a 100644 --- a/lambdas/account-scoped/src/conversation/sendSystemMessage.ts +++ b/lambdas/account-scoped/src/conversation/sendSystemMessage.ts @@ -44,7 +44,12 @@ export const sendSystemMessage = async ( ): Promise> => { const { taskSid, channelSid, conversationSid, message, from } = event; - console.log('------ sendSystemMessage execution ------'); + console.debug('sendSystemMessage execution', { + accountSid, + conversationSid, + channelSid, + taskSid, + }); if (!channelSid && !taskSid && !conversationSid) { return newErr({ @@ -81,9 +86,7 @@ export const sendSystemMessage = async ( } if (conversationSidToMessage) { - console.log( - `Adding message "${message}" to conversation ${conversationSidToMessage}`, - ); + console.info(`Sending system message to conversation ${conversationSidToMessage}`); const messageResult = await client.conversations.v1.conversations .get(conversationSidToMessage) .messages.create({ @@ -91,11 +94,14 @@ export const sendSystemMessage = async ( author: from, xTwilioWebhookEnabled: 'true', }); + console.info( + `System message sent successfully to conversation ${conversationSidToMessage}`, + ); return newOk(messageResult); } if (channelSidToMessage) { - console.log(`Sending message "${message}" to channel ${channelSidToMessage}`); + console.info(`Sending system message to channel ${channelSidToMessage}`); const chatServiceSid = await getChatServiceSid(accountSid); const messageResult = await client.chat.v2.services .get(chatServiceSid) @@ -105,6 +111,7 @@ export const sendSystemMessage = async ( from, xTwilioWebhookEnabled: 'true', }); + console.info(`System message sent successfully to channel ${channelSidToMessage}`); return newOk(messageResult); } @@ -122,6 +129,7 @@ export const sendSystemMessageHandler: FlexValidatedHandler = async ( try { return await sendSystemMessage(accountSid, body as SendSystemMessageBody); } catch (err: any) { + console.error('sendSystemMessage failed', err); return newErr({ message: err.message, error: { statusCode: 500, cause: err } }); } };