You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
[4.41/W1] WhatsApp: business-scoped user ids (BSUID) and inbound context variants #236
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 nofrom; 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.
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".
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"
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).
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_idand nofrom; the Python adapter indexesinbound["from"], raisesKeyError, and the per-messagetry/exceptsilently drops the message. Port upstream3e6e866a(identity extraction, alias/route persistence in chat state,user_id_updatehandling,recipient()on every existing send path) and the type-only context variants from16879fdc. Existing phone-keyed thread ids must keep working.Upstream changes
3e6e866afix(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 persistwhatsapp:identity:alias:{phoneNumberId}:{id}andwhatsapp:identity:route:{phoneNumberId}:{userId}→{bsuid?, parent?, phone?}in state.handleUserIdUpdateforfield == "user_id_update", and skippingtype == "system"messages after linking.user_id/wa_idinstead ofcontacts[0].author():user_name = username || name || userId,full_name = name || username || userId.recipient(), which emitstoand/orrecipientfrom the stored route, falling back toBSUID_PATTERN = /^[A-Z]{2}\.(?:ENT\.)?[A-Za-z0-9]{1,128}$/.raw.userId, and widened types (WhatsAppUserIdUpdate, optionalfrom,from_user_id,system, contactuser_id,profile.username, statusrecipient_user_id).16879fdcfix(whatsapp): model forwarded and product-inquiry context variants in WhatsAppInboundMessage (#723) — chat@4.37.0 — makes everycontextfield optional and addsforwarded,frequently_forwardedandreferred_product. Type-only.Current Python behavior
src/chat_sdk/adapters/whatsapp/adapter.py:225skips every change exceptfield == "messages", souser_id_updateis ignored.:234-249always 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-472and:526-528.parse_message(:1048-1080) indexesraw["message"]["from"]."to"is hard-coded in_send_single_text_message(:797),_send_interactive_message(:851),add_reaction(:921) andremove_reaction(:941). Typing (:947+) andmark_as_read(:1093) address amessage_idonly.whatsapp/types.py:WhatsAppContact(:78-82) requireswa_id;WhatsAppInboundMessage(:140-177) has"context": dict[str, str]and nofrom_user_id/system;WhatsAppWebhookValue(:96-103) has nouser_id_update;WhatsAppRawMessage(:260-272) has nouser_id.grep -rniI 'bsuid\|from_user_id\|user_id_update' src testsfinds nothing relevant.Scope
whatsapp/types.py: optionalfrom, plusfrom_user_id,from_parent_user_id,system; contactwa_idoptional plususer_id,parent_user_id,profile.username;WhatsAppUserIdUpdateandWhatsAppWebhookValue.user_id_update; statusrecipient_user_id/recipient_parent_user_id; aWhatsAppInboundContextTypedDict (total=False:from,id,forwarded,frequently_forwarded,referred_product);WhatsAppRawMessage.user_id; optionalwa_id/user_idon send-response contacts.whatsapp/adapter.pyhelpers with upstream semantics:_fields,async _resolve,async _link,async _handle_user_id_update,async _recipient,_identity_key,_author,_BSUID_PATTERN.handle_webhook: routeuser_id_updatechanges; match the contact per message andawait self._resolve(...); warn and skip when there is no identifier; skipsystemmessages after linking; pass the identity into_handle_inbound_message, the reaction/interactive/button handlers and_build_message.inbound["from"]withidentity.user_id._build_messagestoresraw["user_id"].parse_messagetriesraw.get("user_id"), then_fields(...), and otherwise raisesValidationError("whatsapp", "WhatsApp message has no user identifier").post_messageresolves the recipient once per logical post and passes it through_send_text_message(every chunk),_send_single_text_messageand_send_interactive_message.add_reaction/remove_reactioncall_recipient. Spread the result into the payload in place of"to".Out of scope
send_templateand itsrecipient()use: [4.41/W2] WhatsApp: send_template and typed API errors #237.reply,mark_as_readand downloads: [4.41/W4] WhatsApp & Messenger: mark_as_read, native replies, code fences, guarded downloads #239.adapter.py:202: [4.41/LOG1] Stop logging raw webhook bodies / PII across adapters #187.Porting notes
whatsapp:identity:alias:{phone_number_id}:{identifier}andwhatsapp:identity:route:{phone_number_id}:{user_id}. Route value keys arebsuid/parent/phone; omit absent keys rather than writingNone.??vs||:fields()uses??(port asis not Nonechains);author()deliberately uses||so an empty profile name falls through; identifier lists drop both""andNone.source = inbound.get("from") if type == "system" else Nonefallback = source if source is not None else identity.user_idphone if changed else (phone ?? route.phone)._link:asyncio.gather, but honor them in list order (the first truthy one wins)."skips redundant identity writes for repeat messages"checks._resolve,_handle_user_id_updateand_recipientare swallowed with a warning and fall back to the un-linked identity or the pattern-based recipient, as upstream does._recipienthonors a stored route only when it hasphone,bsuidorparent. Otherwise it returns{"recipient": id}if_BSUID_PATTERN.fullmatch(id), else{"to": id}.handle_webhook, as upstream does, soprocess_messagescheduling is unchanged.phone_number_id), so useuser_id. Note the casing indocs/UPSTREAM_SYNC.md.Tests
Upstream
packages/adapter-whatsapp/src/index.test.tsis not fidelity-mapped. Port intotests/test_whatsapp_webhook.py/tests/test_whatsapp_adapter.py, using memory state and anAsyncMockGraph 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""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).getstill dispatches (warning logged); reactions on a BSUID thread sendrecipient; a forwardedcontextwithoutidparses.Acceptance criteria
KeyError.toand/orrecipientper the stored route.docs/UPSTREAM_SYNC.mdupdated for any divergence.Author.user_idmay be a BSUID.Dependencies
None — can start immediately. Blocks #237 (and, through it, #238).
Verify first
from_user_idand nofrommust currently log"Failed to handle inbound message"without calling the handler (code reading:adapter.py:337inside the:235-249try/except).Metadata
Author.user_idmay be a BSUID, and identity alias/route keys are written to chat state (degrades gracefully on state errors).sync/4.41-w1Part of #184.