From c0410906753a06e9d372da42906b55ddd7edc78f Mon Sep 17 00:00:00 2001 From: Gabriel Jablonski Date: Thu, 13 Nov 2025 20:42:08 -0300 Subject: [PATCH] feat(baileys): upgrade to v7 (#143) * feat(baileys): upgrade to v7 (#139) * feat(baileys): v7 * chore(zapi): match baileys changes * test: fix send on whatsapp spec * chore: simplify logic and minor fixes * fix: ensure contact avatar updates when profile picture changes * chore: add beta release workflow * feat: enhance contact update logic to handle conflicts in inbox updates (#140) * feat: update GitHub Actions workflow to trigger on release events and adjust GIT_REF handling * fix: prevent unique constraint error when LID contact inbox already exists (#141) * fix: prevent unique constraint error when LID contact inbox already exists * feat(baileys): handle ephemeral messages --- .../workflows/publish_ee_github_docker.yml | 17 +- .github/workflows/publish_github_docker.yml | 17 +- .../workflows/publish_github_docker_beta.yml | 139 ++++++++ app/models/channel/whatsapp.rb | 14 +- .../whatsapp/baileys_handlers/helpers.rb | 88 +++-- .../baileys_handlers/messages_upsert.rb | 61 +++- .../providers/whatsapp_baileys_service.rb | 31 +- .../providers/whatsapp_zapi_service.rb | 4 +- .../whatsapp/send_on_whatsapp_service.rb | 10 +- .../whatsapp/zapi_handlers/helpers.rb | 6 +- spec/models/channel/whatsapp_spec.rb | 58 +++- .../baileys_handlers/messages_upsert_spec.rb | 319 ++++++++++++++++++ .../incoming_message_baileys_service_spec.rb | 142 ++++---- .../whatsapp_baileys_service_spec.rb | 20 +- .../providers/whatsapp_zapi_service_spec.rb | 4 +- .../whatsapp/send_on_whatsapp_service_spec.rb | 3 +- .../zapi_handlers/received_callback_spec.rb | 2 +- 17 files changed, 766 insertions(+), 169 deletions(-) create mode 100644 .github/workflows/publish_github_docker_beta.yml create mode 100644 spec/services/whatsapp/baileys_handlers/messages_upsert_spec.rb diff --git a/.github/workflows/publish_ee_github_docker.yml b/.github/workflows/publish_ee_github_docker.yml index 3d390333f..4bf5d9fce 100644 --- a/.github/workflows/publish_ee_github_docker.yml +++ b/.github/workflows/publish_ee_github_docker.yml @@ -5,9 +5,8 @@ permissions: packages: write on: - push: - tags: - - '*' + release: + types: [released] workflow_dispatch: env: @@ -25,10 +24,12 @@ jobs: runner: ubuntu-22.04-arm runs-on: ${{ matrix.runner }} env: - GIT_REF: ${{ github.head_ref || github.ref_name || github.sha }} + GIT_REF: ${{ github.event.release.tag_name || github.ref_name }} steps: - name: Checkout uses: actions/checkout@v4 + with: + ref: ${{ github.event.release.tag_name || github.ref }} - name: Prepare run: | @@ -61,7 +62,7 @@ jobs: uses: docker/setup-buildx-action@v3 - name: Login to GitHub Container Registry - if: github.event_name == 'push' || github.event_name == 'workflow_dispatch' + if: github.event_name == 'release' || github.event_name == 'workflow_dispatch' uses: docker/login-action@v3 with: registry: ghcr.io @@ -75,7 +76,7 @@ jobs: context: . file: docker/Dockerfile platforms: ${{ matrix.platform }} - push: ${{ github.event_name == 'push' || github.event_name == 'workflow_dispatch' }} + push: ${{ github.event_name == 'release' || github.event_name == 'workflow_dispatch' }} tags: | ${{ env.GITHUB_REPO }}:${{ env.SANITIZED_REF }}-ee ${{ env.GITHUB_REPO }}:latest-ee @@ -119,7 +120,7 @@ jobs: - name: Create manifest list and push working-directory: ${{ runner.temp }}/digests env: - GIT_REF: ${{ github.head_ref || github.ref_name || github.sha }} + GIT_REF: ${{ github.event.release.tag_name || github.ref_name }} run: | SANITIZED_REF=$(echo "$GIT_REF" | sed 's/\//-/g') docker buildx imagetools create \ @@ -129,7 +130,7 @@ jobs: - name: Inspect image env: - GIT_REF: ${{ github.head_ref || github.ref_name || github.sha }} + GIT_REF: ${{ github.event.release.tag_name || github.ref_name }} run: | SANITIZED_REF=$(echo "$GIT_REF" | sed 's/\//-/g') REPO="ghcr.io/${{ github.repository }}" diff --git a/.github/workflows/publish_github_docker.yml b/.github/workflows/publish_github_docker.yml index 60ebab774..2aeaf6366 100644 --- a/.github/workflows/publish_github_docker.yml +++ b/.github/workflows/publish_github_docker.yml @@ -5,9 +5,8 @@ permissions: packages: write on: - push: - tags: - - '*' + release: + types: [released] workflow_dispatch: env: @@ -25,10 +24,12 @@ jobs: runner: ubuntu-22.04-arm runs-on: ${{ matrix.runner }} env: - GIT_REF: ${{ github.head_ref || github.ref_name }} + GIT_REF: ${{ github.event.release.tag_name || github.ref_name }} steps: - name: Checkout uses: actions/checkout@v4 + with: + ref: ${{ github.event.release.tag_name || github.ref }} - name: Prepare run: | @@ -62,7 +63,7 @@ jobs: uses: docker/setup-buildx-action@v3 - name: Login to GitHub Container Registry - if: github.event_name == 'push' || github.event_name == 'workflow_dispatch' + if: github.event_name == 'release' || github.event_name == 'workflow_dispatch' uses: docker/login-action@v3 with: registry: ghcr.io @@ -76,7 +77,7 @@ jobs: context: . file: docker/Dockerfile platforms: ${{ matrix.platform }} - push: ${{ github.event_name == 'push' || github.event_name == 'workflow_dispatch' }} + push: ${{ github.event_name == 'release' || github.event_name == 'workflow_dispatch' }} tags: | ${{ env.GITHUB_REPO }}:${{ env.SANITIZED_REF }} ${{ env.GITHUB_REPO }}:latest @@ -120,7 +121,7 @@ jobs: - name: Create manifest list and push working-directory: ${{ runner.temp }}/digests env: - GIT_REF: ${{ github.head_ref || github.ref_name }} + GIT_REF: ${{ github.event.release.tag_name || github.ref_name }} run: | SANITIZED_REF=$(echo "$GIT_REF" | sed 's/\//-/g') docker buildx imagetools create \ @@ -130,7 +131,7 @@ jobs: - name: Inspect image env: - GIT_REF: ${{ github.head_ref || github.ref_name }} + GIT_REF: ${{ github.event.release.tag_name || github.ref_name }} run: | SANITIZED_REF=$(echo "$GIT_REF" | sed 's/\//-/g') REPO="ghcr.io/${{ github.repository }}" diff --git a/.github/workflows/publish_github_docker_beta.yml b/.github/workflows/publish_github_docker_beta.yml new file mode 100644 index 000000000..7bc537ed2 --- /dev/null +++ b/.github/workflows/publish_github_docker_beta.yml @@ -0,0 +1,139 @@ +name: Publish Chatwoot beta docker images to GitHub + +permissions: + contents: read + packages: write + +on: + release: + types: [prereleased] + workflow_dispatch: + +env: + GITHUB_REPO: ghcr.io/${{ github.repository }} + +jobs: + build: + strategy: + fail-fast: false + matrix: + include: + - platform: linux/amd64 + runner: ubuntu-latest + - platform: linux/arm64 + runner: ubuntu-22.04-arm + runs-on: ${{ matrix.runner }} + env: + GIT_REF: ${{ github.event.release.tag_name || github.ref_name }} + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + ref: ${{ github.event.release.tag_name || github.ref }} + + - name: Prepare + run: | + platform=${{ matrix.platform }} + echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV + + - name: Strip enterprise code + run: | + rm -rf enterprise + rm -rf spec/enterprise + + - name: Update version in app.yml + run: | + if [[ "$GIT_REF" =~ ^v(.+)$ ]]; then + VERSION="${BASH_REMATCH[1]}" + echo "Updating version to: $VERSION" + sed -i "s/version: '.*'/version: '$VERSION'/" config/app.yml + else + echo "No version tag found, keeping existing version" + fi + + - name: Set Docker Tags + run: | + SANITIZED_REF=$(echo "$GIT_REF" | sed 's/\//-/g') + echo "SANITIZED_REF=${SANITIZED_REF}" >> $GITHUB_ENV + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to GitHub Container Registry + if: github.event_name == 'release' || github.event_name == 'workflow_dispatch' + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push to GitHub Container Registry + id: build-ghcr + uses: docker/build-push-action@v6 + with: + context: . + file: docker/Dockerfile + platforms: ${{ matrix.platform }} + push: ${{ github.event_name == 'release' || github.event_name == 'workflow_dispatch' }} + tags: | + ${{ env.GITHUB_REPO }}:${{ env.SANITIZED_REF }} + ${{ env.GITHUB_REPO }}:beta + + - name: Export digest + run: | + mkdir -p ${{ runner.temp }}/digests + digest="${{ steps.build-ghcr.outputs.digest }}" + touch "${{ runner.temp }}/digests/${digest#sha256:}" + + - name: Upload digest + uses: actions/upload-artifact@v4 + with: + name: digests-${{ env.PLATFORM_PAIR }} + path: ${{ runner.temp }}/digests/* + if-no-files-found: error + retention-days: 1 + + merge: + runs-on: ubuntu-latest + needs: + - build + steps: + - name: Download digests + uses: actions/download-artifact@v4 + with: + path: ${{ runner.temp }}/digests + pattern: digests-* + merge-multiple: true + + - name: Login to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Create manifest list and push + working-directory: ${{ runner.temp }}/digests + env: + GIT_REF: ${{ github.event.release.tag_name || github.ref_name }} + run: | + SANITIZED_REF=$(echo "$GIT_REF" | sed 's/\//-/g') + docker buildx imagetools create \ + -t ghcr.io/${{ github.repository }}:${SANITIZED_REF} \ + -t ghcr.io/${{ github.repository }}:beta \ + $(printf 'ghcr.io/${{ github.repository }}@sha256:%s ' *) + + - name: Inspect image + env: + GIT_REF: ${{ github.event.release.tag_name || github.ref_name }} + run: | + SANITIZED_REF=$(echo "$GIT_REF" | sed 's/\//-/g') + REPO="ghcr.io/${{ github.repository }}" + docker buildx imagetools inspect ${REPO}:${SANITIZED_REF} + docker buildx imagetools inspect ${REPO}:beta diff --git a/app/models/channel/whatsapp.rb b/app/models/channel/whatsapp.rb index adc220229..7c1bb344e 100644 --- a/app/models/channel/whatsapp.rb +++ b/app/models/channel/whatsapp.rb @@ -86,8 +86,9 @@ class Channel::Whatsapp < ApplicationRecord def toggle_typing_status(typing_status, conversation:) return unless provider_service.respond_to?(:toggle_typing_status) + recipient_id = conversation.contact.identifier || conversation.contact.phone_number last_message = conversation.messages.last - provider_service.toggle_typing_status(typing_status, last_message: last_message, phone_number: conversation.contact.phone_number) + provider_service.toggle_typing_status(typing_status, last_message: last_message, recipient_id: recipient_id) end def update_presence(status) @@ -101,7 +102,13 @@ class Channel::Whatsapp < ApplicationRecord # NOTE: This is the default behavior, so `mark_as_read` being `nil` is the same as `true`. return if provider_config&.dig('mark_as_read') == false - provider_service.read_messages(messages, phone_number: conversation.contact.phone_number) + recipient_id = if provider == 'zapi' + conversation.contact.phone_number + else + conversation.contact.identifier || conversation.contact.phone_number + end + + provider_service.read_messages(messages, recipient_id: recipient_id) end def unread_conversation(conversation) @@ -122,7 +129,8 @@ class Channel::Whatsapp < ApplicationRecord def received_messages(messages, conversation) return unless provider_service.respond_to?(:received_messages) - provider_service.received_messages(conversation.contact.phone_number, messages) + recipient_id = conversation.contact.identifier || conversation.contact.phone_number + provider_service.received_messages(recipient_id, messages) end def on_whatsapp(phone_number) diff --git a/app/services/whatsapp/baileys_handlers/helpers.rb b/app/services/whatsapp/baileys_handlers/helpers.rb index 0c6a1d70f..64faf806c 100644 --- a/app/services/whatsapp/baileys_handlers/helpers.rb +++ b/app/services/whatsapp/baileys_handlers/helpers.rb @@ -3,14 +3,12 @@ module Whatsapp::BaileysHandlers::Helpers # rubocop:disable Metrics/ModuleLength private - def raw_message_id - @raw_message[:key][:id] + def unwrap_ephemeral_message(msg) + msg.key?(:ephemeralMessage) ? msg.dig(:ephemeralMessage, :message) : msg end - def sender_lid - return @raw_message[:key][:senderLid] if @raw_message[:key].key?(:senderLid) - - @raw_message[:key][:remoteJid] if jid_type == 'lid' + def raw_message_id + @raw_message[:key][:id] end def incoming? @@ -42,7 +40,7 @@ module Whatsapp::BaileysHandlers::Helpers # rubocop:disable Metrics/ModuleLength end def message_type # rubocop:disable Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity,Metrics/MethodLength,Metrics/AbcSize - msg = @raw_message[:message] + msg = unwrap_ephemeral_message(@raw_message[:message]) if msg.key?(:conversation) || msg.dig(:extendedTextMessage, :text).present? 'text' elsif msg.key?(:imageMessage) @@ -72,22 +70,23 @@ module Whatsapp::BaileysHandlers::Helpers # rubocop:disable Metrics/ModuleLength end def message_content # rubocop:disable Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity,Metrics/MethodLength + msg = unwrap_ephemeral_message(@raw_message[:message]) case message_type when 'text' - @raw_message.dig(:message, :conversation) || @raw_message.dig(:message, :extendedTextMessage, :text) + msg[:conversation] || msg.dig(:extendedTextMessage, :text) when 'image' - @raw_message.dig(:message, :imageMessage, :caption) + msg.dig(:imageMessage, :caption) when 'video' - @raw_message.dig(:message, :videoMessage, :caption) + msg.dig(:videoMessage, :caption) when 'file' - @raw_message.dig(:message, :documentMessage, :caption).presence || - @raw_message.dig(:message, :documentWithCaptionMessage, :message, :documentMessage, :caption) + msg.dig(:documentMessage, :caption).presence || + msg.dig(:documentWithCaptionMessage, :message, :documentMessage, :caption) when 'reaction' - @raw_message.dig(:message, :reactionMessage, :text) + msg.dig(:reactionMessage, :text) when 'contact' # FIXME: Missing specs - display_name = @raw_message.dig(:message, :contactMessage, :displayName) - vcard = @raw_message.dig(:message, :contactMessage, :vcard) + display_name = msg.dig(:contactMessage, :displayName) + vcard = msg.dig(:contactMessage, :vcard) match_phone_number = vcard&.match(/waid=(\d+)/) return display_name unless match_phone_number @@ -97,6 +96,24 @@ module Whatsapp::BaileysHandlers::Helpers # rubocop:disable Metrics/ModuleLength end end + def reply_to_message_id # rubocop:disable Metrics/CyclomaticComplexity + msg = unwrap_ephemeral_message(@raw_message[:message]) + message_key = case message_type + when 'text' then :extendedTextMessage + when 'image' then :imageMessage + when 'sticker' then :stickerMessage + when 'audio' then :audioMessage + when 'video' then :videoMessage + when 'contact' then :contactMessage + when 'file' + context_info = msg.dig(:documentMessage, :contextInfo).presence || + msg.dig(:documentWithCaptionMessage, :message, :documentMessage, :contextInfo) + return context_info&.dig(:stanzaId) + end + + msg.dig(message_key, :contextInfo, :stanzaId) if message_key + end + def file_content_type return :image if message_type.in?(%w[image sticker]) return :video if message_type.in?(%w[video video_note]) @@ -106,28 +123,31 @@ module Whatsapp::BaileysHandlers::Helpers # rubocop:disable Metrics/ModuleLength end def message_mimetype + msg = unwrap_ephemeral_message(@raw_message[:message]) case message_type when 'image' - @raw_message.dig(:message, :imageMessage, :mimetype) + msg.dig(:imageMessage, :mimetype) when 'sticker' - @raw_message.dig(:message, :stickerMessage, :mimetype) + msg.dig(:stickerMessage, :mimetype) when 'video' - @raw_message.dig(:message, :videoMessage, :mimetype) + msg.dig(:videoMessage, :mimetype) when 'audio' - @raw_message.dig(:message, :audioMessage, :mimetype) + msg.dig(:audioMessage, :mimetype) when 'file' - @raw_message.dig(:message, :documentMessage, :mimetype).presence || - @raw_message.dig(:message, :documentWithCaptionMessage, :message, :documentMessage, :mimetype) + msg.dig(:documentMessage, :mimetype).presence || + msg.dig(:documentWithCaptionMessage, :message, :documentMessage, :mimetype) end end - def phone_number_from_jid - reference_field = jid_type == 'lid' ? :senderPn : :remoteJid + def extract_from_jid(type:) + addressing_mode = @raw_message[:key][:addressingMode] + reference_field = addressing_mode && addressing_mode != type ? :remoteJidAlt : :remoteJid + jid = @raw_message[:key][reference_field] return unless jid # NOTE: jid shape is `_:@` - # https://github.com/WhiskeySockets/Baileys/blob/v6.7.16/src/WABinary/jid-utils.ts#L19 + # https://github.com/WhiskeySockets/Baileys/blob/v7.0.0-rc.6/src/WABinary/jid-utils.ts#L52 jid.split('@').first.split(':').first.split('_').first end @@ -136,12 +156,17 @@ module Whatsapp::BaileysHandlers::Helpers # rubocop:disable Metrics/ModuleLength name = @raw_message[:verifiedBizName].presence || @raw_message[:pushName] return name if name.presence && (self_message? || incoming?) - phone_number_from_jid + extract_from_jid(type: 'pn') || extract_from_jid(type: 'lid') end def self_message? - # TODO: Handle denormalized Brazilian phone numbers - phone_number_from_jid == inbox.channel.phone_number.delete('+') + normalize_phone_number(extract_from_jid(type: 'pn')) == normalize_phone_number(inbox.channel.phone_number.delete('+')) + end + + def normalize_phone_number(phone_number) + return unless phone_number + + Whatsapp::PhoneNormalizers::BrazilPhoneNormalizer.new.normalize(phone_number) end def ignore_message? @@ -162,22 +187,23 @@ module Whatsapp::BaileysHandlers::Helpers # rubocop:disable Metrics/ModuleLength # TODO: Current logic will never update the contact avatar if their profile picture changes on WhatsApp. return if @contact.avatar.attached? - profile_pic_url = fetch_profile_picture_url(phone_number_from_jid) + phone = extract_from_jid(type: 'pn') + profile_pic_url = fetch_profile_picture_url(phone) if phone ::Avatar::AvatarFromUrlJob.perform_later(@contact, profile_pic_url) if profile_pic_url end def message_under_process? - key = format(Redis::RedisKeys::MESSAGE_SOURCE_KEY, id: raw_message_id) + key = format(Redis::RedisKeys::MESSAGE_SOURCE_KEY, id: "#{inbox.id}_#{raw_message_id}") Redis::Alfred.get(key) end def cache_message_source_id_in_redis - key = format(Redis::RedisKeys::MESSAGE_SOURCE_KEY, id: raw_message_id) + key = format(Redis::RedisKeys::MESSAGE_SOURCE_KEY, id: "#{inbox.id}_#{raw_message_id}") ::Redis::Alfred.setex(key, true) end def clear_message_source_id_from_redis - key = format(Redis::RedisKeys::MESSAGE_SOURCE_KEY, id: raw_message_id) + key = format(Redis::RedisKeys::MESSAGE_SOURCE_KEY, id: "#{inbox.id}_#{raw_message_id}") ::Redis::Alfred.delete(key) end end diff --git a/app/services/whatsapp/baileys_handlers/messages_upsert.rb b/app/services/whatsapp/baileys_handlers/messages_upsert.rb index f949b9438..9cdbb7cc1 100644 --- a/app/services/whatsapp/baileys_handlers/messages_upsert.rb +++ b/app/services/whatsapp/baileys_handlers/messages_upsert.rb @@ -1,4 +1,4 @@ -module Whatsapp::BaileysHandlers::MessagesUpsert +module Whatsapp::BaileysHandlers::MessagesUpsert # rubocop:disable Metrics/ModuleLength include Whatsapp::BaileysHandlers::Helpers include BaileysHelper @@ -20,9 +20,9 @@ module Whatsapp::BaileysHandlers::MessagesUpsert end end - def handle_message # rubocop:disable Metrics/CyclomaticComplexity + def handle_message return unless %w[lid user].include?(jid_type) - return if jid_type == 'lid' && !phone_number_from_jid + return unless extract_from_jid(type: 'lid') return if ignore_message? return if find_message_by_source_id(raw_message_id) || message_under_process? @@ -42,27 +42,51 @@ module Whatsapp::BaileysHandlers::MessagesUpsert end def set_contact + phone = extract_from_jid(type: 'pn') + source_id = extract_from_jid(type: 'lid') + identifier = "#{source_id}@lid" + + update_existing_contact_inbox(phone, source_id, identifier) if phone + contact_inbox = ::ContactInboxWithContactBuilder.new( - # FIXME: update the source_id to use sender LID - source_id: phone_number_from_jid, + source_id: source_id, inbox: inbox, - contact_attributes: { name: contact_name, phone_number: "+#{phone_number_from_jid}", identifier: sender_lid } + contact_attributes: { name: contact_name, phone_number: ("+#{phone}" if phone), identifier: identifier } ).perform @contact_inbox = contact_inbox @contact = contact_inbox.contact - update_contact_information + update_contact_info(phone, source_id, identifier) end - def update_contact_information - updates = {} - updates[:identifier] = sender_lid if @contact.identifier.blank? - updates[:phone_number] = "+#{phone_number_from_jid}" if @contact.phone_number.blank? - updates[:name] = contact_name if @contact.name == phone_number_from_jid || @contact.name == sender_lid + def update_existing_contact_inbox(phone, source_id, identifier) + # NOTE: This is useful when we create a new contact manually, so we don't have information about contact LID; + # With this, when we receive a message from that contact, we can link it properly. + existing_contact_inbox = inbox.contact_inboxes.find_by(source_id: phone) + return unless existing_contact_inbox + return if inbox.contact_inboxes.exists?(source_id: source_id) - @contact.update!(updates) if updates.present? + existing_contact = existing_contact_inbox.contact + conflicting_identifier = inbox.account.contacts.find_by(identifier: identifier) + conflicting_phone = inbox.account.contacts.find_by(phone_number: "+#{phone}") + return if conflicting_identifier && conflicting_identifier.id != existing_contact.id + return if conflicting_phone && conflicting_phone.id != existing_contact.id + + ActiveRecord::Base.transaction do + existing_contact_inbox.update!(source_id: source_id) + existing_contact.update!(identifier: identifier, phone_number: "+#{phone}") + end + end + + def update_contact_info(phone, source_id, identifier) + update_params = {} + update_params[:phone_number] = "+#{phone}" if phone + update_params[:identifier] = identifier + update_params[:name] = contact_name if @contact.name.in?([phone, source_id, identifier]) + + @contact.update!(update_params) if update_params.present? try_update_contact_avatar end @@ -91,10 +115,13 @@ module Whatsapp::BaileysHandlers::MessagesUpsert def message_content_attributes type = message_type + msg = unwrap_ephemeral_message(@raw_message[:message]) content_attributes = { external_created_at: baileys_extract_message_timestamp(@raw_message[:messageTimestamp]) } if type == 'reaction' - content_attributes[:in_reply_to_external_id] = @raw_message.dig(:message, :reactionMessage, :key, :id) + content_attributes[:in_reply_to_external_id] = msg.dig(:reactionMessage, :key, :id) content_attributes[:is_reaction] = true + elsif reply_to_message_id + content_attributes[:in_reply_to_external_id] = reply_to_message_id elsif type == 'unsupported' content_attributes[:is_unsupported] = true end @@ -104,13 +131,14 @@ module Whatsapp::BaileysHandlers::MessagesUpsert def handle_attach_media attachment_file = download_attachment_file + msg = unwrap_ephemeral_message(@raw_message[:message]) attachment = @message.attachments.build( account_id: @message.account_id, file_type: file_content_type.to_s, file: { io: attachment_file, filename: filename, content_type: message_mimetype } ) - attachment.meta = { is_recorded_audio: true } if @raw_message.dig(:message, :audioMessage, :ptt) + attachment.meta = { is_recorded_audio: true } if msg.dig(:audioMessage, :ptt) rescue Down::Error => e @message.update!(is_unsupported: true) @@ -122,7 +150,8 @@ module Whatsapp::BaileysHandlers::MessagesUpsert end def filename - filename = @raw_message.dig(:message, :documentMessage, :fileName) + msg = unwrap_ephemeral_message(@raw_message[:message]) + filename = msg.dig(:documentMessage, :fileName) return filename if filename.present? ext = ".#{message_mimetype.split(';').first.split('/').last}" if message_mimetype.present? diff --git a/app/services/whatsapp/providers/whatsapp_baileys_service.rb b/app/services/whatsapp/providers/whatsapp_baileys_service.rb index bfed1fc57..8adc145ad 100644 --- a/app/services/whatsapp/providers/whatsapp_baileys_service.rb +++ b/app/services/whatsapp/providers/whatsapp_baileys_service.rb @@ -55,9 +55,9 @@ class Whatsapp::Providers::WhatsappBaileysService < Whatsapp::Providers::BaseSer true end - def send_message(phone_number, message) + def send_message(recipient_id, message) @message = message - @phone_number = phone_number + @recipient_id = recipient_id if message.content_attributes[:is_reaction] @message_content = reaction_message_content @@ -94,8 +94,8 @@ class Whatsapp::Providers::WhatsappBaileysService < Whatsapp::Providers::BaseSer process_response(response) end - def toggle_typing_status(typing_status, phone_number:, **) - @phone_number = phone_number + def toggle_typing_status(typing_status, recipient_id:, **) + @recipient_id = recipient_id status_map = { Events::Types::CONVERSATION_TYPING_ON => 'composing', Events::Types::CONVERSATION_RECORDING => 'recording', @@ -136,8 +136,8 @@ class Whatsapp::Providers::WhatsappBaileysService < Whatsapp::Providers::BaseSer true end - def read_messages(messages, phone_number:, **) - @phone_number = phone_number + def read_messages(messages, recipient_id:, **) + @recipient_id = recipient_id response = HTTParty.post( "#{provider_url}/connections/#{whatsapp_channel.phone_number}/read-messages", @@ -158,8 +158,8 @@ class Whatsapp::Providers::WhatsappBaileysService < Whatsapp::Providers::BaseSer true end - def unread_message(phone_number, message) # rubocop:disable Metrics/MethodLength - @phone_number = phone_number + def unread_message(recipient_id, message) # rubocop:disable Metrics/MethodLength + @recipient_id = recipient_id response = HTTParty.post( "#{provider_url}/connections/#{whatsapp_channel.phone_number}/chat-modify", @@ -185,8 +185,8 @@ class Whatsapp::Providers::WhatsappBaileysService < Whatsapp::Providers::BaseSer true end - def received_messages(phone_number, messages) - @phone_number = phone_number + def received_messages(recipient_id, messages) + @recipient_id = recipient_id response = HTTParty.post( "#{provider_url}/connections/#{whatsapp_channel.phone_number}/send-receipts", @@ -220,8 +220,8 @@ class Whatsapp::Providers::WhatsappBaileysService < Whatsapp::Providers::BaseSer response.parsed_response end - def on_whatsapp(phone_number) - @phone_number = phone_number + def on_whatsapp(recipient_id) + @recipient_id = recipient_id response = HTTParty.post( "#{provider_url}/connections/#{whatsapp_channel.phone_number}/on-whatsapp", @@ -233,7 +233,7 @@ class Whatsapp::Providers::WhatsappBaileysService < Whatsapp::Providers::BaseSer raise ProviderUnavailableError unless process_response(response) - response.parsed_response&.first || { 'jid' => remote_jid, 'exists' => false, 'lid' => nil } + response.parsed_response&.first || { 'jid' => remote_jid, 'exists' => false } end private @@ -303,9 +303,10 @@ class Whatsapp::Providers::WhatsappBaileysService < Whatsapp::Providers::BaseSer response.success? end - # FIXME: Make this a function with argument, instead of using instance variable def remote_jid - "#{@phone_number.delete('+')}@s.whatsapp.net" + return @recipient_id if @recipient_id.ends_with?('@lid') + + "#{@recipient_id.delete('+')}@s.whatsapp.net" end def update_external_created_at(response) diff --git a/app/services/whatsapp/providers/whatsapp_zapi_service.rb b/app/services/whatsapp/providers/whatsapp_zapi_service.rb index e3653fbab..c1cd001f7 100644 --- a/app/services/whatsapp/providers/whatsapp_zapi_service.rb +++ b/app/services/whatsapp/providers/whatsapp_zapi_service.rb @@ -80,7 +80,7 @@ class Whatsapp::Providers::WhatsappZapiService < Whatsapp::Providers::BaseServic response.parsed_response['value'] end - def read_messages(messages, phone_number:, **) + def read_messages(messages, recipient_id:, **) # NOTE: Z-API will handle marking previous messages as read. last_message = messages.last @@ -88,7 +88,7 @@ class Whatsapp::Providers::WhatsappZapiService < Whatsapp::Providers::BaseServic "#{api_instance_path_with_token}/read-message", headers: api_headers, body: { - phone: phone_number.delete('+'), + phone: recipient_id.delete('+'), messageId: last_message.source_id }.to_json ) diff --git a/app/services/whatsapp/send_on_whatsapp_service.rb b/app/services/whatsapp/send_on_whatsapp_service.rb index 94f67f4b4..10db7cb2c 100644 --- a/app/services/whatsapp/send_on_whatsapp_service.rb +++ b/app/services/whatsapp/send_on_whatsapp_service.rb @@ -51,14 +51,10 @@ class Whatsapp::SendOnWhatsappService < Base::SendOnChannelService end def recipient_id - return message.conversation.contact_inbox.source_id unless channel.provider == 'zapi' + return message.conversation.contact_inbox.source_id unless %w[baileys zapi].include?(channel.provider) - if message.conversation.contact.phone_number.present? - message.conversation.contact.phone_number.gsub(/[^\d]/, '') - else - # NOTE: `identifier` is the WhatsApp LID format. See `Whatsapp::ZapiHandlers::ReceivedCallback#set_contact` - message.conversation.contact.identifier - end + # NOTE: `identifier` must be in the WhatsApp LID format + message.conversation.contact.phone_number&.gsub(/[^\d]/, '') || message.conversation.contact.identifier end def template_params diff --git a/app/services/whatsapp/zapi_handlers/helpers.rb b/app/services/whatsapp/zapi_handlers/helpers.rb index 18d9084ee..e80afe8a8 100644 --- a/app/services/whatsapp/zapi_handlers/helpers.rb +++ b/app/services/whatsapp/zapi_handlers/helpers.rb @@ -12,17 +12,17 @@ module Whatsapp::ZapiHandlers::Helpers end def cache_message_source_id_in_redis - key = format(Redis::RedisKeys::MESSAGE_SOURCE_KEY, id: raw_message_id) + key = format(Redis::RedisKeys::MESSAGE_SOURCE_KEY, id: "#{inbox.id}_#{raw_message_id}") Redis::Alfred.setex(key, true) end def clear_message_source_id_from_redis - key = format(Redis::RedisKeys::MESSAGE_SOURCE_KEY, id: raw_message_id) + key = format(Redis::RedisKeys::MESSAGE_SOURCE_KEY, id: "#{inbox.id}_#{raw_message_id}") Redis::Alfred.delete(key) end def message_under_process? - key = format(Redis::RedisKeys::MESSAGE_SOURCE_KEY, id: raw_message_id) + key = format(Redis::RedisKeys::MESSAGE_SOURCE_KEY, id: "#{inbox.id}_#{raw_message_id}") Redis::Alfred.get(key) end end diff --git a/spec/models/channel/whatsapp_spec.rb b/spec/models/channel/whatsapp_spec.rb index 573bf5924..e4ae56889 100644 --- a/spec/models/channel/whatsapp_spec.rb +++ b/spec/models/channel/whatsapp_spec.rb @@ -184,9 +184,10 @@ RSpec.describe Channel::Whatsapp do let(:conversation) { create(:conversation) } it 'calls provider service method' do + message = create(:message, conversation: conversation) provider_double = instance_double(Whatsapp::Providers::WhatsappBaileysService, toggle_typing_status: nil) allow(provider_double).to receive(:toggle_typing_status) - .with(Events::Types::CONVERSATION_TYPING_ON, phone_number: conversation.contact.phone_number) + .with(Events::Types::CONVERSATION_TYPING_ON, recipient_id: conversation.contact.identifier, last_message: message) allow(Whatsapp::Providers::WhatsappBaileysService).to receive(:new) .with(whatsapp_channel: channel) .and_return(provider_double) @@ -242,11 +243,11 @@ RSpec.describe Channel::Whatsapp do create(:channel_whatsapp, provider: 'baileys', provider_config: { mark_as_read: true }, validate_provider_config: false, sync_templates: false) end let(:conversation) { create(:conversation) } - let(:message) { create(:message) } + let(:message) { create(:message, conversation: conversation) } it 'calls provider service method' do provider_double = instance_double(Whatsapp::Providers::WhatsappBaileysService, read_messages: nil) - allow(provider_double).to receive(:read_messages).with([message], phone_number: conversation.contact.phone_number) + allow(provider_double).to receive(:read_messages).with([message], recipient_id: conversation.contact.identifier) allow(Whatsapp::Providers::WhatsappBaileysService).to receive(:new) .with(whatsapp_channel: channel) .and_return(provider_double) @@ -259,7 +260,7 @@ RSpec.describe Channel::Whatsapp do it 'call method when the provider config mark_as_read is nil' do channel.update!(provider_config: {}) provider_double = instance_double(Whatsapp::Providers::WhatsappBaileysService, read_messages: nil) - allow(provider_double).to receive(:read_messages).with([message], phone_number: conversation.contact.phone_number) + allow(provider_double).to receive(:read_messages).with([message], recipient_id: conversation.contact.identifier) allow(Whatsapp::Providers::WhatsappBaileysService).to receive(:new) .with(whatsapp_channel: channel) .and_return(provider_double) @@ -332,7 +333,7 @@ RSpec.describe Channel::Whatsapp do it 'calls provider service method' do provider_double = instance_double(Whatsapp::Providers::WhatsappBaileysService, received_messages: nil) - allow(provider_double).to receive(:received_messages).with(conversation.contact.phone_number, messages) + allow(provider_double).to receive(:received_messages).with(conversation.contact.identifier, messages) allow(Whatsapp::Providers::WhatsappBaileysService).to receive(:new) .with(whatsapp_channel: channel) .and_return(provider_double) @@ -415,4 +416,51 @@ RSpec.describe Channel::Whatsapp do end end end + + describe '#provider_connection_data' do + let(:channel) do + create(:channel_whatsapp, provider: 'baileys', validate_provider_config: false, sync_templates: false, + provider_connection: { + 'connection' => 'open', + 'qr_data_url' => 'data:image/png;base64,test', + 'error' => 'some_error' + }) + end + + context 'when user is an administrator' do + it 'includes qr_data_url and error in the response' do + account_user = create(:account_user, account: channel.account, role: :administrator) + allow(Current).to receive(:account_user).and_return(account_user) + + data = channel.provider_connection_data + + expect(data).to eq({ + connection: 'open', + qr_data_url: 'data:image/png;base64,test', + error: 'some_error' + }) + end + end + + context 'when user is not an administrator' do + it 'excludes qr_data_url and error from the response' do + account_user = create(:account_user, account: channel.account, role: :agent) + allow(Current).to receive(:account_user).and_return(account_user) + + data = channel.provider_connection_data + + expect(data).to eq({ connection: 'open' }) + end + end + + context 'when Current.account_user is nil' do + it 'excludes qr_data_url and error from the response' do + allow(Current).to receive(:account_user).and_return(nil) + + data = channel.provider_connection_data + + expect(data).to eq({ connection: 'open' }) + end + end + end end diff --git a/spec/services/whatsapp/baileys_handlers/messages_upsert_spec.rb b/spec/services/whatsapp/baileys_handlers/messages_upsert_spec.rb new file mode 100644 index 000000000..0e66d820b --- /dev/null +++ b/spec/services/whatsapp/baileys_handlers/messages_upsert_spec.rb @@ -0,0 +1,319 @@ +require 'rails_helper' + +describe Whatsapp::BaileysHandlers::MessagesUpsert do + let(:webhook_verify_token) { 'valid_token' } + let!(:whatsapp_channel) do + create(:channel_whatsapp, + provider: 'baileys', + provider_config: { webhook_verify_token: webhook_verify_token }, + validate_provider_config: false, + received_messages: false) + end + let(:inbox) { whatsapp_channel.inbox } + let(:timestamp) { Time.current.to_i } + + before do + stub_request(:get, /profile-picture-url/) + .to_return( + status: 200, + body: { data: { profilePictureUrl: nil } }.to_json + ) + end + + describe '#update_existing_contact_inbox' do + context 'when updating contact inbox with LID information' do + let(:phone) { '5511912345678' } + let(:lid) { '12345678' } + let(:source_id) { lid } + let(:identifier) { "#{lid}@lid" } + + context 'when there is no conflict' do + it 'updates existing contact_inbox source_id from phone to LID' do + contact = create(:contact, account: inbox.account, phone_number: "+#{phone}", identifier: nil) + contact_inbox = create(:contact_inbox, inbox: inbox, contact: contact, source_id: phone) + + raw_message = { + key: { id: 'msg_123', remoteJid: "#{lid}@lid", remoteJidAlt: "#{phone}@s.whatsapp.net", fromMe: false, addressingMode: 'lid' }, + pushName: 'John Doe', + messageTimestamp: timestamp, + message: { conversation: 'Hello' } + } + params = { + webhookVerifyToken: webhook_verify_token, + event: 'messages.upsert', + data: { type: 'notify', messages: [raw_message] } + } + + expect do + Whatsapp::IncomingMessageBaileysService.new(inbox: inbox, params: params).perform + end.not_to raise_error + + expect(contact_inbox.reload.source_id).to eq(source_id) + expect(contact.reload.identifier).to eq(identifier) + expect(contact.phone_number).to eq("+#{phone}") + end + end + + context 'when identifier is already taken by a different contact (race condition)' do + it 'does not raise validation error and skips the update' do + original_contact = create(:contact, account: inbox.account, phone_number: nil, identifier: nil, name: 'Original Contact') + original_contact_inbox = create(:contact_inbox, inbox: inbox, contact: original_contact, source_id: phone) + + conflicting_contact = create(:contact, account: inbox.account, phone_number: "+#{phone}", identifier: identifier, + name: 'Conflicting Contact') + create(:contact_inbox, inbox: inbox, contact: conflicting_contact, source_id: source_id) + + raw_message = { + key: { id: 'msg_123', remoteJid: "#{lid}@lid", remoteJidAlt: "#{phone}@s.whatsapp.net", fromMe: false, addressingMode: 'lid' }, + pushName: 'John Doe', + messageTimestamp: timestamp, + message: { conversation: 'Hello' } + } + params = { + webhookVerifyToken: webhook_verify_token, + event: 'messages.upsert', + data: { type: 'notify', messages: [raw_message] } + } + + expect do + Whatsapp::IncomingMessageBaileysService.new(inbox: inbox, params: params).perform + end.not_to raise_error + + expect(original_contact_inbox.reload.source_id).to eq(phone) + expect(original_contact.reload.identifier).to be_nil + + message = inbox.messages.last + expect(message).to be_present + expect(message.sender).to eq(conflicting_contact) + expect(message.conversation.contact).to eq(conflicting_contact) + end + end + + context 'when phone number is already taken by a different contact (race condition)' do + it 'does not raise validation error and skips the update' do + original_contact = create(:contact, account: inbox.account, phone_number: nil, identifier: nil) + create(:contact_inbox, inbox: inbox, contact: original_contact, source_id: phone) + + different_lid = '87654321' + different_identifier = "#{different_lid}@lid" + conflicting_contact = create(:contact, account: inbox.account, phone_number: "+#{phone}", identifier: different_identifier) + create(:contact_inbox, inbox: inbox, contact: conflicting_contact, source_id: different_lid) + + raw_message = { + key: { id: 'msg_123', remoteJid: "#{lid}@lid", remoteJidAlt: "#{phone}@s.whatsapp.net", fromMe: false, addressingMode: 'lid' }, + pushName: 'John Doe', + messageTimestamp: timestamp, + message: { conversation: 'Hello' } + } + params = { + webhookVerifyToken: webhook_verify_token, + event: 'messages.upsert', + data: { type: 'notify', messages: [raw_message] } + } + + expect do + Whatsapp::IncomingMessageBaileysService.new(inbox: inbox, params: params).perform + end.not_to raise_error + + expect(original_contact.reload.phone_number).to be_nil + expect(original_contact.identifier).to be_nil + end + end + + context 'when LID contact_inbox already exists (race condition)' do + it 'does not raise unique constraint error and skips the update' do + original_contact = create(:contact, account: inbox.account, phone_number: nil, identifier: nil) + create(:contact_inbox, inbox: inbox, contact: original_contact, source_id: phone) + + lid_contact = create(:contact, account: inbox.account, phone_number: "+#{phone}", identifier: identifier) + create(:contact_inbox, inbox: inbox, contact: lid_contact, source_id: source_id) + + raw_message = { + key: { id: 'msg_123', remoteJid: "#{lid}@lid", remoteJidAlt: "#{phone}@s.whatsapp.net", fromMe: false, addressingMode: 'lid' }, + pushName: 'John Doe', + messageTimestamp: timestamp, + message: { conversation: 'Hello' } + } + params = { + webhookVerifyToken: webhook_verify_token, + event: 'messages.upsert', + data: { type: 'notify', messages: [raw_message] } + } + + expect do + Whatsapp::IncomingMessageBaileysService.new(inbox: inbox, params: params).perform + end.not_to raise_error + + message = inbox.messages.last + expect(message).to be_present + expect(message.sender).to eq(lid_contact) + end + end + + context 'when updating the same contact (no conflict)' do + it 'successfully updates the contact' do + contact = create(:contact, account: inbox.account, phone_number: "+#{phone}", identifier: nil) + contact_inbox = create(:contact_inbox, inbox: inbox, contact: contact, source_id: phone) + + raw_message = { + key: { id: 'msg_123', remoteJid: "#{lid}@lid", remoteJidAlt: "#{phone}@s.whatsapp.net", fromMe: false, addressingMode: 'lid' }, + pushName: 'John Doe', + messageTimestamp: timestamp, + message: { conversation: 'Hello' } + } + params = { + webhookVerifyToken: webhook_verify_token, + event: 'messages.upsert', + data: { type: 'notify', messages: [raw_message] } + } + + expect do + Whatsapp::IncomingMessageBaileysService.new(inbox: inbox, params: params).perform + end.not_to raise_error + + expect(contact_inbox.reload.source_id).to eq(source_id) + expect(contact.reload.identifier).to eq(identifier) + expect(contact.phone_number).to eq("+#{phone}") + end + end + end + end + + describe 'ephemeral message handling' do + let(:phone) { '5511912345678' } + let(:lid) { '12345678' } + + context 'when receiving an ephemeral text message' do + it 'correctly unwraps and processes the message' do + raw_message = { + key: { id: 'msg_ephemeral_123', remoteJid: "#{phone}@s.whatsapp.net", remoteJidAlt: "#{lid}@lid", fromMe: false, + addressingMode: 'pn' }, + pushName: 'Gabriel', + messageTimestamp: timestamp, + message: { + messageContextInfo: { + deviceListMetadata: {}, + deviceListMetadataVersion: 2 + }, + ephemeralMessage: { + message: { + extendedTextMessage: { + text: 'This is a disappearing message', + contextInfo: { + expiration: 604_800, + disappearingMode: { initiator: 0 } + } + } + } + } + } + } + params = { + webhookVerifyToken: webhook_verify_token, + event: 'messages.upsert', + data: { type: 'notify', messages: [raw_message] } + } + + expect do + Whatsapp::IncomingMessageBaileysService.new(inbox: inbox, params: params).perform + end.to change(inbox.messages, :count).by(1) + + message = inbox.messages.last + expect(message.content).to eq('This is a disappearing message') + expect(message.message_type).to eq('incoming') + expect(message.is_unsupported).to be_falsey + end + end + + context 'when receiving an ephemeral image message' do + it 'correctly unwraps and processes the message with media' do + raw_message = { + key: { id: 'msg_ephemeral_image_123', remoteJid: "#{phone}@s.whatsapp.net", remoteJidAlt: "#{lid}@lid", fromMe: false, + addressingMode: 'pn' }, + pushName: 'Gabriel', + messageTimestamp: timestamp, + message: { + messageContextInfo: { + deviceListMetadata: {}, + deviceListMetadataVersion: 2 + }, + ephemeralMessage: { + message: { + imageMessage: { + caption: 'Check this out', + mimetype: 'image/jpeg', + url: 'https://example.com/image.jpg' + } + } + } + } + } + params = { + webhookVerifyToken: webhook_verify_token, + event: 'messages.upsert', + data: { type: 'notify', messages: [raw_message] } + } + + stub_request(:get, whatsapp_channel.media_url('msg_ephemeral_image_123')) + .to_return(status: 200, body: 'fake image data') + + expect do + Whatsapp::IncomingMessageBaileysService.new(inbox: inbox, params: params).perform + end.to change(inbox.messages, :count).by(1) + + message = inbox.messages.last + expect(message.content).to eq('Check this out') + expect(message.message_type).to eq('incoming') + expect(message.is_unsupported).to be_falsey + expect(message.attachments.count).to eq(1) + end + end + + context 'when receiving an ephemeral reaction message' do + it 'correctly unwraps and processes the reaction' do + # First create the original message + contact = create(:contact, account: inbox.account, phone_number: "+#{phone}", identifier: "#{lid}@lid") + contact_inbox = create(:contact_inbox, inbox: inbox, contact: contact, source_id: lid) + conversation = create(:conversation, inbox: inbox, contact_inbox: contact_inbox) + original_message = create(:message, inbox: inbox, conversation: conversation, source_id: 'original_msg_id') + + raw_message = { + key: { id: 'msg_ephemeral_reaction_123', remoteJid: "#{phone}@s.whatsapp.net", remoteJidAlt: "#{lid}@lid", fromMe: false, + addressingMode: 'pn' }, + pushName: 'Gabriel', + messageTimestamp: timestamp, + message: { + messageContextInfo: { + deviceListMetadata: {}, + deviceListMetadataVersion: 2 + }, + ephemeralMessage: { + message: { + reactionMessage: { + text: '👍', + key: { id: 'original_msg_id' } + } + } + } + } + } + params = { + webhookVerifyToken: webhook_verify_token, + event: 'messages.upsert', + data: { type: 'notify', messages: [raw_message] } + } + + expect do + Whatsapp::IncomingMessageBaileysService.new(inbox: inbox, params: params).perform + end.to change(conversation.messages, :count).by(1) + + reaction = conversation.messages.last + expect(reaction.content).to eq('👍') + expect(reaction.message_type).to eq('incoming') + expect(reaction.content_attributes['is_reaction']).to be_truthy + expect(reaction.in_reply_to).to eq(original_message.id) + expect(reaction.in_reply_to_external_id).to eq(original_message.source_id) + end + end + end +end diff --git a/spec/services/whatsapp/incoming_message_baileys_service_spec.rb b/spec/services/whatsapp/incoming_message_baileys_service_spec.rb index db3b4f409..3f3bb398d 100644 --- a/spec/services/whatsapp/incoming_message_baileys_service_spec.rb +++ b/spec/services/whatsapp/incoming_message_baileys_service_spec.rb @@ -171,7 +171,7 @@ describe Whatsapp::IncomingMessageBaileysService do let(:timestamp) { Time.current.to_i } let(:raw_message) do { - key: { id: 'msg_123', remoteJid: '5511912345678@s.whatsapp.net', fromMe: false, senderLid: '12345678@lid' }, + key: { id: 'msg_123', remoteJid: '12345678@lid', remoteJidAlt: '5511912345678@s.whatsapp.net', fromMe: false, addressingMode: 'lid' }, pushName: 'John Doe', messageTimestamp: timestamp, message: { conversation: 'Hello from Baileys' } @@ -360,23 +360,6 @@ describe Whatsapp::IncomingMessageBaileysService do expect(message.message_type).to eq('outgoing') end - it 'creates an outgoing self message' do - self_jid = "#{whatsapp_channel.phone_number.delete('+')}@s.whatsapp.net" - raw_message[:key][:remoteJid] = self_jid - raw_message[:key][:fromMe] = true - create(:account_user, account: inbox.account) - - described_class.new(inbox: inbox, params: params).perform - - conversation = inbox.conversations.last - message = conversation.messages.last - - expect(message).to be_present - expect(message.content).to eq('Hello from Baileys') - expect(conversation.contact.name).to eq('John Doe') - expect(message.message_type).to eq('outgoing') - end - it 'updates the contact name when name is the phone number and message has a pushName' do create(:contact, account: inbox.account, name: '5511912345678') @@ -396,6 +379,23 @@ describe Whatsapp::IncomingMessageBaileysService do expect(conversation.contact.name).to eq('Verified John') end + it 'creates contact with LID only when phone number is not present' do + raw_message[:key][:remoteJid] = '87654321@lid' + raw_message[:key][:remoteJidAlt] = nil + + described_class.new(inbox: inbox, params: params).perform + + conversation = inbox.conversations.last + contact = conversation.contact + contact_inbox = conversation.contact_inbox + + expect(contact).to be_present + expect(contact.phone_number).to be_nil + expect(contact.identifier).to eq('87654321@lid') + expect(contact.name).to eq('John Doe') + expect(contact_inbox.source_id).to eq('87654321') + end + it 'creates contact with phone number as name on outgoing message' do raw_message[:key][:fromMe] = true create(:account_user, account: inbox.account) @@ -416,9 +416,23 @@ describe Whatsapp::IncomingMessageBaileysService do expect(conversation.contact.name).to eq('5511912345678') end + it 'migrates existing phone-based contact inbox to LID-based when receiving message with LID' do + contact = create(:contact, account: inbox.account, name: 'Existing Contact', phone_number: '+5511912345678') + contact_inbox = create(:contact_inbox, inbox: inbox, contact: contact, source_id: '5511912345678') + + described_class.new(inbox: inbox, params: params).perform + + contact_inbox.reload + contact.reload + + expect(contact_inbox.source_id).to eq('12345678') + expect(contact.identifier).to eq('12345678@lid') + expect(contact.phone_number).to eq('+5511912345678') + end + it 'creates a message on an existing conversation' do contact = create(:contact, account: inbox.account, name: 'John Doe') - contact_inbox = create(:contact_inbox, inbox: inbox, contact: contact, source_id: '5511912345678') + contact_inbox = create(:contact_inbox, inbox: inbox, contact: contact, source_id: '12345678') existing_conversation = create(:conversation, inbox: inbox, contact_inbox: contact_inbox) described_class.new(inbox: inbox, params: params).perform @@ -476,7 +490,7 @@ describe Whatsapp::IncomingMessageBaileysService do context 'when message type is reaction' do let!(:message) do contact = create(:contact, account: inbox.account, name: '5511912345678') - contact_inbox = create(:contact_inbox, inbox: inbox, contact: contact, source_id: '5511912345678') + contact_inbox = create(:contact_inbox, inbox: inbox, contact: contact, source_id: '12345678') conversation = create(:conversation, inbox: inbox, contact_inbox: contact_inbox) create(:message, inbox: inbox, conversation: conversation, source_id: 'msg_123') end @@ -485,7 +499,7 @@ describe Whatsapp::IncomingMessageBaileysService do raw_message[:key][:id] = 'reaction_123' raw_message[:message] = { reactionMessage: { - key: { remoteJid: '5511912345678@s.whatsapp.net', fromMe: true, id: 'msg_123' }, + key: { remoteJid: '12345678@lid', fromMe: true, id: 'msg_123' }, text: '👍' } } @@ -502,7 +516,7 @@ describe Whatsapp::IncomingMessageBaileysService do raw_message[:key][:id] = 'reaction_123' raw_message[:message] = { reactionMessage: { - key: { remoteJid: '5511912345678@s.whatsapp.net', fromMe: true, id: 'msg_123' }, + key: { remoteJid: '12345678@lid', fromMe: true, id: 'msg_123' }, text: '' } } @@ -522,7 +536,8 @@ describe Whatsapp::IncomingMessageBaileysService do type: 'notify', messages: [ { - key: { id: 'msg_123', remoteJid: '5511912345678@s.whatsapp.net', fromMe: false }, + key: { id: 'msg_123', remoteJid: '12345678@lid', remoteJidAlt: '5511912345678@s.whatsapp.net', fromMe: false, + addressingMode: 'lid' }, message: { imageMessage: { caption: 'Hello from Baileys', mimetype: 'image/png' } }, pushName: 'John Doe' } @@ -574,7 +589,8 @@ describe Whatsapp::IncomingMessageBaileysService do type: 'notify', messages: [ { - key: { id: 'msg_123', remoteJid: '5511912345678@s.whatsapp.net', fromMe: false }, + key: { id: 'msg_123', remoteJid: '12345678@lid', remoteJidAlt: '5511912345678@s.whatsapp.net', fromMe: false, + addressingMode: 'lid' }, message: { videoMessage: { caption: 'Hello from Baileys', mimetype: 'video/mp4' } }, pushName: 'John Doe' } @@ -617,7 +633,8 @@ describe Whatsapp::IncomingMessageBaileysService do type: 'notify', messages: [ { - key: { id: 'msg_123', remoteJid: '5511912345678@s.whatsapp.net', fromMe: false }, + key: { id: 'msg_123', remoteJid: '12345678@lid', remoteJidAlt: '5511912345678@s.whatsapp.net', fromMe: false, + addressingMode: 'lid' }, message: { documentMessage: { fileName: filename } }, pushName: 'John Doe' } @@ -668,7 +685,8 @@ describe Whatsapp::IncomingMessageBaileysService do type: 'notify', messages: [ { - key: { id: 'msg_123', remoteJid: '5511912345678@s.whatsapp.net', fromMe: false }, + key: { id: 'msg_123', remoteJid: '12345678@lid', remoteJidAlt: '5511912345678@s.whatsapp.net', fromMe: false, + addressingMode: 'lid' }, message: { audioMessage: { mimetype: 'audio/opus' } }, pushName: 'John Doe' } @@ -699,7 +717,8 @@ describe Whatsapp::IncomingMessageBaileysService do type: 'notify', messages: [ { - key: { id: 'msg_123', remoteJid: '5511912345678@s.whatsapp.net', fromMe: false }, + key: { id: 'msg_123', remoteJid: '12345678@lid', remoteJidAlt: '5511912345678@s.whatsapp.net', fromMe: false, + addressingMode: 'lid' }, message: { stickerMessage: { mimetype: 'image/png' } }, pushName: 'John Doe' } @@ -724,13 +743,13 @@ describe Whatsapp::IncomingMessageBaileysService do context 'when processing multiple messages' do it 'creates separate contacts and conversations for each message' do raw_message1 = { - key: { id: 'msg_123', remoteJid: '5511912345678@s.whatsapp.net', fromMe: false }, + key: { id: 'msg_123', remoteJid: '5511912345678@s.whatsapp.net', remoteJidAlt: '12345678@lid', fromMe: false, addressingMode: 'pn' }, pushName: 'John Doe', messageTimestamp: timestamp, message: { conversation: 'Hello from Baileys' } } raw_message2 = { - key: { id: 'msg_124', remoteJid: '5511987654321@s.whatsapp.net', fromMe: false }, + key: { id: 'msg_124', remoteJid: '5511987654321@s.whatsapp.net', remoteJidAlt: '87654321@lid', fromMe: false, addressingMode: 'pn' }, pushName: 'Jane Doe', messageTimestamp: timestamp, message: { conversation: 'Hello from another user' } @@ -764,9 +783,10 @@ describe Whatsapp::IncomingMessageBaileysService do end context 'when jid type is lid' do - it 'processes the message with phone number from lid jid type' do - raw_message[:key][:remoteJid] = '12345678@lid' - raw_message[:key][:senderPn] = '5511912345678@s.whatsapp.net' + it 'processes the message with phone number from addressingMode pn' do + raw_message[:key][:remoteJid] = '5511912345678@s.whatsapp.net' + raw_message[:key][:remoteJidAlt] = '12345678@lid' + raw_message[:key][:addressingMode] = 'pn' described_class.new(inbox: inbox, params: params).perform @@ -778,82 +798,90 @@ describe Whatsapp::IncomingMessageBaileysService do end end - it 'skips message if senderPn is not present' do - raw_message[:key][:remoteJid] = '12345678@lid' + it 'skips message if LID cannot be extracted' do + raw_message[:key][:remoteJid] = 'invalid_jid' + raw_message[:key][:remoteJidAlt] = nil + raw_message[:key][:addressingMode] = 'lid' described_class.new(inbox: inbox, params: params).perform expect(inbox.conversations).to be_empty end - context 'when handling senderLid field' do - it 'creates contact with identifier from senderLid' do + context 'when handling LID field' do + it 'creates contact with LID as source_id and identifier' do described_class.new(inbox: inbox, params: params).perform contact = inbox.contacts.last + contact_inbox = inbox.contact_inboxes.last expect(contact.identifier).to eq('12345678@lid') + expect(contact_inbox.source_id).to eq('12345678') end - it 'updates contact identifier for existing contacts without identifier' do + it 'updates existing contact_inbox from phone to LID source_id' do contact = create(:contact, account: inbox.account, phone_number: '+5511912345678', identifier: nil) create(:contact_inbox, inbox: inbox, contact: contact, source_id: '5511912345678') described_class.new(inbox: inbox, params: params).perform + contact_inbox = inbox.contact_inboxes.find_by(contact: contact) + expect(contact_inbox.source_id).to eq('12345678') expect(contact.reload.identifier).to eq('12345678@lid') + expect(contact.phone_number).to eq('+5511912345678') end - it 'does not update contact identifier if already present' do - contact = create(:contact, account: inbox.account, phone_number: '+5511912345678', identifier: 'existing@lid') - create(:contact_inbox, inbox: inbox, contact: contact, source_id: '5511912345678') + it 'does not update contact_inbox if source_id is already LID' do + contact = create(:contact, account: inbox.account, phone_number: '+5511912345678', identifier: '12345678@lid') + contact_inbox = create(:contact_inbox, inbox: inbox, contact: contact, source_id: '12345678') described_class.new(inbox: inbox, params: params).perform - expect(contact.reload.identifier).to eq('existing@lid') + expect(contact_inbox.reload.source_id).to eq('12345678') + expect(contact.reload.identifier).to eq('12345678@lid') end end context 'when updating contact information' do it 'updates contact phone number if blank' do contact = create(:contact, account: inbox.account, phone_number: nil, identifier: '12345678@lid') - create(:contact_inbox, inbox: inbox, contact: contact, source_id: '5511912345678') + create(:contact_inbox, inbox: inbox, contact: contact, source_id: '12345678') described_class.new(inbox: inbox, params: params).perform expect(contact.reload.phone_number).to eq('+5511912345678') end - it 'does not update contact phone number if already present' do - contact = create(:contact, account: inbox.account, phone_number: '+9999999999', identifier: '12345678@lid') - create(:contact_inbox, inbox: inbox, contact: contact, source_id: '5511912345678') - - described_class.new(inbox: inbox, params: params).perform - - expect(contact.reload.phone_number).to eq('+9999999999') - end - it 'updates contact name if it matches phone number' do contact = create(:contact, account: inbox.account, name: '5511912345678', phone_number: '+5511912345678', identifier: '12345678@lid') - create(:contact_inbox, inbox: inbox, contact: contact, source_id: '5511912345678') + create(:contact_inbox, inbox: inbox, contact: contact, source_id: '12345678') described_class.new(inbox: inbox, params: params).perform expect(contact.reload.name).to eq('John Doe') end - it 'updates contact name if it matches sender LID' do + it 'updates contact name if it matches LID source_id' do + contact = create(:contact, account: inbox.account, name: '12345678', phone_number: '+5511912345678', identifier: '12345678@lid') + create(:contact_inbox, inbox: inbox, contact: contact, source_id: '12345678') + + described_class.new(inbox: inbox, params: params).perform + + expect(contact.reload.name).to eq('John Doe') + end + + it 'updates contact name if it matches identifier' do contact = create(:contact, account: inbox.account, name: '12345678@lid', phone_number: '+5511912345678', identifier: '12345678@lid') - create(:contact_inbox, inbox: inbox, contact: contact, source_id: '5511912345678') + create(:contact_inbox, inbox: inbox, contact: contact, source_id: '12345678') described_class.new(inbox: inbox, params: params).perform expect(contact.reload.name).to eq('John Doe') end - it 'does not update contact name if it is different from phone number and sender LID' do + it 'does not update contact name if it is different from phone number, source_id, and identifier' do contact = create(:contact, account: inbox.account, name: 'Existing Name', phone_number: '+5511912345678', identifier: '12345678@lid') - create(:contact_inbox, inbox: inbox, contact: contact, source_id: '5511912345678') + create(:contact_inbox, inbox: inbox, contact: contact, source_id: '12345678') described_class.new(inbox: inbox, params: params).perform @@ -958,7 +986,7 @@ describe Whatsapp::IncomingMessageBaileysService do end def format_message_source_key(message_id) - format(Redis::RedisKeys::MESSAGE_SOURCE_KEY, id: message_id) + format(Redis::RedisKeys::MESSAGE_SOURCE_KEY, id: "#{inbox.id}_#{message_id}") end def stub_download diff --git a/spec/services/whatsapp/providers/whatsapp_baileys_service_spec.rb b/spec/services/whatsapp/providers/whatsapp_baileys_service_spec.rb index e8e778203..6f286b41a 100644 --- a/spec/services/whatsapp/providers/whatsapp_baileys_service_spec.rb +++ b/spec/services/whatsapp/providers/whatsapp_baileys_service_spec.rb @@ -477,7 +477,7 @@ describe Whatsapp::Providers::WhatsappBaileysService do body: { keys: [{ id: message.source_id, remoteJid: test_send_jid, fromMe: false }] }.to_json ).to_return(status: 200, body: '', headers: {}) - result = service.read_messages([message], phone_number: test_send_phone_number) + result = service.read_messages([message], recipient_id: test_send_phone_number) expect(result).to be(true) end @@ -495,7 +495,7 @@ describe Whatsapp::Providers::WhatsappBaileysService do .to_return(status: 200) expect do - service.read_messages([message], phone_number: test_send_phone_number) + service.read_messages([message], recipient_id: test_send_phone_number) end.to raise_error(Whatsapp::Providers::WhatsappBaileysService::ProviderUnavailableError) end end @@ -605,7 +605,7 @@ describe Whatsapp::Providers::WhatsappBaileysService do ) .to_return(status: 200) - service.toggle_typing_status(Events::Types::CONVERSATION_TYPING_ON, phone_number: test_send_phone_number) + service.toggle_typing_status(Events::Types::CONVERSATION_TYPING_ON, recipient_id: test_send_phone_number) expect(request).to have_been_requested end @@ -621,7 +621,7 @@ describe Whatsapp::Providers::WhatsappBaileysService do ) .to_return(status: 200) - service.toggle_typing_status(Events::Types::CONVERSATION_RECORDING, phone_number: test_send_phone_number) + service.toggle_typing_status(Events::Types::CONVERSATION_RECORDING, recipient_id: test_send_phone_number) expect(request).to have_been_requested end @@ -637,7 +637,7 @@ describe Whatsapp::Providers::WhatsappBaileysService do ) .to_return(status: 200) - service.toggle_typing_status(Events::Types::CONVERSATION_TYPING_OFF, phone_number: test_send_phone_number) + service.toggle_typing_status(Events::Types::CONVERSATION_TYPING_OFF, recipient_id: test_send_phone_number) expect(request).to have_been_requested end @@ -664,7 +664,7 @@ describe Whatsapp::Providers::WhatsappBaileysService do allow(Rails.logger).to receive(:error) expect do - service.toggle_typing_status(Events::Types::CONVERSATION_TYPING_ON, phone_number: test_send_phone_number) + service.toggle_typing_status(Events::Types::CONVERSATION_TYPING_ON, recipient_id: test_send_phone_number) end.to raise_error(Whatsapp::Providers::WhatsappBaileysService::ProviderUnavailableError) expect(Rails.logger).to have_received(:error).with('error message') @@ -730,12 +730,12 @@ describe Whatsapp::Providers::WhatsappBaileysService do .to_return( status: 200, headers: { 'Content-Type' => 'application/json' }, - body: [{ jid: "#{phone_number.delete('+')}@s.whatsapp.net", exists: true, lid: '123@lid' }].to_json + body: [{ jid: "#{phone_number.delete('+')}@s.whatsapp.net", exists: true }].to_json ) response = service.on_whatsapp(phone_number) - expect(response).to eq({ 'jid' => "#{phone_number.delete('+')}@s.whatsapp.net", 'exists' => true, 'lid' => '123@lid' }) + expect(response).to eq({ 'jid' => "#{phone_number.delete('+')}@s.whatsapp.net", 'exists' => true }) end it 'returns default check response' do @@ -749,7 +749,7 @@ describe Whatsapp::Providers::WhatsappBaileysService do response = service.on_whatsapp(phone_number) - expect(response).to eq({ 'jid' => "#{phone_number.delete('+')}@s.whatsapp.net", 'exists' => false, 'lid' => nil }) + expect(response).to eq({ 'jid' => "#{phone_number.delete('+')}@s.whatsapp.net", 'exists' => false }) end end @@ -916,7 +916,7 @@ describe Whatsapp::Providers::WhatsappBaileysService do whatsapp_channel.update!(provider_connection: { 'connection' => 'open' }) expect do - service.toggle_typing_status(Events::Types::CONVERSATION_TYPING_ON, phone_number: test_send_phone_number) + service.toggle_typing_status(Events::Types::CONVERSATION_TYPING_ON, recipient_id: test_send_phone_number) end.to raise_error(Whatsapp::Providers::WhatsappBaileysService::ProviderUnavailableError) expect(whatsapp_channel.reload.provider_connection['connection']).to eq('close') diff --git a/spec/services/whatsapp/providers/whatsapp_zapi_service_spec.rb b/spec/services/whatsapp/providers/whatsapp_zapi_service_spec.rb index 1e045f0b3..a37556ee1 100644 --- a/spec/services/whatsapp/providers/whatsapp_zapi_service_spec.rb +++ b/spec/services/whatsapp/providers/whatsapp_zapi_service_spec.rb @@ -208,7 +208,7 @@ describe Whatsapp::Providers::WhatsappZapiService do ) .to_return(status: 200) - result = service.read_messages(messages, phone_number: "+#{test_send_phone_number}") + result = service.read_messages(messages, recipient_id: "+#{test_send_phone_number}") expect(result).to be(true) end @@ -223,7 +223,7 @@ describe Whatsapp::Providers::WhatsappZapiService do allow(Rails.logger).to receive(:error) expect do - service.read_messages(messages, phone_number: "+#{test_send_phone_number}") + service.read_messages(messages, recipient_id: "+#{test_send_phone_number}") end.to raise_error(Whatsapp::Providers::WhatsappZapiService::ProviderUnavailableError) end end diff --git a/spec/services/whatsapp/send_on_whatsapp_service_spec.rb b/spec/services/whatsapp/send_on_whatsapp_service_spec.rb index f042c8b7c..1173dac68 100644 --- a/spec/services/whatsapp/send_on_whatsapp_service_spec.rb +++ b/spec/services/whatsapp/send_on_whatsapp_service_spec.rb @@ -397,8 +397,9 @@ describe Whatsapp::SendOnWhatsappService do end it 'calls channel.send_message if channel is not locked on outgoing message' do + conversation.contact.update!(phone_number: '+123456789') message = create(:message, message_type: :outgoing, content: 'test', conversation: conversation) - allow(whatsapp_channel).to receive(:send_message).with(conversation.contact_inbox.source_id, message).and_return('123456789') + allow(whatsapp_channel).to receive(:send_message).with('123456789', message).and_return('123456789') described_class.new(message: message).perform diff --git a/spec/services/whatsapp/zapi_handlers/received_callback_spec.rb b/spec/services/whatsapp/zapi_handlers/received_callback_spec.rb index 3954c9467..219fd8bf4 100644 --- a/spec/services/whatsapp/zapi_handlers/received_callback_spec.rb +++ b/spec/services/whatsapp/zapi_handlers/received_callback_spec.rb @@ -1217,6 +1217,6 @@ describe Whatsapp::ZapiHandlers::ReceivedCallback do private def format_message_source_key(message_id) - format(Redis::RedisKeys::MESSAGE_SOURCE_KEY, id: message_id) + format(Redis::RedisKeys::MESSAGE_SOURCE_KEY, id: "#{inbox.id}_#{message_id}") end end