From b668f730f5ebd5c99c79a66a8bf3865b3c6421bc Mon Sep 17 00:00:00 2001 From: Yshnav Date: Fri, 24 Apr 2026 02:17:51 +0530 Subject: [PATCH] fix(tools): resolve whatsapp phone numbers to lid format Adds a resolution pipeline `_resolve_whatsapp_phone_to_jid` to the `send_message_tool`. Updates `_parse_target_ref` and `_send_whatsapp` to use the resolution pipeline when bare phone numbers are provided. Passes through already valid JIDs (ending with `@lid`, `@s.whatsapp.net`, `@g.us`). This resolves the issue where AI tool calls and cron reminders failed to send WhatsApp messages because they passed bare phone numbers instead of the newly enforced Linked Identity Device (LID) formats. It checks the local bridge mapping files (`lid-mapping-{phone}.json`) to gracefully convert phone numbers to LIDs. Comprehensive unit tests were added in `test_send_message_tool.py` testing legacy JIDs, LIDs, group JIDs, and fallback mechanisms. Fixes: #14486 --- tests/tools/test_send_message_tool.py | 108 +++++++++++++++++++++++++- tools/send_message_tool.py | 85 +++++++++++++++++++- 2 files changed, 187 insertions(+), 6 deletions(-) diff --git a/tests/tools/test_send_message_tool.py b/tests/tools/test_send_message_tool.py index 626179de1..644d21278 100644 --- a/tests/tools/test_send_message_tool.py +++ b/tests/tools/test_send_message_tool.py @@ -786,8 +786,11 @@ class TestParseTargetRefE164: assert is_explicit is True def test_whatsapp_e164_is_explicit(self): - chat_id, _, is_explicit = _parse_target_ref("whatsapp", "+15551234567") - assert chat_id == "+15551234567" + """WhatsApp E.164 resolves to @s.whatsapp.net JID when no mapping exists.""" + with patch("tools.send_message_tool._resolve_whatsapp_phone_to_jid", + return_value="15551234567@s.whatsapp.net"): + chat_id, _, is_explicit = _parse_target_ref("whatsapp", "+15551234567") + assert chat_id == "15551234567@s.whatsapp.net" assert is_explicit is True def test_signal_bare_digits_still_work(self): @@ -810,6 +813,107 @@ class TestParseTargetRefE164: assert _parse_target_ref("matrix", "+15551234567")[2] is False +class TestWhatsAppPhoneToLidResolution: + """Tests for WhatsApp phone→LID resolution in _parse_target_ref and helpers.""" + + def test_phone_resolves_to_lid_via_mapping_file(self, tmp_path, monkeypatch): + """Phone number is resolved to LID@lid when a mapping file exists.""" + session_dir = tmp_path / "whatsapp" / "session" + session_dir.mkdir(parents=True) + (session_dir / "lid-mapping-351912345678.json").write_text( + '"77214955630717"', encoding="utf-8" + ) + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + + chat_id, _, is_explicit = _parse_target_ref("whatsapp", "+351912345678") + assert chat_id == "77214955630717@lid" + assert is_explicit is True + + def test_phone_falls_back_to_legacy_jid(self, tmp_path, monkeypatch): + """Phone number falls back to @s.whatsapp.net when no mapping exists.""" + # Ensure session dir exists but has no mapping for this phone + session_dir = tmp_path / "whatsapp" / "session" + session_dir.mkdir(parents=True) + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + + chat_id, _, is_explicit = _parse_target_ref("whatsapp", "+15551234567") + assert chat_id == "15551234567@s.whatsapp.net" + assert is_explicit is True + + def test_lid_jid_is_explicit_target(self): + """A @lid JID is recognized as an explicit WhatsApp target.""" + chat_id, _, is_explicit = _parse_target_ref("whatsapp", "77214955630717@lid") + assert chat_id == "77214955630717@lid" + assert is_explicit is True + + def test_legacy_jid_is_explicit_target(self): + """A @s.whatsapp.net JID is recognized as an explicit WhatsApp target.""" + chat_id, _, is_explicit = _parse_target_ref("whatsapp", "351912345678@s.whatsapp.net") + assert chat_id == "351912345678@s.whatsapp.net" + assert is_explicit is True + + def test_group_jid_is_explicit_target(self): + """A @g.us group JID is recognized as an explicit WhatsApp target.""" + chat_id, _, is_explicit = _parse_target_ref("whatsapp", "120363123456789@g.us") + assert chat_id == "120363123456789@g.us" + assert is_explicit is True + + def test_device_lid_is_explicit_target(self): + """A device-qualified LID (with :device suffix) passes through.""" + chat_id, _, is_explicit = _parse_target_ref("whatsapp", "77214955630717:15@lid") + assert chat_id == "77214955630717:15@lid" + assert is_explicit is True + + def test_signal_e164_still_preserves_plus(self): + """Signal E.164 behavior is unchanged (no JID resolution).""" + chat_id, _, is_explicit = _parse_target_ref("signal", "+41791234567") + assert chat_id == "+41791234567" + assert is_explicit is True + + def test_sms_e164_still_preserves_plus(self): + """SMS E.164 behavior is unchanged (no JID resolution).""" + chat_id, _, is_explicit = _parse_target_ref("sms", "+15551234567") + assert chat_id == "+15551234567" + assert is_explicit is True + + +class TestEnsureWhatsAppJid: + """Tests for _ensure_whatsapp_jid helper.""" + + def test_lid_jid_passes_through(self): + from tools.send_message_tool import _ensure_whatsapp_jid + assert _ensure_whatsapp_jid("77214955630717@lid") == "77214955630717@lid" + + def test_legacy_jid_passes_through(self): + from tools.send_message_tool import _ensure_whatsapp_jid + assert _ensure_whatsapp_jid("351912345678@s.whatsapp.net") == "351912345678@s.whatsapp.net" + + def test_group_jid_passes_through(self): + from tools.send_message_tool import _ensure_whatsapp_jid + assert _ensure_whatsapp_jid("120363123456789@g.us") == "120363123456789@g.us" + + def test_bare_number_gets_resolved(self, tmp_path, monkeypatch): + from tools.send_message_tool import _ensure_whatsapp_jid + session_dir = tmp_path / "whatsapp" / "session" + session_dir.mkdir(parents=True) + (session_dir / "lid-mapping-351912345678.json").write_text( + '"77214955630717"', encoding="utf-8" + ) + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + assert _ensure_whatsapp_jid("351912345678") == "77214955630717@lid" + + def test_bare_number_fallback(self, tmp_path, monkeypatch): + from tools.send_message_tool import _ensure_whatsapp_jid + session_dir = tmp_path / "whatsapp" / "session" + session_dir.mkdir(parents=True) + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + assert _ensure_whatsapp_jid("15551234567") == "15551234567@s.whatsapp.net" + + def test_empty_passes_through(self): + from tools.send_message_tool import _ensure_whatsapp_jid + assert _ensure_whatsapp_jid("") == "" + + class TestSendDiscordThreadId: """_send_discord uses thread_id when provided.""" diff --git a/tools/send_message_tool.py b/tools/send_message_tool.py index 19da4f55a..81e7acd56 100644 --- a/tools/send_message_tool.py +++ b/tools/send_message_tool.py @@ -30,6 +30,7 @@ _NUMERIC_TOPIC_RE = _TELEGRAM_TOPIC_TARGET_RE # downstream adapters (signal, etc.) expect. _PHONE_PLATFORMS = frozenset({"signal", "sms", "whatsapp"}) _E164_TARGET_RE = re.compile(r"^\s*\+(\d{7,15})\s*$") + _IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".webp", ".gif"} _VIDEO_EXTS = {".mp4", ".mov", ".avi", ".mkv", ".3gp"} _AUDIO_EXTS = {".ogg", ".opus", ".mp3", ".wav", ".m4a"} @@ -304,6 +305,64 @@ def _handle_send(args): return json.dumps(_error(f"Send failed: {e}")) +def _resolve_whatsapp_phone_to_jid(phone: str) -> str: + """Resolve an E.164 phone number to a WhatsApp JID. + + Resolution order: + 1. Bridge session ``lid-mapping-{phone}.json`` → ``{lid}@lid`` + 2. Fallback to legacy ``{phone}@s.whatsapp.net`` + + The bridge session directory already maintains phone→LID mapping files + created during the Baileys handshake. This is the same data that + ``allowlist.js`` and ``_expand_whatsapp_auth_aliases`` use for incoming + message authorization. + """ + from pathlib import Path + + # Strip leading '+' to get the bare phone number + bare_phone = phone.lstrip("+").strip() + if not bare_phone: + return phone + + # 1. Try LID resolution from bridge session mapping files + try: + from hermes_constants import get_hermes_home + session_dir = get_hermes_home() / "whatsapp" / "session" + mapping_path = session_dir / f"lid-mapping-{bare_phone}.json" + if mapping_path.exists(): + lid = json.loads(mapping_path.read_text(encoding="utf-8")) + if lid: + # Strip any existing JID suffix from the stored value + lid_bare = str(lid).strip().split("@")[0].split(":")[0] + if lid_bare: + logger.debug("Resolved WhatsApp phone %s → LID %s", bare_phone, lid_bare) + return f"{lid_bare}@lid" + except Exception as e: + logger.debug("WhatsApp LID mapping lookup failed for %s: %s", bare_phone, e) + + # 2. Fallback to legacy @s.whatsapp.net JID format + logger.debug("No LID mapping found for WhatsApp phone %s, using legacy JID", bare_phone) + return f"{bare_phone}@s.whatsapp.net" + + +def _ensure_whatsapp_jid(chat_id: str) -> str: + """Ensure a WhatsApp chat_id has a valid JID suffix. + + If the value already ends with ``@lid``, ``@s.whatsapp.net``, or + ``@g.us`` it is returned as-is. Otherwise, LID resolution is + attempted via bridge mapping files, falling back to the legacy + ``@s.whatsapp.net`` suffix. + """ + if not chat_id: + return chat_id + stripped = chat_id.strip() + # Already a valid JID + if "@" in stripped: + return stripped + # Bare number — resolve via phone→LID pipeline + return _resolve_whatsapp_phone_to_jid(stripped) + + def _parse_target_ref(platform_name: str, target_ref: str): """Parse a tool target into chat_id/thread_id and whether it is explicit.""" if platform_name == "telegram": @@ -325,9 +384,19 @@ def _parse_target_ref(platform_name: str, target_ref: str): if platform_name in _PHONE_PLATFORMS: match = _E164_TARGET_RE.fullmatch(target_ref) if match: - # Preserve the leading '+' — signal-cli and sms/whatsapp adapters + if platform_name == "whatsapp": + # WhatsApp has migrated to LID (Linked Identity Device) + # format. Resolve the phone number to the correct JID + # via the bridge session's lid-mapping files. + resolved_jid = _resolve_whatsapp_phone_to_jid(target_ref.strip()) + return resolved_jid, None, True + # Preserve the leading '+' — signal-cli and sms adapters # expect E.164 format for direct recipients. return target_ref.strip(), None, True + # WhatsApp JIDs already contain '@' (e.g. 123@lid, 123@s.whatsapp.net, 123@g.us). + # Pass them through as-is — the bridge validates format on its end. + if platform_name == "whatsapp" and "@" in target_ref.strip(): + return target_ref.strip(), None, True if target_ref.lstrip("-").isdigit(): return target_ref, None, True # Matrix room IDs (start with !) and user IDs (start with @) are explicit @@ -959,17 +1028,25 @@ async def _send_slack(token, chat_id, message): async def _send_whatsapp(extra, chat_id, message): - """Send via the local WhatsApp bridge HTTP API.""" + """Send via the local WhatsApp bridge HTTP API. + + If *chat_id* lacks a JID suffix (``@lid`` or ``@s.whatsapp.net``), + a best-effort resolution is attempted via the bridge session's + ``lid-mapping-{phone}.json`` files. If no mapping is found the + legacy ``@s.whatsapp.net`` suffix is appended as a last resort. + """ try: import aiohttp except ImportError: return {"error": "aiohttp not installed. Run: pip install aiohttp"} try: + # Ensure chat_id is a valid WhatsApp JID. + resolved_chat_id = _ensure_whatsapp_jid(chat_id) bridge_port = extra.get("bridge_port", 3000) async with aiohttp.ClientSession() as session: async with session.post( f"http://localhost:{bridge_port}/send", - json={"chatId": chat_id, "message": message}, + json={"chatId": resolved_chat_id, "message": message}, timeout=aiohttp.ClientTimeout(total=30), ) as resp: if resp.status == 200: @@ -977,7 +1054,7 @@ async def _send_whatsapp(extra, chat_id, message): return { "success": True, "platform": "whatsapp", - "chat_id": chat_id, + "chat_id": resolved_chat_id, "message_id": data.get("messageId"), } body = await resp.text()