diff --git a/gateway/platforms/whatsapp.py b/gateway/platforms/whatsapp.py index 0ca3d41fabbe..006958ee80a6 100644 --- a/gateway/platforms/whatsapp.py +++ b/gateway/platforms/whatsapp.py @@ -888,6 +888,10 @@ async def send( try: import aiohttp + from gateway.whatsapp_identity import resolve_whatsapp_outbound_target + + # Resolve bare phones to LIDs so the bridge accepts the live adapter's payload + resolved_chat_id = resolve_whatsapp_outbound_target(chat_id) # Format and chunk the message formatted = self.format_message(content) @@ -896,7 +900,7 @@ async def send( last_message_id = None for chunk in chunks: payload: Dict[str, Any] = { - "chatId": chat_id, + "chatId": resolved_chat_id, "message": chunk, } if reply_to and last_message_id is None: diff --git a/gateway/whatsapp_identity.py b/gateway/whatsapp_identity.py index 9cd0a6f28bec..7142069872b6 100644 --- a/gateway/whatsapp_identity.py +++ b/gateway/whatsapp_identity.py @@ -153,3 +153,54 @@ def canonical_whatsapp_identifier(identifier: str) -> str: # when no lid-mapping files are present. aliases = expand_whatsapp_aliases(normalized) return min(aliases, key=lambda candidate: (len(candidate), candidate)) + + +def resolve_whatsapp_outbound_target(chat_id: str) -> str: + """Ensure a WhatsApp chat_id has a valid bridge-safe JID suffix. + + If the value explicitly ends with ``@lid``, ``@s.whatsapp.net``, or + ``@g.us`` it is returned as-is. + + If the value contains an unknown suffix (e.g. ``foo@bar.net``), the suffix + is stripped and the prefix (``foo``) is treated as a bare phone number. + + For bare phone numbers, LID resolution is attempted via bridge mapping + files, falling back to the legacy ``@s.whatsapp.net`` suffix if no + mapping is found. + """ + if not chat_id: + return chat_id + + stripped = chat_id.strip() + + # If it contains an '@', it must be a valid known suffix to pass through + if "@" in stripped: + if stripped.endswith(("@lid", "@s.whatsapp.net", "@g.us")): + return stripped + # It has an '@' but isn't a known WhatsApp JID format. + # Strip the unknown suffix and attempt to resolve the prefix as a bare phone number. + bare = stripped.split("@")[0] + else: + bare = stripped + + bare_phone = bare.lstrip("+") + if not bare_phone: + return stripped + + # 1. Try LID resolution from bridge session mapping files + try: + 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: + lid_bare = str(lid).strip().split("@")[0].split(":")[0] + if lid_bare: + # To avoid circular imports or messy logging setups, just return + return f"{lid_bare}@lid" + except Exception: + pass + + # 2. Fallback to legacy @s.whatsapp.net JID format + return f"{bare_phone}@s.whatsapp.net" + diff --git a/tests/tools/test_send_message_tool.py b/tests/tools/test_send_message_tool.py index 3a6ad11fdea1..38da95a7447b 100644 --- a/tests/tools/test_send_message_tool.py +++ b/tests/tools/test_send_message_tool.py @@ -3,6 +3,7 @@ import asyncio import json import os +import pytest import sys from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock, patch @@ -1152,10 +1153,35 @@ def test_sms_e164_is_explicit(self): assert is_explicit is True def test_whatsapp_e164_is_explicit(self): + """WhatsApp E.164 target passes through unmodified for later resolution.""" chat_id, _, is_explicit = _parse_target_ref("whatsapp", "+15551234567") assert chat_id == "+15551234567" assert is_explicit is True + def test_whatsapp_lid_jid_is_explicit(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_whatsapp_legacy_jid_is_explicit(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_whatsapp_group_jid_is_explicit(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_whatsapp_device_lid_is_explicit(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_bare_digits_still_work(self): """Bare digit strings continue to match the generic numeric branch.""" chat_id, _, is_explicit = _parse_target_ref("signal", "15551234567") @@ -1176,6 +1202,60 @@ def test_e164_prefix_only_matches_phone_platforms(self): assert _parse_target_ref("matrix", "+15551234567")[2] is False +class TestWhatsAppSendPayload: + """Tests that _send_whatsapp uses the resolved JID/LID for the bridge payload.""" + + @pytest.mark.asyncio + async def test_send_whatsapp_resolves_phone_to_lid_in_payload(self, monkeypatch, tmp_path): + from tools.send_message_tool import _send_whatsapp + + # Set up a fake LID mapping file + 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)) + + # We'll mock aiohttp.ClientSession.post to capture the JSON payload + captured_payload = {} + captured_url = "" + + class MockResponse: + status = 200 + async def json(self): + return {"messageId": "msg_123"} + async def __aenter__(self): + return self + async def __aexit__(self, exc_type, exc, tb): + pass + + class MockSession: + async def __aenter__(self): + return self + async def __aexit__(self, exc_type, exc, tb): + pass + def post(self, url, json=None, **kwargs): + nonlocal captured_payload, captured_url + captured_url = url + captured_payload = json + return MockResponse() + + monkeypatch.setattr("aiohttp.ClientSession", lambda **kw: MockSession()) + + # Call _send_whatsapp with a bare phone number target + extra_config = {"bridge_port": 3000} + result = await _send_whatsapp(extra_config, "+351912345678", "Hello LID") + + # Verify the success result + assert result.get("success") is True + assert result.get("chat_id") == "77214955630717@lid" + + # Ensure the HTTP payload actually used the resolved LID, not the bare phone + assert captured_url == "http://localhost:3000/send" + assert captured_payload["chatId"] == "77214955630717@lid" + assert captured_payload["message"] == "Hello LID" + class TestParseTargetRefSlack: """_parse_target_ref recognizes Slack channel/user IDs as explicit.""" @@ -1955,6 +2035,58 @@ def session_factory(**kwargs): assert result2["success"] is True # Only one session opened (thread creation) — no probe session this time # (verified by not raising from our side_effect exhaustion) +from gateway.whatsapp_identity import resolve_whatsapp_outbound_target + + +class TestResolveWhatsappOutboundTarget: + """Tests for resolve_whatsapp_outbound_target helper in whatsapp_identity.""" + + def test_lid_jid_passes_through(self): + assert resolve_whatsapp_outbound_target("77214955630717@lid") == "77214955630717@lid" + + def test_legacy_jid_passes_through(self): + assert resolve_whatsapp_outbound_target("351912345678@s.whatsapp.net") == "351912345678@s.whatsapp.net" + + def test_group_jid_passes_through(self): + assert resolve_whatsapp_outbound_target("120363123456789@g.us") == "120363123456789@g.us" + + def test_strict_suffix_validation_falls_back_to_phone(self, tmp_path, monkeypatch): + """If it has an @ but isn't a known suffix, it extracts the prefix and falls back to phone resolution.""" + # Isolate HERMES_HOME so we don't accidentally match a real mapping + session_dir = tmp_path / "whatsapp" / "session" + session_dir.mkdir(parents=True) + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + + # e.g., someone passed 15551234567@unknown.net + # the helper extracts 15551234567 and falls back to legacy JID since no map exists. + assert resolve_whatsapp_outbound_target("15551234567@unknown.net") == "15551234567@s.whatsapp.net" + + def test_bare_number_gets_resolved(self, tmp_path, monkeypatch): + 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 resolve_whatsapp_outbound_target("351912345678") == "77214955630717@lid" + + def test_bare_number_gets_resolved_strips_plus(self, tmp_path, monkeypatch): + 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 resolve_whatsapp_outbound_target("+351912345678") == "77214955630717@lid" + + def test_bare_number_fallback(self, tmp_path, monkeypatch): + session_dir = tmp_path / "whatsapp" / "session" + session_dir.mkdir(parents=True) + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + assert resolve_whatsapp_outbound_target("15551234567") == "15551234567@s.whatsapp.net" + + def test_empty_passes_through(self): + assert resolve_whatsapp_outbound_target("") == "" # --------------------------------------------------------------------------- diff --git a/tools/send_message_tool.py b/tools/send_message_tool.py index 9ea0b9af41b5..6ef381b0565f 100644 --- a/tools/send_message_tool.py +++ b/tools/send_message_tool.py @@ -40,6 +40,7 @@ # 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", ".flac"} @@ -339,6 +340,9 @@ async def _open_slack_dm(token, user_id): return json.dumps(_error(f"Send failed: {e}")) + + + 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": @@ -386,9 +390,13 @@ 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 + # Preserve the leading '+' — signal-cli and sms adapters # expect E.164 format for direct recipients. return target_ref.strip(), None, True + # WhatsApp JIDs end with @lid, @s.whatsapp.net, or @g.us. + # Pass them through as-is — the bridge validates format on its end. + if platform_name == "whatsapp" and target_ref.strip().endswith(("@lid", "@s.whatsapp.net", "@g.us")): + 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 @@ -1057,17 +1065,27 @@ 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: + from gateway.whatsapp_identity import resolve_whatsapp_outbound_target + # Ensure chat_id is a valid WhatsApp JID by resolving through central identity helper. + resolved_chat_id = resolve_whatsapp_outbound_target(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: @@ -1075,7 +1093,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()