diff --git a/tests/tools/test_send_message_tool.py b/tests/tools/test_send_message_tool.py index 626179de19..644d212785 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 19da4f55af..81e7acd561 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()