{"repo":"NousResearch/hermes-agent","public_id":"2899","display_label":"WhatsApp send_message target parsing and JID normalization","summary":"Most 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.","subsystem":"agent","counts":{"members":21,"open_issues":1,"open_issues_distinct":1,"open_prs":0,"closed_issues":8,"merged_prs":1,"attempt_prs":11},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":50379,"merged_at":"2026-06-21T20:32:23+00:00"},"resolution":{"state":"partially_resolved","by":"pr-50379"},"freshness":{"state":"verified","last_verified_at":"2026-06-21T20:32:23+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.08590476190476189},"fix_edges":[{"issue_number":8637,"pr_number":50379,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-06-21T20:32:23+00:00"}],"members":[{"kind":"issue","number":2899,"title":"[Bug]: Slack send_message can fall back to home DM instead of explicit channel target","url":null,"state":"closed"},{"kind":"issue","number":8637,"title":"WhatsApp bridge crashes on bare phone numbers — missing JID normalization","url":null,"state":"closed"},{"kind":"issue","number":18646,"title":"[Bug]: send_message ignores WhatsApp group target, routes to home channel instead","url":null,"state":"closed"},{"kind":"issue","number":31396,"title":"[Bug]: send_message silently delivers to home channel and reports success when target resolution fails (WhatsApp LID format)","url":null,"state":"closed"},{"kind":"issue","number":32538,"title":"[Bug]: WhatsApp Integration Failure","url":null,"state":"closed"},{"kind":"issue","number":37906,"title":"send_message tool fails for WhatsApp: @lid JIDs not recognized, home channel fallback, and raw phone numbers cause jidDecode error","url":null,"state":"open"},{"kind":"issue","number":41407,"title":"[Bug] send_message WhatsApp targets: group/LID JIDs silently fall back to home channel","url":null,"state":"closed"},{"kind":"issue","number":41660,"title":"WhatsApp send fails with bare phone number — missing @s.whatsapp.net JID suffix","url":null,"state":"closed"},{"kind":"issue","number":59136,"title":"WhatsApp LID format not recognized in send_message tool","url":null,"state":"closed"},{"kind":"pr","number":8639,"title":"fix(whatsapp): normalize bare phone targets to JIDs","url":null,"state":"closed","close_reason":{"category":"merged_via","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":31398,"title":"fix(send_message): accept WhatsApp JID targets","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#20718","number":20718},{"kind":"pr","value":"#18899","number":18899}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":37924,"title":"fix(whatsapp): recognise @lid and @s.whatsapp.net JIDs in send_message target resolution","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"ea49a79633d93202d8e495648b2586ee5a1fbecc","number":0}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":41415,"title":"fix(#41407): recognize WhatsApp JID formats (groups, DMs, LIDs)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"ea49a79633d93202d8e495648b2586ee5a1fbecc","number":0},{"kind":"pr","value":"#46622","number":46622}],"category":"merged_via","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":41581,"title":"fix(whatsapp): recognize JID targets and reject bare digits (#41407)","url":null,"state":"closed","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":41667,"title":"fix(whatsapp): normalise bare phone numbers to JIDs before bridge send","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#8639","number":8639}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":41689,"title":"Normalize bare WhatsApp phone targets to JIDs","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#41667","number":41667},{"kind":"pr","value":"#41660","number":41660}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":41866,"title":"fix(whatsapp): normalize bare phone numbers to full JID for outgoing sends (#41660)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#8639","number":8639}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":45438,"title":"fix(send_message): recognize WhatsApp JIDs as explicit send targets","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#12620","number":12620},{"kind":"pr","value":"#37924","number":37924}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":50379,"title":"fix(whatsapp): normalize bare phone targets to JIDs (salvage #8639)","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/whatsapp_identity.py","plugins/platforms/whatsapp/adapter.py","tests/gateway/test_whatsapp_connect.py","tests/gateway/test_whatsapp_to_jid.py"],"fix_hunks":[{"file":"gateway/whatsapp_identity.py","hunks":["@@ -67,6 +67,57 @@ def normalize_whatsapp_identifier(value: str) -> str:\n     )\n \n \n+# A target that is \"just a phone number\" — optional leading ``+`` then digits\n+# and the usual human separators (spaces, dots, dashes, parens). Anything that\n+# already carries an ``@`` is a fully-qualified JID and must pass through\n+# untouched (group ``@g.us``, LID ``@lid``, ``status@broadcast`` etc.).\n+_BARE_PHONE_RE = re.compile(r\"^\\+?[\\d\\s().\\-]+$\")\n+\n+\n+def to_whatsapp_jid(value: str) -> str:\n+    \"\"\"Normalize an *outbound* WhatsApp target to a bridge-safe JID.\n+\n+    Baileys' ``jidDecode`` crashes on a bare phone number — it expects a\n+    fully-qualified JID such as ``[redacted:email]``. This helper\n+    is the inverse of :func:`normalize_whatsapp_identifier`: instead of\n+    stripping a JID down to its numeric core for comparison, it *builds* the\n+    JID a send must use.\n+\n+    Behaviour:\n+\n+    - ``\"+50766715226\"`` / ``\"50766715226\"`` → ``\"[redacted:email]\"``\n+    - ``\"[redacted:email]\"`` → unchanged\n+    - ``\"[redacted:email]\"`` / ``\"130631430344750@lid\"`` → unchanged\n+    - ``\"user:[redacted:email]\"`` style colon-before-``@`` → ``@`` form\n+    - anything that isn't a recognizable bare phone → returned unchanged so\n+      the bridge can surface a meaningful error rather than us mangling it.\n+\n+    Returns ``\"\"`` for an empty/whitespace input.\n+    \"\"\"\n+    if not value:\n+        return \"\"\n+\n+    normalized = str(value).strip()\n+    # Drop a device suffix before the domain: ``user:device@domain`` is a\n+    # legacy Baileys shape whose ``:device`` part is not addressable — collapse\n+    # it to ``user@domain``. (Mirrors normalize_whatsapp_identifier, which\n+    # splits the bare id on ``:`` for the same reason.)\n+    if \":\" in normalized and \"@\" in normalized:\n+        prefix, _, domain = normalized.partition(\"@\")\n+        normalized = f\"{prefix.split(':', 1)[0]}@{domain}\"\n+\n+    # Already a fully-qualified JID — leave it alone.\n+    if \"@\" in normalized:\n+        return normalized\n+\n+    if _BARE_PHONE_RE.fullmatch(normalized):\n+        digits = re.sub(r\"\\D+\", \"\", normalized)\n+        if digits:\n+            return f\"{digits}@s.whatsapp.net\"\n+\n+    return normalized\n+\n+\n def expand_whatsapp_aliases(identifier: str) -> Set[str]:\n     \"\"\"Resolve WhatsApp phone/LID aliases via bridge session mapping files.\n "]},{"file":"plugins/platforms/whatsapp/adapter.py","hunks":["@@ -182,6 +182,7 @@ def _terminate_bridge_process(proc, *, force: bool = False) -> None:\n \n from gateway.config import Platform, PlatformConfig\n from gateway.platforms.whatsapp_common import WhatsAppBehaviorMixin\n+from gateway.whatsapp_identity import to_whatsapp_jid\n from gateway.platforms.base import (\n     BasePlatformAdapter,\n     MessageEvent,","@@ -726,6 +727,8 @@ async def send(\n         if not content or not content.strip():\n             return SendResult(success=True, message_id=None)\n \n+        chat_id = to_whatsapp_jid(chat_id)\n+\n         try:\n             import aiohttp\n ","@@ -785,7 +788,7 @@ async def edit_message(\n             async with self._http_session.post(\n                 f\"http://127.0.0.1:{self._bridge_port}/edit\",\n                 json={\n-                    \"chatId\": chat_id,\n+                    \"chatId\": to_whatsapp_jid(chat_id),\n                     \"messageId\": message_id,\n                     \"message\": content,\n                 },","@@ -820,7 +823,7 @@ async def _send_media_to_bridge(\n                 return SendResult(success=False, error=f\"File not found: {file_path}\")\n \n             payload: Dict[str, Any] = {\n-                \"chatId\": chat_id,\n+                \"chatId\": to_whatsapp_jid(chat_id),\n                 \"filePath\": file_path,\n                 \"mediaType\": media_type,\n             }","@@ -932,7 +935,7 @@ async def send_typing(self, chat_id: str, metadata=None) -> None:\n             # socket in CLOSE_WAIT. See #18451.\n             async with self._http_session.post(\n                 f\"http://127.0.0.1:{self._bridge_port}/typing\",\n-                json={\"chatId\": chat_id},\n+                json={\"chatId\": to_whatsapp_jid(chat_id)},\n                 timeout=aiohttp.ClientTimeout(total=5)\n             ):\n                 pass","@@ -950,7 +953,7 @@ async def get_chat_info(self, chat_id: str) -> Dict[str, Any]:\n             import aiohttp\n \n             async with self._http_session.get(\n-                f\"http://127.0.0.1:{self._bridge_port}/chat/{chat_id}\",\n+                f\"http://127.0.0.1:{self._bridge_port}/chat/{to_whatsapp_jid(chat_id)}\",\n                 timeout=aiohttp.ClientTimeout(total=10)\n             ) as resp:\n                 if resp.status == 200:","@@ -1238,10 +1241,11 @@ async def _standalone_send(\n         return {\"error\": \"aiohttp not installed. Run: pip install aiohttp\"}\n     try:\n         bridge_port = extra.get(\"bridge_port\", 3000)\n+        normalized_chat_id = to_whatsapp_jid(chat_id)\n         async with aiohttp.ClientSession() as session:\n             async with session.post(\n                 f\"http://localhost:{bridge_port}/send\",\n-                json={\"chatId\": chat_id, \"message\": message},\n+                json={\"chatId\": normalized_chat_id, \"message\": message},\n                 timeout=aiohttp.ClientTimeout(total=30),\n             ) as resp:\n                 if resp.status == 200:","@@ -1249,7 +1253,7 @@ async def _standalone_send(\n                     return {\n                         \"success\": True,\n                         \"platform\": \"whatsapp\",\n-                        \"chat_id\": chat_id,\n+                        \"chat_id\": normalized_chat_id,\n                         \"message_id\": data.get(\"messageId\"),\n                     }\n                 body = await resp.text()"]},{"file":"tests/gateway/test_whatsapp_connect.py","hunks":["@@ -262,6 +262,51 @@ async def test_send_marks_retryable_fatal_when_managed_bridge_exits(self):\n         mock_fh.close.assert_called_once()\n         assert adapter._bridge_log_fh is None\n \n+    @pytest.mark.asyncio\n+    async def test_send_normalizes_bare_phone_numbers_to_jid(self):\n+        \"\"\"A bare phone target (with or without +) becomes a full JID.\n+\n+        Baileys' jidDecode crashes on a bare number (#8637); the adapter\n+        must rewrite it to ``<digits>@s.whatsapp.net`` before the bridge\n+        call. Regression guard for that crash.\n+        \"\"\"\n+        adapter = _make_adapter()\n+        adapter._running = True\n+        adapter._bridge_process = None  # unmanaged bridge — skip exit check\n+\n+        mock_resp = MagicMock()\n+        mock_resp.status = 200\n+        mock_resp.json = AsyncMock(return_value={\"messageId\": \"msg-1\"})\n+        mock_session = MagicMock()\n+        mock_session.post = MagicMock(return_value=_AsyncCM(mock_resp))\n+        adapter._http_session = mock_session\n+\n+        result = await adapter.send(\"+50766715226\", \"hello\")\n+\n+        assert result.success is True\n+        payload = mock_session.post.call_args.kwargs[\"json\"]\n+        assert payload[\"chatId\"] == \"[redacted:email]\"\n+\n+    @pytest.mark.asyncio\n+    async def test_send_leaves_group_jid_untouched(self):\n+        \"\"\"A fully-qualified group JID must pass through unchanged.\"\"\"\n+        adapter = _make_adapter()\n+        adapter._running = True\n+        adapter._bridge_process = None\n+\n+        mock_resp = MagicMock()\n+        mock_resp.status = 200\n+        mock_resp.json = AsyncMock(return_value={\"messageId\": \"msg-2\"})\n+        mock_session = MagicMock()\n+        mock_session.post = MagicMock(return_value=_AsyncCM(mock_resp))\n+        adapter._http_session = mock_session\n+\n+        result = await adapter.send(\"[redacted:email]\", \"hello\")\n+\n+        assert result.success is True\n+        payload = mock_session.post.call_args.kwargs[\"json\"]\n+        assert payload[\"chatId\"] == \"[redacted:email]\"\n+\n     @pytest.mark.asyncio\n     async def test_poll_messages_marks_retryable_fatal_when_managed_bridge_exits(self):\n         adapter = _make_adapter()"]},{"file":"tests/gateway/test_whatsapp_to_jid.py","hunks":["@@ -0,0 +1,56 @@\n+\"\"\"Unit tests for gateway.whatsapp_identity.to_whatsapp_jid.\n+\n+``to_whatsapp_jid`` is the outbound inverse of\n+``normalize_whatsapp_identifier``: it builds the bridge-safe JID a send\n+must use. Baileys' ``jidDecode`` crashes on a bare phone number (#8637),\n+so every outbound target must be rewritten to ``<digits>@s.whatsapp.net``\n+before it reaches the bridge.\n+\"\"\"\n+\n+import pytest\n+\n+from gateway.whatsapp_identity import to_whatsapp_jid\n+\n+\n+class TestToWhatsappJid:\n+    @pytest.mark.parametrize(\n+        \"raw,expected\",\n+        [\n+            # bare phone numbers → user JID\n+            (\"+50766715226\", \"[redacted:email]\"),\n+            (\"50766715226\", \"[redacted:email]\"),\n+            # human-formatted phone numbers get stripped to digits\n+            (\"+1 (555) 123-4567\", \"[redacted:email]\"),\n+            (\"+1.555.123.4567\", \"[redacted:email]\"),\n+        ],\n+    )\n+    def test_bare_phone_becomes_user_jid(self, raw, expected):\n+        assert to_whatsapp_jid(raw) == expected\n+\n+    @pytest.mark.parametrize(\n+        \"jid\",\n+        [\n+            \"[redacted:email]\",  # already a user JID\n+            \"[redacted:email]\",    # group JID\n+            \"130631430344750@lid\",         # linked identity\n+            \"status@broadcast\",            # broadcast pseudo-chat\n+            \"123@newsletter\",              # channel/newsletter\n+        ],\n+    )\n+    def test_fully_qualified_jid_passes_through(self, jid):\n+        assert to_whatsapp_jid(jid) == jid\n+\n+    def test_device_suffixed_colon_form_collapses_to_at(self):\n+        # ``user:device@domain`` (legacy) → ``user@domain``\n+        assert to_whatsapp_jid(\"60123456789:[redacted:email]\") == (\n+            \"[redacted:email]\"\n+        )\n+\n+    @pytest.mark.parametrize(\"empty\", [\"\", \"   \", None])\n+    def test_empty_input_returns_empty(self, empty):\n+        assert to_whatsapp_jid(empty) == \"\"\n+\n+    def test_unrecognized_target_passes_through_unchanged(self):\n+        # Not a phone, no ``@`` — leave it for the bridge to reject with a\n+        # meaningful error rather than mangling it into a bogus JID.\n+        assert to_whatsapp_jid(\"not-a-number\") == \"not-a-number\"\n"]}]},{"kind":"pr","number":59166,"title":"fix(send_message): accept WhatsApp LID targets","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"ea49a79633d93202d8e495648b2586ee5a1fbecc","number":0},{"kind":"pr","value":"#59136","number":59136}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":59199,"title":"fix(tools): recognize WhatsApp LID format in send_message target parsing","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#59166","number":59166},{"kind":"pr","value":"#59136","number":59136},{"kind":"pr","value":"#41415","number":41415},{"kind":"pr","value":"#41581","number":41581}],"category":"duplicate_of","implication":"duplicate"},"merged":false}],"trust":{"default_tier":"verified","includes_suggested":false}}