{"repo":"NousResearch/hermes-agent","public_id":"1945","display_label":"WhatsApp send_message target routing and JID handling","summary":"These issues and fixes concern failures in send_message target resolution for WhatsApp, including unrecognized JID formats (groups, DMs, LIDs), bare phone numbers missing the @s.whatsapp.net suffix, and incorrect fallback to home channel instead of the intended target. Some adjacent items touch email and Slack target recognition in the same routing logic.","subsystem":"agent","counts":{"members":41,"open_issues":2,"open_issues_distinct":2,"open_prs":2,"closed_issues":13,"merged_prs":6,"attempt_prs":20},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":3860},"resolution":{"state":"partially_resolved","by":"pr-3860,pr-35257,pr-50379,pr-53588"},"freshness":{"state":"verified","last_verified_at":"2026-06-27T11:17:13+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.04521951219512194},"fix_edges":[{"issue_number":1945,"pr_number":3860,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":1945,"pr_number":3862,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":8637,"pr_number":50379,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":14486,"pr_number":53588,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":29341,"pr_number":35257,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":34740,"pr_number":35258,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":1945,"title":"[Bug]: Cron jobs to WhatsApp fail with Baileys jidDecode error when deliver uses human-readable contact label","url":null,"state":"closed"},{"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":14486,"title":"[Bug]: WhatsApp bridge fails to resolve contacts from allowlist when using phone numbers","url":null,"state":"closed"},{"kind":"issue","number":17295,"title":"[WhatsApp] send_message tool: \"No home channel set\" even when WHATSAPP_HOME_CHANNEL is configured","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":33547,"title":"send_message should trust channel-directory resolution for plugin platform targets","url":null,"state":"open"},{"kind":"issue","number":34740,"title":"[Bug]: send_message does not recognize explicit email targets and incorrectly falls back to home target resolution","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":"issue","number":67941,"title":"Plugin platforms cannot define explicit target formats for `hermes send`","url":null,"state":"closed"},{"kind":"pr","number":1950,"title":"fix(messaging): resolve explicit delivery labels consistently","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#3860","number":3860},{"kind":"pr","value":"#1945","number":1945}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":2001,"title":"fix(messaging): resolve explicit delivery labels consistently (#1945)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#1945","number":1945}],"category":"wontfix_bydesign","implication":"wontfix"},"merged":false},{"kind":"pr","number":3860,"title":"fix(cron): resolve human-friendly delivery labels via channel directory","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["cron/scheduler.py","tests/cron/test_scheduler.py"]},{"kind":"pr","number":3862,"title":"fix(auth): stop silently falling back to OpenRouter when no provider is configured","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["cli-config.yaml.example","cli.py","cron/scheduler.py","gateway/run.py","hermes_cli/auth.py","tests/test_api_key_providers.py"]},{"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":14738,"title":"fix(tools): resolve whatsapp phone numbers to lid format","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#8639","number":8639},{"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":17189,"title":"fix(whatsapp): normalise JIDs and resolve LID aliases in dm_policy / group_policy allowlist check","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#53588","number":53588},{"kind":"pr","value":"#14486","number":14486},{"kind":"pr","value":"#53588","number":53588}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":17449,"title":"fix(gateway): load WhatsApp home channel from env overrides","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#17295","number":17295},{"kind":"pr","value":"#9220","number":9220},{"kind":"pr","value":"#12520","number":12520}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":27444,"title":"fix(tools): trust resolve_channel_name for plugin platform targets","url":null,"state":"open","close_reason":{"category":"merged_via","implication":"superseded"},"merged":false},{"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":"#20718","number":20718},{"kind":"pr","value":"#18899","number":18899}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":34748,"title":"fix(tools): recognize email addresses as explicit targets in send_message","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"d3724c0be","number":0},{"kind":"pr","value":"#35258","number":35258}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":35257,"title":"fix(update/windows): robustly exclude launcher-shim ancestors from concurrent check","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["hermes_cli/main.py","tests/hermes_cli/test_update_concurrent_quarantine.py"]},{"kind":"pr","number":35258,"title":"fix(tools): recognize email addresses as explicit send_message targets","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["tests/tools/test_send_message_tool.py","tools/send_message_tool.py"],"fix_hunks":[{"file":"tests/tools/test_send_message_tool.py","hunks":["@@ -1220,6 +1220,90 @@ def test_slack_id_not_explicit_for_other_platforms(self):\n         assert _parse_target_ref(\"telegram\", \"C0B0QV5434G\")[2] is False\n \n \n+class TestParseTargetRefEmail:\n+    \"\"\"_parse_target_ref recognizes email addresses as explicit for the email platform.\"\"\"\n+\n+    def test_standard_email_is_explicit(self):\n+        chat_id, thread_id, is_explicit = _parse_target_ref(\"email\", \"[redacted:email]\")\n+        assert chat_id == \"[redacted:email]\"\n+        assert thread_id is None\n+        assert is_explicit is True\n+\n+    def test_email_with_dots_in_local_part(self):\n+        chat_id, _, is_explicit = _parse_target_ref(\"email\", \"[redacted:email]\")\n+        assert chat_id == \"[redacted:email]\"\n+        assert is_explicit is True\n+\n+    def test_email_with_plus_tag(self):\n+        chat_id, _, is_explicit = _parse_target_ref(\"email\", \"[redacted:email]\")\n+        assert chat_id == \"[redacted:email]\"\n+        assert is_explicit is True\n+\n+    def test_email_strips_whitespace(self):\n+        chat_id, _, is_explicit = _parse_target_ref(\"email\", \"  [redacted:email]  \")\n+        assert chat_id == \"[redacted:email]\"\n+        assert is_explicit is True\n+\n+    def test_invalid_email_not_explicit(self):\n+        assert _parse_target_ref(\"email\", \"not-an-email\")[2] is False\n+        assert _parse_target_ref(\"email\", \"@example.com\")[2] is False\n+        assert _parse_target_ref(\"email\", \"user@\")[2] is False\n+        assert _parse_target_ref(\"email\", \"user@.com\")[2] is False\n+\n+    def test_email_not_explicit_for_other_platforms(self):\n+        assert _parse_target_ref(\"telegram\", \"[redacted:email]\")[2] is False\n+        assert _parse_target_ref(\"discord\", \"[redacted:email]\")[2] is False\n+        assert _parse_target_ref(\"slack\", \"[redacted:email]\")[2] is False\n+\n+\n+class TestEmailHomeChannelErrorHint:\n+    \"\"\"The no-home-channel error for email points at the real env var.\n+\n+    Email reads its home channel from EMAIL_HOME_ADDRESS (gateway/config.py),\n+    not the generic EMAIL_HOME_CHANNEL. The error guidance must name the\n+    variable that is actually consulted so users who follow it succeed.\n+    \"\"\"\n+\n+    def test_email_error_names_email_home_address(self):\n+        email_cfg = SimpleNamespace(enabled=True, token=\"\", extra={})\n+        config = SimpleNamespace(\n+            platforms={Platform.EMAIL: email_cfg},\n+            get_home_channel=lambda _platform: None,\n+        )\n+        with patch(\"gateway.config.load_gateway_config\", return_value=config), \\\n+             patch(\"tools.interrupt.is_interrupted\", return_value=False):\n+            result = json.loads(\n+                send_message_tool(\n+                    {\n+                        \"action\": \"send\",\n+                        \"target\": \"email\",\n+                        \"message\": \"hi\",\n+                    }\n+                )\n+            )\n+        assert \"EMAIL_HOME_ADDRESS\" in result[\"error\"]\n+        assert \"EMAIL_HOME_CHANNEL\" not in result[\"error\"]\n+\n+    def test_non_email_platform_keeps_generic_home_channel_hint(self):\n+        telegram_cfg = SimpleNamespace(enabled=True, token=\"***\", extra={})\n+        config = SimpleNamespace(\n+            platforms={Platform.TELEGRAM: telegram_cfg},\n+            get_home_channel=lambda _platform: None,\n+        )\n+        with patch(\"gateway.config.load_gateway_config\", return_value=config), \\\n+             patch(\"tools.interrupt.is_interrupted\", return_value=False):\n+            result = json.loads(\n+                send_message_tool(\n+                    {\n+                        \"action\": \"send\",\n+                        \"target\": \"telegram\",\n+                        \"message\": \"hi\",\n+                    }\n+                )\n+            )\n+        assert \"TELEGRAM_HOME_CHANNEL\" in result[\"error\"]\n+\n+\n class TestSendDiscordThreadId:\n     \"\"\"_send_discord uses thread_id when provided.\"\"\"\n "]},{"file":"tools/send_message_tool.py","hunks":["@@ -40,6 +40,15 @@\n # downstream adapters (signal, etc.) expect.\n _PHONE_PLATFORMS = frozenset({\"signal\", \"sms\", \"whatsapp\"})\n _E164_TARGET_RE = re.compile(r\"^\\s*\\+(\\d{7,15})\\s*$\")\n+# Email addresses — a valid email like \"[redacted:email]\" should be treated as\n+# an explicit target for the email platform, not fall through to channel-name\n+# resolution which has no way to resolve a raw address.\n+_EMAIL_TARGET_RE = re.compile(r\"^\\s*[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}\\s*$\")\n+# Most platforms read their home channel from \"<PLATFORM>_HOME_CHANNEL\", but a\n+# few diverge. Email reads EMAIL_HOME_ADDRESS (see gateway/config.py), so the\n+# generic \"<PLATFORM>_HOME_CHANNEL\" hint would point users at a variable that is\n+# never read. Map the exceptions so the error guidance is actually actionable.\n+_HOME_CHANNEL_ENV_OVERRIDES = {\"email\": \"EMAIL_HOME_ADDRESS\"}\n _IMAGE_EXTS = {\".jpg\", \".jpeg\", \".png\", \".webp\", \".gif\"}\n _VIDEO_EXTS = {\".mp4\", \".mov\", \".avi\", \".mkv\", \".3gp\"}\n _AUDIO_EXTS = {\".ogg\", \".opus\", \".mp3\", \".wav\", \".m4a\", \".flac\"}","@@ -265,10 +274,13 @@ def _handle_send(args):\n             chat_id = home.chat_id\n             used_home_channel = True\n         else:\n+            home_env = _HOME_CHANNEL_ENV_OVERRIDES.get(\n+                platform_name, f\"{platform_name.upper()}_HOME_CHANNEL\"\n+            )\n             return json.dumps({\n                 \"error\": f\"No home channel set for {platform_name} to determine where to send the message. \"\n                 f\"Either specify a channel directly with '{platform_name}:CHANNEL_NAME', \"\n-                f\"or set a home channel via: hermes config set {platform_name.upper()}_HOME_CHANNEL <channel_id>\"\n+                f\"or set a home channel via: hermes config set {home_env} <channel_id>\"\n             })\n \n     duplicate_skip = _maybe_skip_cron_duplicate_send(platform_name, chat_id, thread_id)","@@ -383,6 +395,10 @@ def _parse_target_ref(platform_name: str, target_ref: str):\n         if target_ref.strip().isdigit():\n             return f\"group:{target_ref.strip()}\", None, True\n         return None, None, False\n+    if platform_name == \"email\":\n+        match = _EMAIL_TARGET_RE.fullmatch(target_ref)\n+        if match:\n+            return target_ref.strip(), None, True\n     if platform_name in _PHONE_PLATFORMS:\n         match = _E164_TARGET_RE.fullmatch(target_ref)\n         if match:\n"]}]},{"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},{"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},{"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","implication":"superseded"},"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":51036,"title":"fix(whatsapp): resolve LID↔phone aliases in Python DM allowlist","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#53588","number":53588},{"kind":"pr","value":"#14486","number":14486},{"kind":"pr","value":"#53588","number":53588}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":53588,"title":"fix(whatsapp): resolve phone↔LID aliases in adapter DM/group allowlist","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/platforms/whatsapp_common.py","tests/gateway/test_whatsapp_allowlist_lid_resolution.py"]},{"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":"#59166","number":59166},{"kind":"pr","value":"#41415","number":41415},{"kind":"pr","value":"#41581","number":41581}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":68322,"title":"Fix/plugin target verbatim fallback","url":null,"state":"open","close_reason":{"refs":[{"kind":"commit","value":"0ce93f2d4933","number":0},{"kind":"pr","value":"#67941","number":67941},{"kind":"pr","value":"#68322","number":68322},{"kind":"pr","value":"#68322","number":68322},{"kind":"pr","value":"#68322","number":68322},{"kind":"pr","value":"#67941","number":67941},{"kind":"pr","value":"#64900","number":64900},{"kind":"pr","value":"#374151","number":374151},{"kind":"pr","value":"#67941","number":67941},{"kind":"pr","value":"#68322","number":68322}],"category":"partial_fix","implication":"needs_llm"},"merged":false}],"trust":{"default_tier":"verified","includes_suggested":false}}