diff --git a/.github/actions/custom-actions/aselo_development_custom/action.yml b/.github/actions/custom-actions/aselo_development_custom/action.yml index 97182a87..d4275067 100644 --- a/.github/actions/custom-actions/aselo_development_custom/action.yml +++ b/.github/actions/custom-actions/aselo_development_custom/action.yml @@ -95,7 +95,11 @@ runs: with: ssm_parameter: "/development/line/${{inputs.account-sid}}/channel_access_token" env_variable_name: "LINE_CHANNEL_ACCESS_TOKEN" - # Line environment variables + - name: Set helpline Line Studio Flow SID + uses: "marvinpinto/action-inject-ssm-secrets@latest" + with: + ssm_parameter: "/development/twilio/${{inputs.account-sid}}/line_studio_flow_sid" + env_variable_name: "LINE_STUDIO_FLOW_SID" - name: Set helpline Line Flex Messaging Mode (Programmable Chat or Conversations) uses: "marvinpinto/action-inject-ssm-secrets@latest" with: @@ -117,6 +121,37 @@ runs: with: ssm_parameter: "/development/twilio/${{inputs.account-sid}}/modica_flex_flow_sid" env_variable_name: "MODICA_FLEX_FLOW_SID" + + + # Telegram environment variables + - name: Set Telegram Flex Bot Token + uses: "marvinpinto/action-inject-ssm-secrets@latest" + with: + ssm_parameter: "/development/telegram/${{inputs.account-sid}}/flex_bot_token" + env_variable_name: "TELEGRAM_FLEX_BOT_TOKEN" + - name: Set Telegram Bot Api Secret Token + uses: "marvinpinto/action-inject-ssm-secrets@latest" + with: + ssm_parameter: "/development/telegram/${{inputs.account-sid}}/bot_api_secret_token" + env_variable_name: "TELEGRAM_BOT_API_SECRET_TOKEN" + - name: Set helpline Telegram Studio Flow SID + uses: "marvinpinto/action-inject-ssm-secrets@latest" + with: + ssm_parameter: "/development/twilio/${{inputs.account-sid}}/telegram_studio_flow_sid" + env_variable_name: "TELEGRAM_STUDIO_FLOW_SID" + + - name: Set helpline serverless URL + uses: "marvinpinto/action-inject-ssm-secrets@latest" + with: + ssm_parameter: "/development/serverless/${{inputs.account-sid}}/base_url" + env_variable_name: "SERVERLESS_BASE_URL" + - name: Set Telegram flex bot webhook + shell: bash + run: | + curl --request POST \ + --header "Content-Type: application/json" \ + --url "https://api.telegram.org/bot${{ env.TELEGRAM_FLEX_BOT_TOKEN }}/setWebhook" \ + --data '{ "url": "${{ env.SERVERLESS_BASE_URL }}/webhooks/telegram/TelegramToFlex", "secret_token": "${{ env.TELEGRAM_BOT_API_SECRET_TOKEN }}" }' # Append environment variables - name: Add IWF_API_USERNAME run: echo "IWF_API_USERNAME=${{ env.IWF_API_USERNAME }}" >> .env @@ -155,6 +190,9 @@ runs: - name: Add LINE_CHANNEL_ACCESS_TOKEN run: echo "LINE_CHANNEL_ACCESS_TOKEN=${{ env.LINE_CHANNEL_ACCESS_TOKEN }}" >> .env shell: bash + - name: Add LINE_STUDIO_FLOW_SID + run: echo "LINE_STUDIO_FLOW_SID=${{ env.LINE_STUDIO_FLOW_SID }}" >> .env + shell: bash - name: Add LINE_TWILIO_MESSAGING_MODE run: echo "LINE_TWILIO_MESSAGING_MODE=${{ env.LINE_TWILIO_MESSAGING_MODE }}" >> .env shell: bash @@ -167,3 +205,12 @@ runs: - name: Add MODICA_FLEX_FLOW_SID run: echo "MODICA_FLEX_FLOW_SID=${{ env.MODICA_FLEX_FLOW_SID }}" >> .env shell: bash + - name: Add TELEGRAM_FLEX_BOT_TOKEN + run: echo "TELEGRAM_FLEX_BOT_TOKEN=${{ env.TELEGRAM_FLEX_BOT_TOKEN }}" >> .env + shell: bash + - name: Add TELEGRAM_BOT_API_SECRET_TOKEN + run: echo "TELEGRAM_BOT_API_SECRET_TOKEN=${{ env.TELEGRAM_BOT_API_SECRET_TOKEN }}" >> .env + shell: bash + - name: Add TELEGRAM_STUDIO_FLOW_SID + run: echo "TELEGRAM_STUDIO_FLOW_SID=${{ env.TELEGRAM_STUDIO_FLOW_SID }}" >> .env + shell: bash diff --git a/functions/helpers/customChannels/customChannelToFlex.private.ts b/functions/helpers/customChannels/customChannelToFlex.private.ts index fecdf4b8..e9b387ba 100644 --- a/functions/helpers/customChannels/customChannelToFlex.private.ts +++ b/functions/helpers/customChannels/customChannelToFlex.private.ts @@ -59,8 +59,12 @@ export const findExistingConversation = async ( const existing = conversations.find((conversation) => ['active', 'inactive'].includes(conversation.conversationState), ); - console.log(`Found existing conversation for ${identity}`, existing?.conversationSid, existing); - return existing !== undefined ? (existing.conversationSid as ConversationSid) : undefined; + if (existing) { + console.log(`Found existing conversation for ${identity}`, existing.conversationSid, existing); + return existing.conversationSid as ConversationSid; + } + console.log(`No existing conversation found for ${identity}`); + return undefined; }; /** @@ -181,6 +185,7 @@ export enum AseloCustomChannels { Instagram = 'instagram', Line = 'line', Modica = 'modica', + Telegram = 'telegram', } export const isAseloCustomChannel = (s: unknown): s is AseloCustomChannels => @@ -201,11 +206,11 @@ type CreateFlexChannelParams = { type CreateFlexConversationParams = { studioFlowSid: string; channelType: AseloCustomChannels; // The chat channel being used - twilioNumber: string; // The target Twilio number (usually have the shape :, e.g. twitter:1234567) uniqueUserName: string; // Unique identifier for this user senderScreenName: string; // Friendly info to show to show in the Flex UI (like Twitter handle) onMessageSentWebhookUrl: string; // The url that must be used as the onMessageSent event webhook. conversationFriendlyName: string; // A name for the Flex conversation (typically same as uniqueUserName) + twilioNumber: string; // The target Twilio number (usually have the shape :, e.g. twitter:1234567) }; /** @@ -333,7 +338,7 @@ const createConversation = async ( ...channelAttributes, channel_type: channelType, channelType, - senderScreenName, // TODO: in Twitter this is "twitterUserHandle". Rework that in the UI when we use this + senderScreenName, twilioNumber, }), }); @@ -355,6 +360,13 @@ const createConversation = async ( filters: ['onMessageAdded'], }, }); + + console.log('conversation webhooks:'); + (await conversationContext.webhooks.list()).forEach((wh) => { + Object.entries(wh).forEach(([key, value]) => { + console.log(`${key}:`, value); + }); + }); } catch (err) { return { conversationSid, error: err as Error }; } @@ -370,12 +382,12 @@ type SendMessageToFlexParams = CreateFlexChannelParams & { subscribedExternalId: string; // The id in the external chat system of the user that is subscribed to the webhook }; -type SendConversationMessageToFlexParams = CreateFlexConversationParams & { - syncServiceSid: string; // The Sync Service sid where user channel maps are stored +type SendConversationMessageToFlexParams = Omit & { messageText: string; // The body of the message to send + senderExternalId: string; // The id in the external chat system of the user sending the message - accountSid if not provided messageAttributes?: string; // [optional] The message attributes - senderExternalId: string; // The id in the external chat system of the user sending the message - subscribedExternalId: string; // The id in the external chat system of the user that is subscribed to the webhook + customSubscribedExternalId?: string; // The id in the external chat system of the user that is subscribed to the webhook + customTwilioNumber?: string; // The target Twilio number (usually have the shape :, e.g. twitter:1234567) - will be : if not provided }; /** @@ -473,21 +485,23 @@ export const sendMessageToFlex = async ( * (e.g. if the message is sent by Twitter user 1234567, the uniqueUserName will be 'twitter:1234567') */ export const sendConversationMessageToFlex = async ( - context: Context, + context: Context<{ ACCOUNT_SID: string }>, { studioFlowSid, channelType, - twilioNumber, + customTwilioNumber, uniqueUserName, senderScreenName, onMessageSentWebhookUrl, messageText, messageAttributes = undefined, senderExternalId, - subscribedExternalId, + customSubscribedExternalId, conversationFriendlyName, }: SendConversationMessageToFlexParams, ): Promise<{ status: 'ignored' } | { status: 'sent'; response: any }> => { + const subscribedExternalId = customSubscribedExternalId || context.ACCOUNT_SID; + const twilioNumber = customTwilioNumber || `${channelType}:${subscribedExternalId}`; // Do not send messages that were sent by the receiverId (account subscribed to the webhook), as they were either sent from Flex or from the specific UI of the chat system console.log('=== sendConversationMessageToFlex ==='); if (senderExternalId === subscribedExternalId) { @@ -524,6 +538,11 @@ export const sendConversationMessageToFlex = async ( messageAttributes, }); + console.log('sendConversationMessageToFlex response:'); + Object.entries(response).forEach(([key, value]) => { + console.log(`${key}:`, value); + }); + return { status: 'sent', response }; }; diff --git a/functions/helpers/customChannels/flexToCustomChannel.private.ts b/functions/helpers/customChannels/flexToCustomChannel.private.ts index debeb6a1..eee90404 100644 --- a/functions/helpers/customChannels/flexToCustomChannel.private.ts +++ b/functions/helpers/customChannels/flexToCustomChannel.private.ts @@ -33,12 +33,19 @@ export type ConversationWebhookEvent = { Source: string; }; +export type ExternalSendResult = { + ok: boolean; + meta: Record; + body: any; + resultCode: number; +}; + export type WebhookEvent = ConversationWebhookEvent | ProgrammableChatWebhookEvent; -type Params = { +type Params = { event: T; recipientId: string; - sendExternalMessage: (recipientId: string, messageText: string) => Promise; + sendExternalMessage: (recipientId: string, messageText: string) => Promise; }; export const isConversationWebhookEvent = ( @@ -79,34 +86,30 @@ export const redirectMessageToExternalChat = async ( }; export const redirectConversationMessageToExternalChat = async ( - context: Context<{ CHAT_SERVICE_SID: string }>, - { event, recipientId, sendExternalMessage }: Params, + context: Context, + { event, recipientId, sendExternalMessage }: Params, ): Promise => { const { Body, ConversationSid, EventType, ParticipantSid, Source } = event; - + let shouldSend = false; if (Source === 'SDK') { - const response = await sendExternalMessage(recipientId, Body); - return { status: 'sent', response }; - } - - if (Source === 'API' && EventType === 'onMessageAdded') { + shouldSend = true; + } else if (Source === 'API' && EventType === 'onMessageAdded') { const client = context.getTwilioClient(); const conversation = await client.conversations.conversations(ConversationSid).fetch(); const { attributes } = conversation; - console.log('conversation properties'); - Object.entries(conversation).forEach(([key, value]) => { - console.log(key, value); - }); - const { participantSid } = JSON.parse(attributes); // Redirect bot, system or third participant, but not self - if (participantSid && participantSid !== ParticipantSid) { - const response = await sendExternalMessage(recipientId, Body); + shouldSend = participantSid && participantSid !== ParticipantSid; + } + if (shouldSend) { + const response = await sendExternalMessage(recipientId, Body); + if (response.ok) { return { status: 'sent', response }; } + console.log(`Failed to send message: ${response.resultCode}`, response.body, response.meta); + throw new Error(`Failed to send message: ${response.resultCode}`); } - // This ignores self messages and not supported sources return { status: 'ignored' }; }; diff --git a/functions/webhooks/line/FlexToLine.protected.ts b/functions/webhooks/line/FlexToLine.protected.ts index 894db519..925187db 100644 --- a/functions/webhooks/line/FlexToLine.protected.ts +++ b/functions/webhooks/line/FlexToLine.protected.ts @@ -69,9 +69,10 @@ const sendLineMessage = }); return { - status: response.status, + ok: response.ok, + resultCode: response.status, body: await response.json(), - headers: Object.fromEntries(Object.entries(response.headers)), + meta: Object.fromEntries(Object.entries(response.headers)), }; }; diff --git a/functions/webhooks/line/LineToFlex.ts b/functions/webhooks/line/LineToFlex.ts index a8cdb9f7..a39e8035 100644 --- a/functions/webhooks/line/LineToFlex.ts +++ b/functions/webhooks/line/LineToFlex.ts @@ -31,6 +31,7 @@ import crypto from 'crypto'; import { ChannelToFlex } from '../../helpers/customChannels/customChannelToFlex.private'; type EnvVars = { + ACCOUNT_SID: string; CHAT_SERVICE_SID: string; SYNC_SERVICE_SID: string; LINE_FLEX_FLOW_SID: string; @@ -150,16 +151,14 @@ export const handler = async ( // eslint-disable-next-line no-await-in-loop result = await channelToFlex.sendConversationMessageToFlex(context, { studioFlowSid: context.LINE_STUDIO_FLOW_SID, - syncServiceSid: context.SYNC_SERVICE_SID, conversationFriendlyName: chatFriendlyName, channelType, - twilioNumber, uniqueUserName, senderScreenName, onMessageSentWebhookUrl, messageText, senderExternalId, - subscribedExternalId, + customSubscribedExternalId: subscribedExternalId, }); } else { // eslint-disable-next-line no-await-in-loop diff --git a/functions/webhooks/telegram/FlexToTelegram.protected.ts b/functions/webhooks/telegram/FlexToTelegram.protected.ts new file mode 100644 index 00000000..d5664fcf --- /dev/null +++ b/functions/webhooks/telegram/FlexToTelegram.protected.ts @@ -0,0 +1,130 @@ +/** + * 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 '@twilio-labs/serverless-runtime-types'; +import { Context, ServerlessCallback } from '@twilio-labs/serverless-runtime-types/types'; +import { + responseWithCors, + bindResolve, + error400, + error500, + success, + ResolveFunction, +} from '@tech-matters/serverless-helpers'; + +import { + WebhookEvent, + FlexToCustomChannel, + RedirectResult, + ConversationWebhookEvent, + ExternalSendResult, +} from '../../helpers/customChannels/flexToCustomChannel.private'; + +type EnvVars = { + TELEGRAM_FLEX_BOT_TOKEN: string; +}; + +export type Body = WebhookEvent & { + recipientId: string; // The Telegram id of the user that started the conversation. Provided as query parameter +}; + +const sendTelegramMessage = + ({ TELEGRAM_FLEX_BOT_TOKEN }: Context) => + async (recipientId: string, messageText: string): Promise => { + const telegramSendMessageUrl = `https://api.telegram.org/bot${TELEGRAM_FLEX_BOT_TOKEN}/sendMessage`; + + const payload = { + chat_id: recipientId, + text: messageText, + }; + const response = await fetch(telegramSendMessageUrl, { + method: 'post', + body: JSON.stringify(payload), + headers: { 'Content-Type': 'application/json' }, + }); + + return { + ok: response.ok, + resultCode: response.status, + body: await response.json(), + meta: Object.fromEntries(Object.entries(response.headers)), + }; + }; + +const validateProperties = ( + event: any, + resolveFunc: (f: ResolveFunction) => void, + requiredProperties: string[], +): boolean => { + for (const prop of requiredProperties) { + if (event[prop] === undefined) { + resolveFunc(error400(prop)); + return false; + } + } + return true; +}; + +export const handler = async ( + context: Context, + telegramEvent: ConversationWebhookEvent & { recipientId: string }, + callback: ServerlessCallback, +) => { + console.log('==== FlexToTelegram handler ===='); + console.log('Received event:', telegramEvent); + + const response = responseWithCors(); + const resolve = bindResolve(callback)(response); + + try { + const handlerPath = Runtime.getFunctions()['helpers/customChannels/flexToCustomChannel'].path; + // eslint-disable-next-line global-require,import/no-dynamic-require + const flexToCustomChannel = require(handlerPath) as FlexToCustomChannel; + const requiredProperties: (keyof ConversationWebhookEvent | 'recipientId')[] = [ + 'ConversationSid', + 'Body', + 'Author', + 'EventType', + 'Source', + 'recipientId', + ]; + if (!validateProperties(telegramEvent, resolve, requiredProperties)) return; + const { recipientId, ...event } = telegramEvent; + const result: RedirectResult = + await flexToCustomChannel.redirectConversationMessageToExternalChat(context, { + event, + recipientId, + sendExternalMessage: sendTelegramMessage(context), + }); + + switch (result.status) { + case 'sent': + console.log('Result:', result.status, result.response); + console.log(result.response); + resolve(success(result.response)); + return; + case 'ignored': + console.log('Result:', 'Ignored event.'); + resolve(success('Ignored event.')); + return; + default: + resolve(error500(new Error('Reached unexpected default case'))); + } + } catch (err) { + if (err instanceof Error) resolve(error500(err)); + else resolve(error500(new Error(String(err)))); + } +}; diff --git a/functions/webhooks/telegram/TelegramToFlex.ts b/functions/webhooks/telegram/TelegramToFlex.ts new file mode 100644 index 00000000..0518d88e --- /dev/null +++ b/functions/webhooks/telegram/TelegramToFlex.ts @@ -0,0 +1,127 @@ +/** + * Copyright (C) 2021-2023 Technology Matters + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +/* eslint-disable import/no-dynamic-require */ +/* eslint-disable global-require */ +import '@twilio-labs/serverless-runtime-types'; +import { Context, ServerlessCallback } from '@twilio-labs/serverless-runtime-types/types'; +import { + responseWithCors, + bindResolve, + error500, + error403, + success, +} from '@tech-matters/serverless-helpers'; + +import { ChannelToFlex } from '../../helpers/customChannels/customChannelToFlex.private'; + +type EnvVars = { + CHAT_SERVICE_SID: string; + SYNC_SERVICE_SID: string; + TELEGRAM_STUDIO_FLOW_SID: string; + TELEGRAM_BOT_API_SECRET_TOKEN: string; + TELEGRAM_FLEX_BOT_TOKEN: string; + ACCOUNT_SID: string; +}; + +export type Body = { + message: { + chat: { id: string; first_name: string; username: string }; + text: string; + }; + request: { + headers: Record; + }; +}; + +const TELEGRAM_BOT_API_SECRET_TOKEN_HEADER = 'X-Telegram-Bot-Api-Secret-Token'.toLowerCase(); + +/** + * TODO: Implement your own validation logic + */ +const isValidTelegramPayload = (event: Body, helplineBotApiSecretToken: string): boolean => + Boolean( + helplineBotApiSecretToken === event.request.headers[TELEGRAM_BOT_API_SECRET_TOKEN_HEADER], + ); + +export const handler = async ( + context: Context, + event: Body, + callback: ServerlessCallback, +) => { + console.log('==== TelegramToFlex handler ===='); + console.log('Received event:'); + Object.entries(event).forEach(([key, value]) => { + console.log(`${key}: ${JSON.stringify(value)}`); + }); + if (event.message) { + console.log('Received message:'); + Object.entries(event.message).forEach(([key, value]) => { + console.log(`${key}: ${JSON.stringify(value)}`); + }); + } + const response = responseWithCors(); + const resolve = bindResolve(callback)(response); + + if (!context.TELEGRAM_BOT_API_SECRET_TOKEN) { + const msg = 'TELEGRAM_BOT_API_SECRET_TOKEN is not defined, cannot validate the request'; + console.error(msg); + resolve(error500(new Error(msg))); + } + + if (!isValidTelegramPayload(event, context.TELEGRAM_BOT_API_SECRET_TOKEN)) { + resolve(error403('Forbidden')); + return; + } + try { + const handlerPath = Runtime.getFunctions()['helpers/customChannels/customChannelToFlex'].path; + const channelToFlex = require(handlerPath) as ChannelToFlex; + const { + text: messageText, + chat: { id: senderExternalId, username, first_name: firstName }, + } = event.message; + const channelType = channelToFlex.AseloCustomChannels.Telegram; + const chatFriendlyName = username || `${channelType}:${senderExternalId}`; + const uniqueUserName = `${channelType}:${senderExternalId}`; + const senderScreenName = firstName || username || 'child'; // TODO: how to fetch user Profile Name given its ID (found at 'destination' property) + const onMessageSentWebhookUrl = `https://${context.DOMAIN_NAME}/webhooks/telegram/FlexToTelegram?recipientId=${senderExternalId}`; + const result = await channelToFlex.sendConversationMessageToFlex(context, { + studioFlowSid: context.TELEGRAM_STUDIO_FLOW_SID, + conversationFriendlyName: chatFriendlyName, + channelType, + uniqueUserName, + senderScreenName, + onMessageSentWebhookUrl, + messageText, + senderExternalId, + }); + + switch (result.status) { + case 'sent': + resolve(success(JSON.stringify(result))); + return; + case 'ignored': + resolve(success('Ignored event.')); + return; + default: + resolve(error500(new Error(`Unexpected result status: ${(result as any).status}`))); + } + } catch (err: any) { + // eslint-disable-next-line no-console + console.log(err); + resolve(error500(err)); + } +};