Skip to content

fix(meta): handle message_echoes and guard missing contact fields#2514

Open
ricardoisus wants to merge 4 commits intoEvolutionAPI:mainfrom
ricardoisus:fix/cloudapi-message-echoes
Open

fix(meta): handle message_echoes and guard missing contact fields#2514
ricardoisus wants to merge 4 commits intoEvolutionAPI:mainfrom
ricardoisus:fix/cloudapi-message-echoes

Conversation

@ricardoisus
Copy link
Copy Markdown

@ricardoisus ricardoisus commented Apr 25, 2026

Problem

When WhatsApp Cloud API delivers messages sent outside Evolution (official WhatsApp app), payloads can arrive as message_echoes / smb_message_echoes instead of classic messages[].

In production this caused parser breakage (Cannot read properties of undefined, e.g. reading '0' / 'name') and outbound-echo messages were not persisted/routed.

What changed

  • Normalize message_echoes / smb_message_echoes into the regular message processing flow.
  • Add guards for optional contact fields (profile/name/phone).
  • Make remoteJid resolution robust for both messages and statuses.
  • Prevent connectToWhatsapp crashes when payload does not follow classic messages[] structure.

Real environment validation

  • Local build/typecheck passed.
  • Deployed custom image in production-like Swarm setup.
  • Manual test from official WhatsApp app succeeded:
    • message_echoes observed in runtime logs.
    • No reading '0' / reading 'name' errors after deploy.
    • Message persisted in DB (both regular + echo representation).

Traceability

Summary by Sourcery

Handle WhatsApp Cloud API echo payloads and make webhook message/contact parsing more robust.

Bug Fixes:

  • Normalize message_echoes and smb_message_echoes payloads into the standard messages flow to prevent parser crashes and ensure echo messages are processed.
  • Guard access to optional contact fields so missing profile/name/phone data does not throw runtime errors.
  • Resolve remote JIDs for both messages and statuses using available to/from/recipient_id fields to avoid invalid or missing identifiers.
  • Prevent connectToWhatsapp from failing when webhook payloads lack the classic messages[] structure.

Enhancements:

  • Improve contact and JID derivation to better support diverse WhatsApp payload formats while keeping message and status routing consistent.

@sourcery-ai
Copy link
Copy Markdown
Contributor

sourcery-ai Bot commented Apr 25, 2026

Reviewer's Guide

Normalizes WhatsApp Cloud API webhook payloads so message_echoes/smb_message_echoes follow the existing messages[] flow, hardens contact/remoteJid resolution for messages and statuses, and prevents crashes when non-classic payloads arrive.

Sequence diagram for normalized WhatsApp Cloud webhook handling

sequenceDiagram
  participant MetaWebhook as MetaWebhook
  participant BusinessStartupService as BusinessStartupService
  participant EventHandler as EventHandler

  MetaWebhook->>BusinessStartupService: handleWebhook(data)
  BusinessStartupService->>BusinessStartupService: extract content = data.entry[0].changes[0].value
  BusinessStartupService->>BusinessStartupService: firstMessage = messages[0] or message_echoes[0] or smb_message_echoes[0]
  BusinessStartupService->>BusinessStartupService: recipient = statuses[0].recipient_id
  BusinessStartupService->>BusinessStartupService: remoteId = firstMessage.to or firstMessage.from or recipient

  BusinessStartupService->>BusinessStartupService: loadChatwoot()
  BusinessStartupService->>BusinessStartupService: normalizedContent = normalizeWebhookContent(content)
  BusinessStartupService->>EventHandler: eventHandler(normalizedContent)

  alt remoteId resolved
    BusinessStartupService->>BusinessStartupService: phoneNumber = createJid(remoteId)
  else no remoteId
    BusinessStartupService->>BusinessStartupService: phoneNumber unchanged
  end

  BusinessStartupService-->>MetaWebhook: success or error response
Loading

Sequence diagram for robust message and contact handling

sequenceDiagram
  participant BusinessStartupService as BusinessStartupService
  participant MessageHandler as MessageHandler
  participant ContactRepo as ContactRepository

  BusinessStartupService->>MessageHandler: handleIncoming(received)
  MessageHandler->>MessageHandler: incomingContact = received.contacts[0]
  alt incomingContact exists
    MessageHandler->>MessageHandler: pushName = profile.name or name or wa_id
  else
    MessageHandler->>MessageHandler: pushName = undefined
  end

  alt messages present
    MessageHandler->>MessageHandler: message = received.messages[0]
    MessageHandler->>MessageHandler: remoteJid = createJid(message.to or message.from)
    MessageHandler->>MessageHandler: key.fromMe = message.from == metadata.phone_number_id
    MessageHandler->>ContactRepo: findOne(instanceId, remoteJid)
    MessageHandler->>MessageHandler: contactPhone = profile.phone or wa_id or message.to or message.from
    alt contactPhone exists
      MessageHandler->>MessageHandler: contactRemoteJid = createJid(contactPhone)
      MessageHandler->>ContactRepo: upsertContact(instanceId, contactRemoteJid, pushName)
    else
      MessageHandler->>MessageHandler: abort contact creation
    end
  end

  alt statuses present
    loop for each status item
      MessageHandler->>MessageHandler: remoteJid = createJid(item.recipient_id or phoneNumber)
      MessageHandler->>MessageHandler: fromMe = item.recipient_id ? item.recipient_id != metadata.phone_number_id : true
    end
  end
