diff --git a/.github/actions/deploy-integrations/action.yml b/.github/actions/deploy-integrations/action.yml index 5a12d0f2d37..43040b87d31 100644 --- a/.github/actions/deploy-integrations/action.yml +++ b/.github/actions/deploy-integrations/action.yml @@ -64,7 +64,6 @@ runs: # shellcheck disable=SC2086 # SENTRY_FILTER contains multiple space-separated -F flags that must be split into individual arguments pnpm -r --stream $SENTRY_FILTER exec sentry-cli sourcemaps upload --release="$GITHUB_SHA" --url-prefix '~' .botpress/dist - name: Deploys Integrations - shell: bash env: INPUT_ENVIRONMENT: ${{ inputs.environment }} INPUT_FORCE: ${{ inputs.force }} @@ -74,6 +73,8 @@ runs: CLOUD_OPS_WORKSPACE_ID: ${{ inputs.cloud_ops_workspace_id }} SENTRY_RELEASE: ${{ github.sha }} SENTRY_ENVIRONMENT: ${{ inputs.environment }} + GH_TOKEN: ${{ github.token }} + shell: bash run: | if [ "$INPUT_ENVIRONMENT" = "staging" ]; then api_url="https://api.botpress.dev" @@ -104,12 +105,24 @@ runs: all_filters="-F '{integrations/*}' $INPUT_EXTRA_FILTER" list_integrations_cmd="pnpm list $all_filters --json" - integration_paths=$(eval "$list_integrations_cmd" | jq -r 'map(".path") | .[]') + integration_paths=$(eval "$list_integrations_cmd" | jq -r 'map(.path) | .[]') for integration_path in $integration_paths; do integration=$(basename "$integration_path") exists=$(./.github/scripts/integration-exists.sh "$integration") + if [ "$integration" == "chat" ]; then + if [ $exists -eq 0 ] || [ $redeploy -eq 1 ]; then + echo -e "\nTriggering ECS deploy for: ### $integration ###\n" + if [ $is_dry_run -eq 0 ]; then + gh workflow run "deploy-chat-${INPUT_ENVIRONMENT}.yml" --field branchOrCommit="${GITHUB_SHA}" + fi + else + echo -e "\nSkipping integration: ### $integration ###\n" + fi + continue + fi + base_command="bp deploy -v -y --noBuild --visibility public --allowDeprecated $dryrun" upload_sandbox_scripts=false diff --git a/.github/actions/deploy-interfaces/action.yml b/.github/actions/deploy-interfaces/action.yml index 24dcd1d0503..3d35f8e4723 100644 --- a/.github/actions/deploy-interfaces/action.yml +++ b/.github/actions/deploy-interfaces/action.yml @@ -1,7 +1,7 @@ name: Deploy Interfaces description: Deploys interfaces -input: +inputs: environment: type: choice description: 'Environment to deploy to' @@ -59,7 +59,7 @@ runs: all_filters="-F '{interfaces/*}' $INPUT_EXTRA_FILTER" list_interfaces_cmd="pnpm list $all_filters --json" - interface_paths=$(eval "$list_interfaces_cmd" | jq -r 'map(".path") | .[]') + interface_paths=$(eval "$list_interfaces_cmd" | jq -r 'map(.path) | .[]') for interface_path in $interface_paths; do interface=$(basename "$interface_path") diff --git a/.github/actions/deploy-plugins/action.yml b/.github/actions/deploy-plugins/action.yml index ef6d153026e..67a002611a5 100644 --- a/.github/actions/deploy-plugins/action.yml +++ b/.github/actions/deploy-plugins/action.yml @@ -1,7 +1,7 @@ name: Deploy Plugins description: Deploys plugins -input: +inputs: environment: type: choice description: 'Environment to deploy to' @@ -59,7 +59,7 @@ runs: all_filters="-F '{plugins/*}' $INPUT_EXTRA_FILTER" list_plugins_cmd="pnpm list $all_filters --json" - plugin_paths=$(eval "$list_plugins_cmd" | jq -r 'map(".path") | .[]') + plugin_paths=$(eval "$list_plugins_cmd" | jq -r 'map(.path) | .[]') for plugin_path in $plugin_paths; do plugin=$(basename "$plugin_path") diff --git a/.github/workflows/deploy-chat-production.yml b/.github/workflows/deploy-chat-production.yml new file mode 100644 index 00000000000..9eec3052b7a --- /dev/null +++ b/.github/workflows/deploy-chat-production.yml @@ -0,0 +1,85 @@ +name: Deploy Chat Production + +on: + workflow_dispatch: + inputs: + branchOrCommit: + description: 'Branch/Commit/Tag to deploy' + required: false + type: string + default: 'master' + skipDeploy: + description: 'Skip deployment to ECS' + required: false + type: boolean + default: false + builder: + description: 'Image Builder' + required: false + type: choice + options: + - docker + - depot + default: 'depot' + +permissions: + id-token: write + contents: write + +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + +jobs: + deploy-chat-production: + runs-on: ubuntu-latest + steps: + - uses: botpress/gh-actions/full-service-deploy@v3.3 + with: + service: chat + repository: chat-integration + dockerfile: integrations/chat/Dockerfile + context: . + builder: ${{ inputs.builder || 'depot' }} + depot-project: ${{ secrets.DEPOT_PROJECT_ID }} + role-ecs-update: botpress_infra_update + skip-ecs-update: ${{ inputs.skipDeploy }} + create-tag: true + ref: ${{ inputs.branchOrCommit || github.sha }} + environment: production + + update-integration-url: + needs: deploy-chat-production + if: ${{ !inputs.skipDeploy }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ inputs.branchOrCommit || github.sha }} + + - name: Setup + uses: ./.github/actions/setup + + - name: Deploy integration with ECS URL + run: | + pnpm bp login -y \ + --api-url "https://api.botpress.cloud" \ + --workspaceId "${{ secrets.PRODUCTION_CLOUD_OPS_WORKSPACE_ID }}" \ + --token "${{ secrets.PRODUCTION_TOKEN_CLOUD_OPS_ACCOUNT }}" + + pnpm -F @botpresshub/chat -c exec -- \ + bp deploy -v -y --noBuild --visibility public --allowDeprecated \ + --url "http://chat.private:9271" + + ping-success: + needs: [deploy-chat-production, update-integration-url] + runs-on: ubuntu-latest + steps: + - run: curl -m 10 --retry 5 ${{ secrets.CHAT_DEPLOY_PRODUCTION_PING_URL }} + + ping-failure: + needs: [deploy-chat-production, update-integration-url] + if: ${{ failure() }} + runs-on: ubuntu-latest + steps: + - run: curl -m 10 --retry 5 ${{ secrets.CHAT_DEPLOY_PRODUCTION_PING_URL }}/fail diff --git a/.github/workflows/deploy-chat-staging.yml b/.github/workflows/deploy-chat-staging.yml new file mode 100644 index 00000000000..5bc0d23948e --- /dev/null +++ b/.github/workflows/deploy-chat-staging.yml @@ -0,0 +1,84 @@ +name: Deploy Chat Staging + +on: + workflow_dispatch: + inputs: + branchOrCommit: + description: 'Branch/Commit/Tag to deploy' + required: false + type: string + default: 'master' + skipDeploy: + description: 'Skip deployment to ECS' + required: false + type: boolean + default: false + builder: + description: 'Image Builder' + required: false + type: choice + options: + - docker + - depot + default: 'depot' + +permissions: + id-token: write + contents: write + +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + +jobs: + deploy-chat-staging: + runs-on: ubuntu-latest + steps: + - uses: botpress/gh-actions/full-service-deploy@v3.3 + with: + service: chat + repository: chat-integration + dockerfile: integrations/chat/Dockerfile + context: . + builder: ${{ inputs.builder || 'depot' }} + depot-project: ${{ secrets.DEPOT_PROJECT_ID }} + role-ecs-update: botpress_infra_update + skip-ecs-update: ${{ inputs.skipDeploy }} + ref: ${{ inputs.branchOrCommit || github.sha }} + environment: staging + + update-integration-url: + needs: deploy-chat-staging + if: ${{ !inputs.skipDeploy }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ inputs.branchOrCommit || github.sha }} + + - name: Setup + uses: ./.github/actions/setup + + - name: Deploy integration with ECS URL + run: | + pnpm bp login -y \ + --api-url "https://api.botpress.dev" \ + --workspaceId "${{ secrets.STAGING_CLOUD_OPS_WORKSPACE_ID }}" \ + --token "${{ secrets.STAGING_TOKEN_CLOUD_OPS_ACCOUNT }}" + + pnpm -F @botpresshub/chat -c exec -- \ + bp deploy -v -y --noBuild --visibility public --allowDeprecated \ + --url "http://chat.private:9271" + + ping-success: + needs: [deploy-chat-staging, update-integration-url] + runs-on: ubuntu-latest + steps: + - run: curl -m 10 --retry 5 ${{ secrets.CHAT_DEPLOY_STAGING_PING_URL }} + + ping-failure: + needs: [deploy-chat-staging, update-integration-url] + if: ${{ failure() }} + runs-on: ubuntu-latest + steps: + - run: curl -m 10 --retry 5 ${{ secrets.CHAT_DEPLOY_STAGING_PING_URL }}/fail diff --git a/.github/workflows/deploy-integrations-production.yml b/.github/workflows/deploy-integrations-production.yml index a3ec940e26f..2836abd4a25 100644 --- a/.github/workflows/deploy-integrations-production.yml +++ b/.github/workflows/deploy-integrations-production.yml @@ -12,6 +12,7 @@ on: permissions: id-token: write contents: read + actions: write jobs: deploy-production: diff --git a/.github/workflows/deploy-integrations-staging.yml b/.github/workflows/deploy-integrations-staging.yml index fa4eae32e3c..1268deb261c 100644 --- a/.github/workflows/deploy-integrations-staging.yml +++ b/.github/workflows/deploy-integrations-staging.yml @@ -19,6 +19,7 @@ on: permissions: id-token: write contents: read + actions: write jobs: deploy-staging: diff --git a/.github/workflows/docker-chat.yml b/.github/workflows/docker-chat.yml index 11766eb70ee..0fd40367b13 100644 --- a/.github/workflows/docker-chat.yml +++ b/.github/workflows/docker-chat.yml @@ -1,4 +1,4 @@ -name: Build and push Chat Integration Docker +name: Check Chat Integration Docker on: pull_request: @@ -8,21 +8,6 @@ on: - 'integrations/chat/**' - 'packages/sdk/**' - push: - branches: - - master - paths: - - 'integrations/chat/**' - - 'packages/sdk/**' - - workflow_dispatch: - inputs: - push_to_ecr: - description: 'Push image to ECR after successful tests' - required: true - type: boolean - default: true - permissions: id-token: write contents: read @@ -32,55 +17,11 @@ concurrency: cancel-in-progress: false jobs: - build-test-push: + build-and-test: runs-on: depot-ubuntu-latest steps: - uses: actions/checkout@v4 - - name: Setup # FIXME: This should not be necessary, as the Dockerfile should be self-contained - uses: ./.github/actions/setup - with: - extra_filters: '-F @botpresshub/chat' - - - uses: aws-actions/configure-aws-credentials@v3 - if: ${{ github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.push_to_ecr) }} - with: - role-session-name: container_pusher - role-to-assume: arn:aws:iam::986677156374:role/actions/build/container_pusher - aws-region: us-east-1 - - - uses: aws-actions/amazon-ecr-login@v1 - if: ${{ github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.push_to_ecr) }} - id: ecr - with: - mask-password: true - - - uses: docker/metadata-action@v4 - if: ${{ github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.push_to_ecr) }} - id: meta - with: - images: ${{ steps.ecr.outputs.registry }}/chat-integration - flavor: | - latest=false - tags: | - type=sha,prefix=,format=long - - - name: Set BUILD_DATE - id: meta_date - run: | - export TZ=America/Toronto - echo "timestamp=$(date +"%Y-%m-%d %H:%M:%S")" >> "$GITHUB_OUTPUT" - - - name: Create ECR Registry - if: ${{ github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.push_to_ecr) }} - env: - ECR_REPOSITORY: chat-integration - run: | - aws --version - aws ecr create-repository --repository-name "$ECR_REPOSITORY" || true - aws ssm get-parameter --name '/cloud/container-registry/ecr-policy-document' --query 'Parameter.Value' | jq -r '.' > repository-policy.json - aws ecr set-repository-policy --repository-name "$ECR_REPOSITORY" --policy-text file://repository-policy.json &> /dev/null - - name: Set up Depot CLI uses: depot/setup-action@v1 @@ -90,13 +31,11 @@ jobs: project: ${{ secrets.DEPOT_PROJECT_ID }} build-args: | MINIFY=true - BUILD_DATE=${{ steps.meta_date.outputs.timestamp }} file: ./integrations/chat/Dockerfile context: . push: false load: true tags: chat-integration:test - labels: ${{ steps.meta.outputs.labels }} - name: Start Docker container run: | @@ -148,17 +87,3 @@ jobs: - name: Cleanup container if: always() run: docker rm -f chat-test || true - - - name: Tag and push to ECR - if: ${{ github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.push_to_ecr) }} - env: - ECR_REGISTRY: ${{ steps.ecr.outputs.registry }} - IMAGE_TAG: ${{ github.sha }} - run: | - echo "Tagging image for ECR: $ECR_REGISTRY/chat-integration:$IMAGE_TAG" - docker tag chat-integration:test "$ECR_REGISTRY/chat-integration:$IMAGE_TAG" - - echo "Pushing to ECR..." - docker push "$ECR_REGISTRY/chat-integration:$IMAGE_TAG" - - echo "Successfully pushed image: $IMAGE_TAG" diff --git a/integrations/chat/Dockerfile b/integrations/chat/Dockerfile index ab63686edca..4ff1bf92a46 100644 --- a/integrations/chat/Dockerfile +++ b/integrations/chat/Dockerfile @@ -24,11 +24,15 @@ COPY ./tsconfig.json /usr/app/tsconfig.json COPY integrations/chat ./integrations/chat COPY patches/source-map-js@1.2.1.patch patches/source-map-js@1.2.1.patch -# Use NODE_ENV to avoid installing devDependencies -ENV NODE_ENV=production - # install RUN pnpm install --frozen-lockfile + +# generate +RUN pnpm --filter @botpress/client run generate +RUN pnpm --filter @botpress/chat run generate +RUN pnpm --filter @botpresshub/chat run generate + +# build RUN pnpm --filter @bpinternal/zui run build RUN pnpm --filter @botpress/chat run build RUN pnpm --filter @botpress/client run build @@ -37,7 +41,6 @@ RUN pnpm --filter @botpress/cognitive run build RUN pnpm --filter @botpress/cli run bundle RUN pnpm --filter @botpresshub/chat run build - FROM node:${NODE_VERSION}-bullseye-slim AS deploy COPY --from=base /usr/app/integrations/chat/.botpress/dist/index.cjs ./index.cjs diff --git a/integrations/whatsapp/integration.definition.ts b/integrations/whatsapp/integration.definition.ts index 2a5aa74d88f..f782dfbd430 100644 --- a/integrations/whatsapp/integration.definition.ts +++ b/integrations/whatsapp/integration.definition.ts @@ -46,6 +46,14 @@ const commonConfigSchema = z.object({ .describe( 'Expiry time in hours for downloaded media files. An expiry time of 0 means the files will never expire.' ), + listenMessageEchoes: z + .boolean() + .default(false) + .optional() + .title('Listen Message Echoes') + .describe( + 'When enabled, triggers an onMessageEchoReceived event when an external message is echoed via the webhook.' + ), }) const dropdownButtonLabelSchema = z @@ -149,7 +157,7 @@ const defaultBotPhoneNumberId = { } export const INTEGRATION_NAME = 'whatsapp' -export const INTEGRATION_VERSION = '4.12.2' +export const INTEGRATION_VERSION = '4.13.0' export default new IntegrationDefinition({ name: INTEGRATION_NAME, version: INTEGRATION_VERSION, @@ -330,6 +338,10 @@ export default new IntegrationDefinition({ title: 'Referral Source ID', description: 'The ID of the ad or content that led to the conversation', }, + echoCreationType: { + title: 'Echo Creation Type', + description: 'For echoed messages: the creation type reported by WhatsApp (e.g. "created_by_1p_bot")', + }, }, }, conversation: { @@ -356,6 +368,10 @@ export default new IntegrationDefinition({ title: 'Name', description: 'WhatsApp user display name', }, + number: { + title: 'Phone Number', + description: 'WhatsApp phone number of the user', + }, }, }, actions: { @@ -499,6 +515,12 @@ export default new IntegrationDefinition({ description: 'Triggered when a user reads a message', schema: z.object({}), }, + onMessageEchoReceived: { + title: 'Message Echo Received', + description: + 'Triggered when an outbound message sent through another channel (e.g. a human agent) is echoed back via the webhook', + schema: z.object({}), + }, reactionAdded: { title: 'Reaction Added', description: 'Triggered when a user adds a reaction to a message', diff --git a/integrations/whatsapp/src/misc/types.ts b/integrations/whatsapp/src/misc/types.ts index afb55a9dff5..4c441f11141 100644 --- a/integrations/whatsapp/src/misc/types.ts +++ b/integrations/whatsapp/src/misc/types.ts @@ -286,11 +286,30 @@ export const WhatsAppTemplateCategoryUpdateValueSchema = z.object({ new_category: z.string().optional(), }) +const WhatsAppEchoMessageSchema = WhatsAppMessageSchema.and( + z.object({ to: z.string(), message_creation_type: z.string() }) +) +export type WhatsAppEchoMessage = z.infer + +const WhatsAppMessageEchoValueSchema = z.object({ + messaging_product: z.literal('whatsapp'), + metadata: z.object({ + display_phone_number: z.string(), + phone_number_id: z.string(), + }), + message_echoes: z.array(WhatsAppEchoMessageSchema), +}) +export type WhatsAppMessageEchoValue = z.infer + const WhatsAppChangesSchema = z.discriminatedUnion('field', [ z.object({ field: z.literal('messages'), value: WhatsAppMessageValueSchema, }), + z.object({ + field: z.literal('smb_message_echoes'), + value: WhatsAppMessageEchoValueSchema, + }), z.object({ field: z.literal('message_template_components_update'), value: WhatsAppMessageTemplateComponentsUpdateValueSchema, diff --git a/integrations/whatsapp/src/webhook/handler.ts b/integrations/whatsapp/src/webhook/handler.ts index 021116fb0ab..8d055223329 100644 --- a/integrations/whatsapp/src/webhook/handler.ts +++ b/integrations/whatsapp/src/webhook/handler.ts @@ -2,6 +2,7 @@ import { Request } from '@botpress/sdk' import * as crypto from 'crypto' import { getClientSecret } from '../auth' import { WhatsAppPayload, WhatsAppPayloadSchema } from '../misc/types' +import { echoHandler } from './handlers/echo' import { messagesHandler } from './handlers/messages' import { oauthCallbackHandler } from './handlers/oauth' import { reactionHandler } from './handlers/reaction' @@ -56,6 +57,16 @@ const _handler: bp.IntegrationProps['handler'] = async (props: bp.HandlerProps) } switch (changes.field) { + case 'smb_message_echoes': + for (const echo of changes.value.message_echoes) { + try { + await echoHandler(echo, changes.value, props) + } catch (thrown: unknown) { + const errMsg = thrown instanceof Error ? thrown.message : 'Unknown error thrown' + logger.forBot().error(`Failed to process WhatsApp echo event: ${errMsg}`) + } + } + break case 'messages': for (const message of changes.value.messages ?? []) { if (message.type === 'reaction') { diff --git a/integrations/whatsapp/src/webhook/handlers/echo.ts b/integrations/whatsapp/src/webhook/handlers/echo.ts new file mode 100644 index 00000000000..5a00662b982 --- /dev/null +++ b/integrations/whatsapp/src/webhook/handlers/echo.ts @@ -0,0 +1,69 @@ +import { safeFormatPhoneNumber } from '../../misc/phone-number-to-whatsapp' +import { WhatsAppMessageEchoValue, WhatsAppEchoMessage } from '../../misc/types' +import { _handleMessage } from './messages' +import * as bp from '.botpress' + +export const echoHandler = async ( + echo: WhatsAppEchoMessage, + value: WhatsAppMessageEchoValue, + props: bp.HandlerProps +) => { + const { ctx, client, logger } = props + + const formatPhoneNumberResponse = safeFormatPhoneNumber(echo.to) + if (formatPhoneNumberResponse.success === false) { + logger + .forBot() + .error(`Failed to parse recipient phone number "${echo.to}": ${formatPhoneNumberResponse.error.message}`) + } + const userPhone = formatPhoneNumberResponse.success ? formatPhoneNumberResponse.phoneNumber : echo.to + + const { conversation } = await client.getOrCreateConversation({ + channel: 'channel', + tags: { + userPhone, + botPhoneNumberId: value.metadata.phone_number_id, + }, + }) + + const { user } = await client.getOrCreateUser({ + tags: { number: echo.from }, + name: echo.from, + discriminateByTags: ['number'], + }) + + const newMessage = await _handleMessage({ + message: echo, + conversationId: conversation.id, + userId: user.id, + ctx, + client, + logger, + tags: { id: echo.id, echoCreationType: echo.message_creation_type }, + createMessageOverride: async ({ type, payload }) => { + const { messages } = await client._inner.importMessages({ + messages: [ + { + type, + payload, + userId: user.id, + conversationId: conversation.id, + tags: { id: echo.id, echoCreationType: echo.message_creation_type }, + createdAt: new Date(parseInt(echo.timestamp) * 1000).toISOString(), + discriminateByTags: ['id'], + }, + ], + }) + return messages[0] ? { message: messages[0] } : undefined + }, + }) + + if (newMessage && ctx.configuration.listenMessageEchoes) { + await client.createEvent({ + type: 'onMessageEchoReceived', + conversationId: conversation.id, + messageId: newMessage.message.id, + payload: {}, + }) + } +} diff --git a/integrations/whatsapp/src/webhook/handlers/messages.ts b/integrations/whatsapp/src/webhook/handlers/messages.ts index f2b32f388d9..943008bc1dc 100644 --- a/integrations/whatsapp/src/webhook/handlers/messages.ts +++ b/integrations/whatsapp/src/webhook/handlers/messages.ts @@ -17,6 +17,21 @@ type IncomingMessages = { } } +type CreateMessageArgs = ValueOf & { incomingMessageType?: string } +type CreateMessageFn = (args: CreateMessageArgs) => Promise<{ message: { id: string } } | undefined> + +export type HandleMessageArgs = { + message: WhatsAppMessage + conversationId: string + userId: string + ctx: bp.Context + client: bp.Client + logger: bp.Logger + tags: Record + origin?: 'synthetic' + createMessageOverride?: CreateMessageFn +} + export const messagesHandler = async ( message: NonNullable[number], value: WhatsAppMessageValue, @@ -27,18 +42,7 @@ export const messagesHandler = async ( const whatsapp = await getAuthenticatedWhatsappClient(client, ctx) const phoneNumberId = value.metadata.phone_number_id await whatsapp.markAsRead(phoneNumberId, message.id) - await _handleIncomingMessage(message, value, ctx, client, logger) - - return { status: 200 } -} -async function _handleIncomingMessage( - message: WhatsAppMessage, - value: WhatsAppMessageValue, - ctx: bp.Context, - client: bp.Client, - logger: bp.Logger -) { const formatPhoneNumberResponse = safeFormatPhoneNumber(message.from) if (formatPhoneNumberResponse.success === false) { const distinctId = formatPhoneNumberResponse.error.id @@ -79,27 +83,6 @@ async function _handleIncomingMessage( name: contact?.profile.name, }) - const createMessage = async ({ - type, - payload, - incomingMessageType, - replyTo, - }: ValueOf & { incomingMessageType?: string; replyTo?: string }) => { - logger.forBot().debug(`Received ${incomingMessageType ?? type} message from WhatsApp:`, payload) - return client.getOrCreateMessage({ - tags: { - id: message.id, - replyTo, - ..._processReferralTags(message, logger), - }, - type, - payload, - userId: user.id, - conversationId: conversation.id, - discriminateByTags: ['id'], - }) - } - const replyToWhatsAppId = message.context?.id const replyToMessage = replyToWhatsAppId ? await getMessageFromWhatsappMessageId(replyToWhatsAppId, client) @@ -115,67 +98,94 @@ async function _handleIncomingMessage( } const replyTo = replyToMessage?.id + await _handleMessage({ + message, + conversationId: conversation.id, + userId: user.id, + ctx, + client, + logger, + tags: { + id: message.id, + ...(replyTo && { replyTo }), + ..._processReferralTags(message, logger), + }, + }) +} + +export async function _handleMessage(args: HandleMessageArgs) { + const { message, conversationId, userId, ctx, client, logger, tags, origin } = args + + const _createMessage: CreateMessageFn = + args.createMessageOverride ?? + (async ({ type, payload, incomingMessageType }) => { + logger.forBot().debug(`Received ${incomingMessageType ?? type} message from WhatsApp:`, payload) + return client.getOrCreateMessage({ + tags, + type, + payload, + userId, + conversationId, + discriminateByTags: ['id'], + origin, + }) + }) + const { type } = message if (type === 'text') { - await createMessage({ type, payload: { text: message.text.body }, replyTo }) + return _createMessage({ type, payload: { text: message.text.body } }) } else if (type === 'button') { - await createMessage({ + return _createMessage({ type: 'text', payload: { value: message.button.payload, text: message.button.text, }, - replyTo, }) } else if (type === 'location') { const { latitude, longitude, address, name } = message.location - await createMessage({ + return _createMessage({ type, payload: { latitude: Number(latitude), longitude: Number(longitude), title: name, address }, - replyTo, }) } else if (type === 'image') { const imageUrl = await _getOrDownloadWhatsappMedia(message.image.id, client, ctx) - await createMessage({ + return _createMessage({ type, payload: { imageUrl, ...(message.image.caption && { caption: message.image.caption }), }, - replyTo, }) } else if (type === 'sticker') { const stickerUrl = await _getOrDownloadWhatsappMedia(message.sticker.id, client, ctx) - await createMessage({ type: 'image', payload: { imageUrl: stickerUrl }, replyTo }) + return _createMessage({ type: 'image', payload: { imageUrl: stickerUrl } }) } else if (type === 'audio') { const audioUrl = await _getOrDownloadWhatsappMedia(message.audio.id, client, ctx) - await createMessage({ type, payload: { audioUrl }, replyTo }) + return _createMessage({ type, payload: { audioUrl } }) } else if (type === 'document') { const documentUrl = await _getOrDownloadWhatsappMedia(message.document.id, client, ctx) - await createMessage({ + return _createMessage({ type: 'file', payload: { fileUrl: documentUrl, filename: message.document.filename }, - replyTo, }) } else if (type === 'video') { const videoUrl = await _getOrDownloadWhatsappMedia(message.video.id, client, ctx) - await createMessage({ type, payload: { videoUrl }, replyTo }) + return _createMessage({ type, payload: { videoUrl } }) } else if (message.type === 'interactive') { if (message.interactive.type === 'button_reply') { const { id: value, title: text } = message.interactive.button_reply - await createMessage({ + return _createMessage({ type: 'text', payload: { value, text }, incomingMessageType: type, - replyTo, }) } else if (message.interactive.type === 'list_reply') { const { id: value, title: text } = message.interactive.list_reply - await createMessage({ + return _createMessage({ type: 'text', payload: { value, text }, incomingMessageType: type, - replyTo, }) } } else if (message.type === 'unsupported' || message.type === 'unknown') { @@ -184,6 +194,7 @@ async function _handleIncomingMessage( } else { logger.forBot().warn(`Unhandled message type ${type}: ${JSON.stringify(message)}`) } + return undefined } async function _getOrDownloadWhatsappMedia(whatsappMediaId: string, client: bp.Client, ctx: bp.Context) { diff --git a/integrations/whatsapp/src/webhook/handlers/oauth/wizard.ts b/integrations/whatsapp/src/webhook/handlers/oauth/wizard.ts index 06cddd54ebb..36ead28efbb 100644 --- a/integrations/whatsapp/src/webhook/handlers/oauth/wizard.ts +++ b/integrations/whatsapp/src/webhook/handlers/oauth/wizard.ts @@ -81,7 +81,8 @@ const _setupHandler: WizardHandler = async (props) => { '&config_id=' + bp.secrets.OAUTH_CONFIG_ID + '&override_default_response_type=true' + - '&response_type=code' + '&response_type=code' + + '&extras={"featureType":"whatsapp_business_app_onboarding","sessionInfoVersion":"3","version":"v3","features":[]}' ) } diff --git a/services.json b/services.json new file mode 100644 index 00000000000..a69c769558e --- /dev/null +++ b/services.json @@ -0,0 +1,7 @@ +{ + "chat": { + "repository": "chat-integration", + "cluster": "Cloud-Chat-ServiceCluster", + "services": ["Cloud-Chat-Service"] + } +}