WhatsApp send_message target parsing and JID normalization
Cluster 2899 · agent. Data as of
1 open item. Fix merged in #50379 on 21 Jun 2026. Check it covers your case.
The short answer
- Known?
- Yes. 21 related items grouped here, 8 issues already closed.
- Fix event
- A fix was merged to main in PR #50379. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
partially resolved, deciding PR #50379. That is separate from the fix event. - Still open
- 1 issue and 0 PRs.
Next step: read #50379, then check whether your report is the one still open.
Read #50379Pick up a Lab taskMost members concern send_message handling of WhatsApp targets: recognizing JID formats (group, DM, @lid) as explicit targets, normalizing bare phone numbers to JIDs, and avoiding fallback to the home channel. One member is about Slack falling back to a home DM, and one generic WhatsApp failure report is vaguer, so the group is not entirely uniform.
Open issues1
- Issue#37906send_message tool fails for WhatsApp: @lid JIDs not recognized, home channel fallback, and raw phone numbers cause jidDecode error
Open PRs0
Fixes on record
-
#50379 fix(whatsapp): normalize bare phone targets to JIDs (salvage #8639)
files:
gateway/whatsapp_identity.py,plugins/platforms/whatsapp/adapter.py,tests/gateway/test_whatsapp_connect.py,tests/gateway/test_whatsapp_to_jid.pyShow the diff
-
gateway/whatsapp_identity.py@@ -67,6 +67,57 @@ def normalize_whatsapp_identifier(value: str) -> str: ) +# A target that is "just a phone number" — optional leading ``+`` then digits +# and the usual human separators (spaces, dots, dashes, parens). Anything that +# already carries an ``@`` is a fully-qualified JID and must pass through +# untouched (group ``@g.us``, LID ``@lid``, ``status@broadcast`` etc.). +_BARE_PHONE_RE = re.compile(r"^\+?[\d\s().\-]+$") + + +def to_whatsapp_jid(value: str) -> str: + """Normalize an *outbound* WhatsApp target to a bridge-safe JID. + + Baileys' ``jidDecode`` crashes on a bare phone number — it expects a + fully-qualified JID such as ``[redacted:email]``. This helper + is the inverse of :func:`normalize_whatsapp_identifier`: instead of + stripping a JID down to its numeric core for comparison, it *builds* the + JID a send must use. + + Behaviour: + + - ``"+50766715226"`` / ``"50766715226"`` → ``"[redacted:email]"`` + - ``"[redacted:email]"`` → unchanged + - ``"[redacted:email]"`` / ``"130631430344750@lid"`` → unchanged + - ``"user:[redacted:email]"`` style colon-before-``@`` → ``@`` form + - anything that isn't a recognizable bare phone → returned unchanged so + the bridge can surface a meaningful error rather than us mangling it. + + Returns ``""`` for an empty/whitespace input. + """ + if not value: + return "" + + normalized = str(value).strip() + # Drop a device suffix before the domain: ``user:device@domain`` is a + # legacy Baileys shape whose ``:device`` part is not addressable — collapse + # it to ``user@domain``. (Mirrors normalize_whatsapp_identifier, which + # splits the bare id on ``:`` for the same reason.) + if ":" in normalized and "@" in normalized: + prefix, _, domain = normalized.partition("@") + normalized = f"{prefix.split(':', 1)[0]}@{domain}" + + # Already a fully-qualified JID — leave it alone. + if "@" in normalized: + return normalized + + if _BARE_PHONE_RE.fullmatch(normalized): + digits = re.sub(r"\D+", "", normalized) + if digits: + return f"{digits}@s.whatsapp.net" + + return normalized + + def expand_whatsapp_aliases(identifier: str) -> Set[str]: """Resolve WhatsApp phone/LID aliases via bridge session mapping files. -
plugins/platforms/whatsapp/adapter.py@@ -182,6 +182,7 @@ def _terminate_bridge_process(proc, *, force: bool = False) -> None: from gateway.config import Platform, PlatformConfig from gateway.platforms.whatsapp_common import WhatsAppBehaviorMixin +from gateway.whatsapp_identity import to_whatsapp_jid from gateway.platforms.base import ( BasePlatformAdapter, MessageEvent, @@ -726,6 +727,8 @@ async def send( if not content or not content.strip(): return SendResult(success=True, message_id=None) + chat_id = to_whatsapp_jid(chat_id) + try: import aiohttp @@ -785,7 +788,7 @@ async def edit_message( async with self._http_session.post( f"http://127.0.0.1:{self._bridge_port}/edit", json={ - "chatId": chat_id, + "chatId": to_whatsapp_jid(chat_id), "messageId": message_id, "message": content, }, @@ -820,7 +823,7 @@ async def _send_media_to_bridge( return SendResult(success=False, error=f"File not found: {file_path}") payload: Dict[str, Any] = { - "chatId": chat_id, + "chatId": to_whatsapp_jid(chat_id), "filePath": file_path, "mediaType": media_type, } @@ -932,7 +935,7 @@ async def send_typing(self, chat_id: str, metadata=None) -> None: # socket in CLOSE_WAIT. See #18451. async with self._http_session.post( f"http://127.0.0.1:{self._bridge_port}/typing", - json={"chatId": chat_id}, + json={"chatId": to_whatsapp_jid(chat_id)}, timeout=aiohttp.ClientTimeout(total=5) ): pass @@ -950,7 +953,7 @@ async def get_chat_info(self, chat_id: str) -> Dict[str, Any]: import aiohttp async with self._http_session.get( - f"http://127.0.0.1:{self._bridge_port}/chat/{chat_id}", + f"http://127.0.0.1:{self._bridge_port}/chat/{to_whatsapp_jid(chat_id)}", timeout=aiohttp.ClientTimeout(total=10) ) as resp: if resp.status == 200: @@ -1238,10 +1241,11 @@ async def _standalone_send( return {"error": "aiohttp not installed. Run: pip install aiohttp"} try: bridge_port = extra.get("bridge_port", 3000) + normalized_chat_id = to_whatsapp_jid(chat_id) async with aiohttp.ClientSession() as session: async with session.post( f"http://localhost:{bridge_port}/send", - json={"chatId": chat_id, "message": message}, + json={"chatId": normalized_chat_id, "message": message}, timeout=aiohttp.ClientTimeout(total=30), ) as resp: if resp.status == 200: @@ -1249,7 +1253,7 @@ async def _standalone_send( return { "success": True, "platform": "whatsapp", - "chat_id": chat_id, + "chat_id": normalized_chat_id, "message_id": data.get("messageId"), } body = await resp.text() -
tests/gateway/test_whatsapp_connect.py@@ -262,6 +262,51 @@ async def test_send_marks_retryable_fatal_when_managed_bridge_exits(self): mock_fh.close.assert_called_once() assert adapter._bridge_log_fh is None + @pytest.mark.asyncio + async def test_send_normalizes_bare_phone_numbers_to_jid(self): + """A bare phone target (with or without +) becomes a full JID. + + Baileys' jidDecode crashes on a bare number (#8637); the adapter + must rewrite it to ``<digits>@s.whatsapp.net`` before the bridge + call. Regression guard for that crash. + """ + adapter = _make_adapter() + adapter._running = True + adapter._bridge_process = None # unmanaged bridge — skip exit check + + mock_resp = MagicMock() + mock_resp.status = 200 + mock_resp.json = AsyncMock(return_value={"messageId": "msg-1"}) + mock_session = MagicMock() + mock_session.post = MagicMock(return_value=_AsyncCM(mock_resp)) + adapter._http_session = mock_session + + result = await adapter.send("+50766715226", "hello") + + assert result.success is True + payload = mock_session.post.call_args.kwargs["json"] + assert payload["chatId"] == "[redacted:email]" + + @pytest.mark.asyncio + async def test_send_leaves_group_jid_untouched(self): + """A fully-qualified group JID must pass through unchanged.""" + adapter = _make_adapter() + adapter._running = True + adapter._bridge_process = None + + mock_resp = MagicMock() + mock_resp.status = 200 + mock_resp.json = AsyncMock(return_value={"messageId": "msg-2"}) + mock_session = MagicMock() + mock_session.post = MagicMock(return_value=_AsyncCM(mock_resp)) + adapter._http_session = mock_session + + result = await adapter.send("[redacted:email]", "hello") + + assert result.success is True + payload = mock_session.post.call_args.kwargs["json"] + assert payload["chatId"] == "[redacted:email]" + @pytest.mark.asyncio async def test_poll_messages_marks_retryable_fatal_when_managed_bridge_exits(self): adapter = _make_adapter() -
tests/gateway/test_whatsapp_to_jid.py@@ -0,0 +1,56 @@ +"""Unit tests for gateway.whatsapp_identity.to_whatsapp_jid. + +``to_whatsapp_jid`` is the outbound inverse of +``normalize_whatsapp_identifier``: it builds the bridge-safe JID a send +must use. Baileys' ``jidDecode`` crashes on a bare phone number (#8637), +so every outbound target must be rewritten to ``<digits>@s.whatsapp.net`` +before it reaches the bridge. +""" + +import pytest + +from gateway.whatsapp_identity import to_whatsapp_jid + + +class TestToWhatsappJid: + @pytest.mark.parametrize( + "raw,expected", + [ + # bare phone numbers → user JID + ("+50766715226", "[redacted:email]"), + ("50766715226", "[redacted:email]"), + # human-formatted phone numbers get stripped to digits + ("+1 (555) 123-4567", "[redacted:email]"), + ("+1.555.123.4567", "[redacted:email]"), + ], + ) + def test_bare_phone_becomes_user_jid(self, raw, expected): + assert to_whatsapp_jid(raw) == expected + + @pytest.mark.parametrize( + "jid", + [ + "[redacted:email]", # already a user JID + "[redacted:email]", # group JID + "130631430344750@lid", # linked identity + "status@broadcast", # broadcast pseudo-chat + "123@newsletter", # channel/newsletter + ], + ) + def test_fully_qualified_jid_passes_through(self, jid): + assert to_whatsapp_jid(jid) == jid + + def test_device_suffixed_colon_form_collapses_to_at(self): + # ``user:device@domain`` (legacy) → ``user@domain`` + assert to_whatsapp_jid("60123456789:[redacted:email]") == ( + "[redacted:email]" + ) + + @pytest.mark.parametrize("empty", ["", " ", None]) + def test_empty_input_returns_empty(self, empty): + assert to_whatsapp_jid(empty) == "" + + def test_unrecognized_target_passes_through_unchanged(self): + # Not a phone, no ``@`` — leave it for the bridge to reject with a + # meaningful error rather than mangling it into a bogus JID. + assert to_whatsapp_jid("not-a-number") == "not-a-number"
-
11 PRs closed without merging
From the close comments: done elsewhere 4 · duplicate 4 · merged via another change 2 · other 1
- #8639 fix(whatsapp): normalize bare phone targets to JIDs · merged via another change
-
#31398 fix(send_message): accept WhatsApp JID targets · duplicate →
#20718,#18899 -
#37924 fix(whatsapp): recognise @lid and @s.whatsapp.net JIDs in send_message target resolution · done elsewhere →
ea49a79 -
#41415 fix(#41407): recognize WhatsApp JID formats (groups, DMs, LIDs) · merged via another change →
ea49a79,#46622 - #41581 fix(whatsapp): recognize JID targets and reject bare digits (#41407) · other
Show 6 more
-
#41667 fix(whatsapp): normalise bare phone numbers to JIDs before bridge send · done elsewhere →
#8639 -
#41689 Normalize bare WhatsApp phone targets to JIDs · duplicate →
#41667,#41660 -
#41866 fix(whatsapp): normalize bare phone numbers to full JID for outgoing sends (#41660) · done elsewhere →
#8639 -
#45438 fix(send_message): recognize WhatsApp JIDs as explicit send targets · duplicate →
#12620,#37924 -
#59166 fix(send_message): accept WhatsApp LID targets · done elsewhere →
ea49a79,#59136 -
#59199 fix(tools): recognize WhatsApp LID format in send_message target parsing · duplicate →
#59166,#59136,#41415,#41581
Closed issues · 8
- Closed#2899[Bug]: Slack send_message can fall back to home DM instead of explicit channel target
- Closed#8637WhatsApp bridge crashes on bare phone numbers — missing JID normalization
- Closed#18646[Bug]: send_message ignores WhatsApp group target, routes to home channel instead
- Closed#31396[Bug]: send_message silently delivers to home channel and reports success when target resolution fails (WhatsApp LID format)
- Closed#32538[Bug]: WhatsApp Integration Failure
- Closed#41407[Bug] send_message WhatsApp targets: group/LID JIDs silently fall back to home channel
- Closed#41660WhatsApp send fails with bare phone number — missing @s.whatsapp.net JID suffix
- Closed#59136WhatsApp LID format not recognized in send_message tool
How this cluster was grouped
-
Link evidence (
partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text. - Members outside that structural core may belong to a different underlying problem.
-
Pairwise link density
0.09: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means every pair is linked.
Pick up a Lab task
Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.
Open the LabAt a glance
- Subsystem
- agent
- Merged PRs
- #50379
- Closed issues
- 8
- Attempts
- 11 PRs closed without merging
- Fix link checked
- 21 Jun 2026