Loading

Class diagram for BusinessStartupService webhook normalization and contact handling

classDiagram
  class ChannelStartupService {
  }

  class BusinessStartupService {
    - phoneNumber : string
    - logger
    - instanceId : string
    + handleWebhook(data) void
    + normalizeWebhookContent(content) any
    + downloadMediaMessage(message) Promise~void~
    + loadChatwoot() void
    + eventHandler(content) void
    + handleIncomingMessage(received) Promise~void~
  }

  ChannelStartupService <|-- BusinessStartupService

  class WebhookContent {
    + messages : Message[]
    + message_echoes : Message[]
    + smb_message_echoes : Message[]
    + statuses : Status[]
    + contacts : Contact[]
    + metadata : Metadata
  }

  class Message {
    + id : string
    + from : string
    + to : string
    + type : string
  }

  class Status {
    + id : string
    + recipient_id : string
  }

  class Contact {
    + profile : ContactProfile
    + name : string
    + wa_id : string
  }

  class ContactProfile {
    + name : string
    + phone : string
  }

  class Metadata {
    + phone_number_id : string
  }

  BusinessStartupService --> WebhookContent
  WebhookContent o-- Message
  WebhookContent o-- Status
  WebhookContent o-- Contact
  WebhookContent --> Metadata
  Contact o-- ContactProfile
Loading

File-Level Changes

Change Details Files
Normalize message_echoes/smb_message_echoes into the standard messages[] processing path and make startup handling robust to non-classic payload shapes.
  • Compute a firstMessage candidate from messages[0], message_echoes[0], or smb_message_echoes[0], and derive a generic remoteId from message.to, message.from, or statuses[0].recipient_id.
  • Guard BusinessStartupService.connectToWhatsapp against missing messages[] by using remoteId (if present) to set phoneNumber instead of assuming messages[0]/statuses[0].recipient_id.
  • Introduce normalizeWebhookContent to copy message_echoes/smb_message_echoes into messages[] when messages is absent but echoes are present, and pass the normalized payload into eventHandler.
src/api/integrations/channel/meta/whatsapp.business.service.ts
Harden contact handling and remoteJid resolution for inbound messages and contacts.
  • Cache received.contacts[0] as incomingContact and derive pushName from profile.name, name, or wa_id with null-safe access.
  • Derive remoteJid for message keys from message.to or message.from instead of relying on this.phoneNumber.
  • Derive contactPhone defensively from incomingContact.profile.phone, incomingContact.wa_id, message.to, or message.from, early-returning if no phone can be resolved, and consistently wrap it with createJid when building contact records.
src/api/integrations/channel/meta/whatsapp.business.service.ts
Improve status handling so remoteJid/fromMe logic works for both classic and echo-like payloads.
  • For each status item, compute remoteJid from item.recipient_id (when present) or fall back to this.phoneNumber.
  • Set fromMe to true when recipient_id is missing, otherwise compare item.recipient_id with metadata.phone_number_id instead of relying on this.phoneNumber.
src/api/integrations/channel/meta/whatsapp.business.service.ts

Possibly linked issues

  • #Bug meta: PR normalizes message_echoes and guards contacts, directly addressing the reported undefined '0' meta error.

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

Copy link
Copy Markdown
Contributor

@sourcery-ai sourcery-ai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 2 issues, and left some high level feedback:

  • In BusinessStartupService.BusinessConnect, remoteId now prefers firstMessage.to over .from, which changes the previous behavior for inbound messages; please double-check that this aligns with how phoneNumber is expected to be set (business vs. customer JID) to avoid regressions in routing/ownership semantics.
  • The new fromMe computation for statuses (item.recipient_id ? recipient_id !== phone_number_id : true) in eventHandler significantly changes the previous logic (this.phoneNumber === received.metadata.phone_number_id); consider revisiting this condition or adding a brief comment to clarify the intended directionality to future readers.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- In `BusinessStartupService.BusinessConnect`, `remoteId` now prefers `firstMessage.to` over `.from`, which changes the previous behavior for inbound messages; please double-check that this aligns with how `phoneNumber` is expected to be set (business vs. customer JID) to avoid regressions in routing/ownership semantics.
- The new `fromMe` computation for statuses (`item.recipient_id ? recipient_id !== phone_number_id : true`) in `eventHandler` significantly changes the previous logic (`this.phoneNumber === received.metadata.phone_number_id`); consider revisiting this condition or adding a brief comment to clarify the intended directionality to future readers.

