Skip to content

[4.41/W1] WhatsApp: business-scoped user ids (BSUID) and inbound context variants #236

Description

@patrick-chinchill

Summary

Meta now sends business-scoped user IDs (BSUIDs) and username-only WhatsApp webhooks, where an inbound message may carry from_user_id / from_parent_user_id and no from; the Python adapter indexes inbound["from"], raises KeyError, and the per-message try/except silently drops the message. Port upstream 3e6e866a (identity extraction, alias/route persistence in chat state, user_id_update handling, recipient() on every existing send path) and the type-only context variants from 16879fdc. Existing phone-keyed thread ids must keep working.

Upstream changes

  • 3e6e866a fix(whatsapp): support business-scoped user ids (#818) — chat@4.39.0 — adds the following to the adapter:
    • fields() identity precedence: phone (system.wa_id ?? from ?? contact.wa_id), then BSUID, then parent BSUID.
    • resolve()/link(), which persist whatsapp:identity:alias:{phoneNumberId}:{id} and whatsapp:identity:route:{phoneNumberId}:{userId} → {bsuid?, parent?, phone?} in state.
    • handleUserIdUpdate for field == "user_id_update", and skipping type == "system" messages after linking.
    • Contact matching by user_id/wa_id instead of contacts[0].
    • author(): user_name = username || name || userId, full_name = name || username || userId.
    • recipient(), which emits to and/or recipient from the stored route, falling back to BSUID_PATTERN = /^[A-Z]{2}\.(?:ENT\.)?[A-Za-z0-9]{1,128}$/.
    • raw.userId, and widened types (WhatsAppUserIdUpdate, optional from, from_user_id, system, contact user_id, profile.username, status recipient_user_id).
  • 16879fdc fix(whatsapp): model forwarded and product-inquiry context variants in WhatsAppInboundMessage (#723) — chat@4.37.0 — makes every context field optional and adds forwarded, frequently_forwarded and referred_product. Type-only.

Current Python behavior

  • src/chat_sdk/adapters/whatsapp/adapter.py:225 skips every change except field == "messages", so user_id_update is ignored.
  • :234-249 always passes (value.get("contacts") or [None])[0] and swallows exceptions as "Failed to handle inbound message".
  • inbound["from"] is indexed at :337, :358, :366-368, :404, :425-433, :460-472 and :526-528. parse_message (:1048-1080) indexes raw["message"]["from"].
  • "to" is hard-coded in _send_single_text_message (:797), _send_interactive_message (:851), add_reaction (:921) and remove_reaction (:941). Typing (:947+) and mark_as_read (:1093) address a message_id only.
  • whatsapp/types.py: WhatsAppContact (:78-82) requires wa_id; WhatsAppInboundMessage (:140-177) has "context": dict[str, str] and no from_user_id/system; WhatsAppWebhookValue (:96-103) has no user_id_update; WhatsAppRawMessage (:260-272) has no user_id.
  • grep -rniI 'bsuid\|from_user_id\|user_id_update' src tests finds nothing relevant.

Scope

  • whatsapp/types.py: optional from, plus from_user_id, from_parent_user_id, system; contact wa_id optional plus user_id, parent_user_id, profile.username; WhatsAppUserIdUpdate and WhatsAppWebhookValue.user_id_update; status recipient_user_id / recipient_parent_user_id; a WhatsAppInboundContext TypedDict (total=False: from, id, forwarded, frequently_forwarded, referred_product); WhatsAppRawMessage.user_id; optional wa_id/user_id on send-response contacts.
  • whatsapp/adapter.py helpers with upstream semantics: _fields, async _resolve, async _link, async _handle_user_id_update, async _recipient, _identity_key, _author, _BSUID_PATTERN.
  • handle_webhook: route user_id_update changes; match the contact per message and await self._resolve(...); warn and skip when there is no identifier; skip system messages after linking; pass the identity into _handle_inbound_message, the reaction/interactive/button handlers and _build_message.
  • Replace every inbound["from"] with identity.user_id.
  • _build_message stores raw["user_id"].
  • parse_message tries raw.get("user_id"), then _fields(...), and otherwise raises ValidationError("whatsapp", "WhatsApp message has no user identifier").
  • post_message resolves the recipient once per logical post and passes it through _send_text_message (every chunk), _send_single_text_message and _send_interactive_message. add_reaction/remove_reaction call _recipient. Spread the result into the payload in place of "to".

Out of scope

Porting notes

  • State keys must be byte-identical to upstream so state stays compatible across the TS and Python SDKs: whatsapp:identity:alias:{phone_number_id}:{identifier} and whatsapp:identity:route:{phone_number_id}:{user_id}. Route value keys are bsuid/parent/phone; omit absent keys rather than writing None.
  • ?? vs ||: fields() uses ?? (port as is not None chains); author() deliberately uses || so an empty profile name falls through; identifier lists drop both "" and None.
  • System messages:
    • source = inbound.get("from") if type == "system" else None
    • fallback = source if source is not None else identity.user_id
    • On a system message, a missing phone means the stored phone is stale: phone if changed else (phone ?? route.phone).
  • _link:
    • Look up aliases concurrently with asyncio.gather, but honor them in list order (the first truthy one wins).
    • Write only the aliases that differ, and write the route only when bsuid/parent/phone changed. This is what "skips redundant identity writes for repeat messages" checks.
  • State errors in _resolve, _handle_user_id_update and _recipient are swallowed with a warning and fall back to the un-linked identity or the pattern-based recipient, as upstream does.
  • _recipient honors a stored route only when it has phone, bsuid or parent. Otherwise it returns {"recipient": id} if _BSUID_PATTERN.fullmatch(id), else {"to": id}.
  • Keep the per-type handlers synchronous and do the async resolution in handle_webhook, as upstream does, so process_message scheduling is unchanged.
  • Python raw keys are already snake_case (phone_number_id), so use user_id. Note the casing in docs/UPSTREAM_SYNC.md.

Tests

Upstream packages/adapter-whatsapp/src/index.test.ts is not fidelity-mapped. Port into tests/test_whatsapp_webhook.py / tests/test_whatsapp_adapter.py, using memory state and an AsyncMock Graph request:

  • "preserves canonical identity when reparsing raw messages", "parses BSUID-only raw messages without stored identity"
  • describe("handleWebhook - business-scoped user IDs"):
    • "handles $name webhooks" (parametrized: phone and BSUID, username without phone, username with phone, parent BSUID)
    • "sends both known identifiers from a preserved phone thread", "preserves identity across BSUID rotation system messages", "preserves identity across number change system messages"
    • "clears a stale phone route after a user_id_update rotation", "preserves a BSUID-keyed thread across a user_id_update rotation", "keeps the pre-change thread when a number change arrives without prior state"
    • "skips redundant identity writes for repeat messages", "falls back to the user ID when the profile name is empty", "ignores messages without a phone number or BSUID"
    • "sends %s threads through recipient without to" (US.13491208655302741918, US.ENT.11815799212886844830), "sends interactive messages to BSUID recipients"
  • Deferred: "sends templates to BSUID recipients" ([4.41/W2] WhatsApp: send_template and typed API errors #237), "sends media to BSUID recipients" ([4.41/W3] WhatsApp: outbound files/media, CTA URL LinkButton, no duplicate card title #238).
  • Python-specific: a raising state get still dispatches (warning logged); reactions on a BSUID thread send recipient; a forwarded context without id parses.

Acceptance criteria

  • BSUID-only and username-only webhooks reach handlers, and no inbound shape raises KeyError.
  • Phone-keyed thread ids are unchanged. Every send path emits to and/or recipient per the stored route.
  • Full validation command from CLAUDE.md passes.
  • docs/UPSTREAM_SYNC.md updated for any divergence.
  • CHANGELOG entry under an "Unreleased (4.41 wave)" heading.
  • Consumer-visible changes called out: WhatsApp writes identity alias/route keys to chat state, and Author.user_id may be a BSUID.

Dependencies

None — can start immediately. Blocks #237 (and, through it, #238).

Verify first

  • Confirm the drop with a failing test first: a signed webhook with from_user_id and no from must currently log "Failed to handle inbound message" without calling the handler (code reading: adapter.py:337 inside the :235-249 try/except).

Metadata

  • Effort: L (700-1.5k LOC incl. tests)
  • Consumer impact: low. None for Slack/Teams. WhatsApp: BSUID users start working, Author.user_id may be a BSUID, and identity alias/route keys are written to chat state (degrades gracefully on state errors).
  • Suggested branch: sync/4.41-w1

Part of #184.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions