This commit is contained in:
Sri Vaishnav Vutukuri 2026-04-24 19:25:21 -05:00 committed by GitHub
commit 3ae1f651c9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 187 additions and 6 deletions

View file

@ -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."""

View file

@ -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 phoneLID 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()