Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion gateway/platforms/whatsapp.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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:
Expand Down
51 changes: 51 additions & 0 deletions gateway/whatsapp_identity.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"

132 changes: 132 additions & 0 deletions tests/tools/test_send_message_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand All @@ -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."""

Expand Down Expand Up @@ -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("") == ""


# ---------------------------------------------------------------------------
Expand Down
26 changes: 22 additions & 4 deletions tools/send_message_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
Expand Down Expand Up @@ -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":
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -1057,25 +1065,35 @@ 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:
data = await resp.json()
return {
"success": True,
"platform": "whatsapp",
"chat_id": chat_id,
"chat_id": resolved_chat_id,
"message_id": data.get("messageId"),
}
body = await resp.text()
Expand Down