## Individual Comments

### Comment 1
<location path="src/api/integrations/channel/meta/whatsapp.business.service.ts" line_range="152-157" />
<code_context>
+    if (!content || typeof content !== 'object') return content;
+
+    const normalized = { ...content };
+    const echoes = normalized?.message_echoes ?? normalized?.smb_message_echoes;
+
+    if (!Array.isArray(normalized.messages) && Array.isArray(echoes) && echoes.length > 0) {
+      normalized.messages = echoes;
+    }
</code_context>
<issue_to_address>
**suggestion:** Echo selection prefers an empty `message_echoes` over a non-empty `smb_message_echoes`.

Because `??` only distinguishes `null/undefined`, this will select an empty `message_echoes` array instead of falling back to a non-empty `smb_message_echoes`. If you want to use `smb_message_echoes` when `message_echoes` is missing or empty, explicitly check `message_echoes.length` and only prefer it when it’s a non-empty array.

```suggestion
    const normalized = { ...content };

    const messageEchoes = Array.isArray(normalized.message_echoes)
      ? normalized.message_echoes
      : undefined;
    const smbMessageEchoes = Array.isArray(normalized.smb_message_echoes)
      ? normalized.smb_message_echoes
      : undefined;

    const echoes =
      messageEchoes && messageEchoes.length > 0
        ? messageEchoes
        : smbMessageEchoes;

    if (!Array.isArray(normalized.messages) && Array.isArray(echoes) && echoes.length > 0) {
      normalized.messages = echoes;
    }
```
</issue_to_address>

### Comment 2
<location path="src/api/integrations/channel/meta/whatsapp.business.service.ts" line_range="774-778" />
<code_context>
       }
       if (received.statuses) {
         for await (const item of received.statuses) {
+          const remoteJid = createJid(item?.recipient_id ?? this.phoneNumber);
           const key = {
             id: item.id,
-            remoteJid: this.phoneNumber,
-            fromMe: this.phoneNumber === received.metadata.phone_number_id,
+            remoteJid,
+            fromMe: item?.recipient_id ? item.recipient_id !== received.metadata.phone_number_id : true,
           };
           if (settings?.groups_ignore && key.remoteJid.includes('@g.us')) {
</code_context>
<issue_to_address>
**issue (bug_risk):** The new `fromMe` logic for statuses looks inverted and may misclassify message direction.

Previously `fromMe` was `this.phoneNumber === received.metadata.phone_number_id`; now it’s `item?.recipient_id ? item.recipient_id !== received.metadata.phone_number_id : true`, which treats statuses as `fromMe` when the recipient differs from `phone_number_id` and defaults to `true` if `recipient_id` is missing. That reverses the usual “from us” meaning. If this is meant to indicate events sent by the business, it likely should be `item?.recipient_id === received.metadata.phone_number_id` (or similar), and the default value should be revisited.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment thread src/api/integrations/channel/meta/whatsapp.business.service.ts
Comment on lines +774 to +778
const remoteJid = createJid(item?.recipient_id ?? this.phoneNumber);
const key = {
id: item.id,
remoteJid: this.phoneNumber,
fromMe: this.phoneNumber === received.metadata.phone_number_id,
remoteJid,
fromMe: item?.recipient_id ? item.recipient_id !== received.metadata.phone_number_id : true,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (bug_risk): The new fromMe logic for statuses looks inverted and may misclassify message direction.

Previously fromMe was this.phoneNumber === received.metadata.phone_number_id; now it’s item?.recipient_id ? item.recipient_id !== received.metadata.phone_number_id : true, which treats statuses as fromMe when the recipient differs from phone_number_id and defaults to true if recipient_id is missing. That reverses the usual “from us” meaning. If this is meant to indicate events sent by the business, it likely should be item?.recipient_id === received.metadata.phone_number_id (or similar), and the default value should be revisited.

@ricardoisus
Copy link
Copy Markdown
Author

Thanks for the review — addressed in commit 532bc1dc.

What was updated:

  1. connectToWhatsapp remote id selection
  • Reworked to use resolveRemoteId(...) instead of hard-prefering firstMessage.to.
  • The helper prioritizes the external counterpart (non-business number) using metadata.display_phone_number / metadata.phone_number_id as business references.
  • This preserves routing/ownership semantics for inbound/outbound while still handling echo payloads.
  1. Statuses fromMe directionality
  • Replaced inline condition with isCloudApiStatusFromMe(...).
  • Added a safe reconciliation step from the persisted message key (findMessage.key.fromMe), so status events inherit the original message direction when available.
  • This avoids inversions and keeps behavior aligned with the message pipeline.
  1. Echo fallback behavior (message_echoes vs smb_message_echoes)
  • Updated normalization to avoid selecting an empty message_echoes array over a non-empty smb_message_echoes.
  • messages[] is populated from whichever echo array is present and non-empty.

I also re-ran build/typecheck after these changes.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant