Skip to content

fix(whatsapp): support business-scoped user ids and inbound context variants (#236) - #255

Merged
patrick-chinchill merged 7 commits into
mainfrom
sync/4.41-w1
Sep 30, 2026
Merged

patrick-chinchill merged 7 commits into
mainfrom
sync/4.41-w1

Conversation

@patrick-chinchill

@patrick-chinchill patrick-chinchill commented Sep 30, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

WhatsApp can now deliver business-scoped user ID (BSUID) and username-only webhooks. Their messages carry from_user_id / from_parent_user_id and no from. The Python adapter indexed inbound["from"], and the per-message try/except swallowed the KeyError, so these messages were silently dropped. I confirmed this on main before changing anything: the signed webhook returned 200, process_message was called 0 times, and the error log read "Failed to handle inbound message" {'error': "'from'"}.

This PR ports upstream identity handling. Existing phone-keyed thread ids are unchanged.

Upstream commits mapped

Upstream Tag Python
3e6e866a fix(whatsapp): support business-scoped user ids (vercel/chat#818) chat@4.39.0 _fields, _resolve, _link, _handle_user_id_update, _recipient, _identity_key, _author, _BSUID_PATTERN; handle_webhook routes user_id_update, matches the contact per message, resolves identity, and skips system messages after linking; _build_message stores raw["user_id"]; parse_message uses stored user_id, then _fields, otherwise raises ValidationError; post_message resolves the recipient once and passes it through text (every chunk) and interactive sends; add_reaction / remove_reaction call _recipient
16879fdc fix(whatsapp): model forwarded and product-inquiry context variants (vercel/chat#723) chat@4.37.0 WhatsAppInboundContext (total=False: from, id, forwarded, frequently_forwarded, referred_product). Type-only.

The types now cover:

  • optional from, plus from_user_id, from_parent_user_id and system
  • contact user_id and parent_user_id, optional wa_id, and profile.username
  • WhatsAppUserIdUpdate and WhatsAppWebhookValue.user_id_update
  • status recipient_user_id and recipient_parent_user_id
  • WhatsAppRawMessage.user_id
  • optional wa_id / user_id on send-response contacts

State keys and the route shape match the TS SDK byte for byte: whatsapp:identity:alias:{phone_number_id}:{id} and whatsapp:identity:route:{phone_number_id}:{user_id} → {"bsuid"?, "parent"?, "phone"?}, with absent keys omitted rather than written as None.

Tests ported (tests/test_whatsapp_webhook.py, memory state + AsyncMock Graph request)

  • parseMessage: preserves canonical identity when reparsing raw messages, parses BSUID-only raw messages without stored identity
  • handleWebhook - business-scoped user IDs:
    • handles $name webhooks, parametrized as phone and BSUID, username without phone, username with phone and parent BSUID
    • sends both known identifiers from a preserved phone thread, which also asserts the exact state keys and route JSON
    • 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
  • postMessage: sends %s threads through recipient without to (US.13491208655302741918, US.ENT.11815799212886844830), sends interactive messages to BSUID recipients
  • Python-specific:
    • A raising state get still dispatches the message and sends to the pattern-based recipient, with warnings logged.
    • A raising state on user_id_update is swallowed.
    • Reactions on a BSUID thread send recipient. Reactions on a phone thread with a stored route send both to and recipient.
    • A long message resolves the recipient once for all chunks.
    • BSUID-only reaction, interactive and button events reach their handlers.
    • The contact is matched per message.
    • A forwarded context without id parses.
    • parse_message with no identifier raises.
    • Input sweep of _BSUID_PATTERN: lowercase, trailing newline, 129 characters, US.ENT..
    • An empty stored route falls through to the pattern.
  • 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).

Fidelity

index.test.ts for the WhatsApp adapter is not fidelity-mapped, so the target report is unchanged and scripts/fidelity_target.json needed no update:

Delta vs committed report (HEAD): missing 265 -> 265 (+0)

Validation

Full validation is green: ruff check and format, audit_test_quality (0 hard failures), --check-docs, --strict at chat@4.31.0 (all TS tests have Python equivalents), and pytest (5680 passed, 24 skipped, after merging current main). pyrefly check reports 0 errors.

Divergences (1 of the 2-per-PR budget)

WhatsApp contact matching. This PR adds a row to the non-parity table, a breadcrumb in _match_contact, three regression tests and a CHANGELOG sub-bullet.

  • Upstream: matches user_id / wa_id and otherwise falls back to contacts[0]. fields() fills a message's missing phone or BSUID from that contact, so in a batched webhook it can combine two senders' identifiers. link() then merges both users into one thread and route.
  • Python: also matches parent_user_id. An unmatched message gets contacts[0] only when the webhook has exactly one contact, the message carries no from* identifier of its own, and it is not a system message.
  • Tests: test_does_not_borrow_identity_from_an_unrelated_contact, test_does_not_pair_a_single_unmatched_contact_with_another_sender, test_system_message_does_not_take_an_unmatched_contact, plus test_uses_the_only_contact_when_the_message_has_no_sender_ids for the fallback that remains.

Known limitations kept at parity. Codex flagged these during review; upstream behaves the same at chat@4.41.1. They are documented in docs/UPSTREAM_SYNC.md rather than fixed, because each needs an identity-model change:

  • Concurrent first-contact webhooks for one user can split into two threads, because identity is resolved before the Chat lock.
  • A redelivered older message can revert a rotated route.
  • Recycled phone numbers: phone aliases never expire.
  • Undocumented system-message shapes are not special-cased. The documented shapes are in upstream sample-messages.md.

Codex (gpt-6-astra) review: 4 rounds, and round 4 is a PASS ("No actionable regressions found"). Round 1: the contact-fallback P1 was fixed; four P2s were declined as parity limitations and documented. Round 2: the single-contact fallback P1 was fixed. Round 3: the system-message fallback P1 was fixed; the recycled-number P1 was documented as a parity limitation.

docs/UPSTREAM_SYNC.md gains a "WhatsApp business-scoped user IDs" section covering:

A stored alias or route of the wrong type is read as absent. Upstream reads the missing properties as undefined, which has the same effect.

Consumer impact

  • Slack/Teams: none.
  • WhatsApp:
    • BSUID and username-only users now reach handlers.
    • The adapter writes whatsapp:identity:alias:* and whatsapp:identity:route:* keys to the chat state adapter, using the same keys and JSON as the TS SDK. State errors are logged and fall back to un-linked behavior.
    • Author.user_id, and the user segment of the thread id, may now be a BSUID for users who don't share a phone number.
    • Author.user_name prefers profile.username.
    • On webhook messages, Author.is_me is now user_id == bot_user_id (it was hard-coded False). This matches parse_message and upstream.
    • type: "system" messages are no longer dispatched. Before this change they were dropped anyway, because they had no text content.

Merge gate

Final HEAD 6911245 (fixes plus a merge of current origin/main; the only conflict was docs/UPSTREAM_SYNC.md, where both sides added independent sections and both were kept. fidelity_target.json was regenerated with no change).

Independent review findings (5): all fixed, none declined

  • handle_webhook read metadata.phone_number_id outside the per-message try, so "metadata": null raised out of the webhook (500, and Meta retried the whole batch). This was a regression from main. Fixed: the id is now read defensively. A change with no string phone_number_id fails each of its messages with the per-message error log, as upstream does, and later changes in the same POST are still dispatched. A non-dict change value is skipped. Test: TestBusinessScopedUserIdsMalformedPayloads::test_bad_metadata_fails_per_message_and_later_changes_still_dispatch, parametrized over null, string, {} and a null id, plus test_null_change_value_is_skipped.

  • The same issue was reported by the robustness lens, and it is covered by the same fix and tests.

  • _handle_user_id_update raised when user_id / parent_user_id was not a dict. Both are now read as absent, like upstream ?.. When metadata is missing, the update is skipped with a warning and no alias:: / route:: keys are written under an empty business number. Tests: test_user_id_update_ignores_non_dict_nested_identifiers and test_user_id_update_without_business_number_writes_nothing.

  • The same nested-field crash was reported by the robustness lens, and it is covered by the same fix and tests.

  • Seven untested behaviors are now pinned in TestBusinessScopedUserIdsIdentityInvariants:

    • is_me
    • a system message dropping a stale phone
    • alias list order
    • the phone-only merge keeping the stored bsuid and parent
    • the parent recipient fallback
    • the user_id_update previous-id fallback
    • the user_id_update stored-parent merge

    I re-ran the reviewer's mutation list (8 single-line mutants). All 8 are now killed; before this change all 8 survived.

All malformed-payload tests failed on the previous head and pass now. The hardening is documented in docs/UPSTREAM_SYNC.md, with a CHANGELOG sub-bullet.

gpt-6-astra: 1 round on 6911245. Verdict: PASS ("No actionable regressions were found relative to the supplied merge base"). Earlier rounds on the pre-fix head are summarized above.

Bots: CodeRabbit skipped the draft PR, then hit its rate limit after the PR was marked ready. There are no inline or review comments. Gemini posted nothing.

CI: all green on 6911245: Lint & Type Check, test (3.12), test (3.13), CodeQL, and Analyze (python and actions).

Local: full validation is green. That covers ruff, format, audit (0 hard failures), --check-docs, --strict at 4.31.0, 5680 passed, and pyrefly with 0 errors.

Closes #236
Part of #184

…ariants (#236)

Port upstream 3e6e866a (vercel/chat#818, chat@4.39.0) and the type-only
context variants of 16879fdc (vercel/chat#723, chat@4.37.0).

BSUID / username-only webhooks carry from_user_id and no from; the adapter
indexed inbound["from"] and the per-message try/except silently dropped
them. Identity now resolves phone > BSUID > parent BSUID, aliases and
routes persist in chat state under the TS SDK's keys, user_id_update and
system messages re-link identity, and text/interactive/reaction sends emit
to and/or recipient from the stored route.

Part of #184
@coderabbitai

coderabbitai Bot commented Sep 30, 2026 •

Copy link
Copy Markdown

Warning

Review limit reached

Next included review available in 34 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Advanced

Run ID: 791d3ac4-f4f0-4dde-aab7-9c024aee5eea

📥 Commits

Reviewing files that changed from the base of the PR and between ff384c9 and bac10ce.

📒 Files selected for processing (5)
  • CHANGELOG.md
  • docs/UPSTREAM_SYNC.md
  • src/chat_sdk/adapters/whatsapp/adapter.py
  • src/chat_sdk/adapters/whatsapp/types.py
  • tests/test_whatsapp_webhook.py

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Match a message's contact by parent_user_id too, and fall back to
contacts[0] only when the webhook has a single contact. Documented as a
divergence; the remaining review findings (concurrent first contact,
out-of-order route writes, undocumented system shapes) are upstream
parity limitations recorded in docs/UPSTREAM_SYNC.md.
An unmatched message falls back to the only contact just when it carries
no sender identifier of its own, so a single-contact batch can no longer
merge two senders' identities or overwrite a route.
Also document phone-number recycling (aliases never expire) as an
upstream-parity limitation.
@patrick-chinchill
patrick-chinchill marked this pull request as ready for review September 30, 2026 10:51
@patrick-chinchill

Copy link
Copy Markdown
Collaborator Author

Merge gate: CI green (test 3.12, test 3.13, Lint & Type Check, CodeQL, Analyze actions/python) on bac10ce (6911245 + clean merge of origin/main ff384c9: #252 github, #193 shared, #223 gchat; no conflicts, no overlap with the WhatsApp modules; local full validation 5892 passed, strict fidelity 730/730 real tests, pyrefly 0 errors, fidelity target delta +0). Local Codex review (gpt-6-astra, xhigh, --base origin/main) on 6911245: "No actionable regressions were found relative to the supplied merge base."; 5 astra rounds (4 fix rounds on #236 findings, then a clean final). CodeRabbit rate-limited, no human reviews. Merging with --admin (Protect Main requires a code-owner approval).

@patrick-chinchill
patrick-chinchill merged commit d94c888 into main Sep 30, 2026
7 checks passed
@patrick-chinchill
patrick-chinchill deleted the sync/4.41-w1 branch September 30, 2026 11:03
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.

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

1 participant