mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
Merge b668f730f5 into 05d8f11085
This commit is contained in:
commit
3ae1f651c9
2 changed files with 187 additions and 6 deletions
|
|
@ -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."""
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue