mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-23 10:42:00 +00:00
fix(whatsapp): normalize bare phone targets to JIDs before bridge send
Baileys' jidDecode crashes ("Cannot destructure property 'user' of
jidDecode(...) as it is undefined") when handed a bare phone number, so
sending a WhatsApp message to +50766715226 / 50766715226 returned HTTP
500 and never delivered (#8637).
Add to_whatsapp_jid() to gateway/whatsapp_identity.py — the outbound
inverse of normalize_whatsapp_identifier: it builds the JID a send must
use (bare phone -> <digits>@s.whatsapp.net) and passes through already
qualified JIDs (@g.us, @lid, status@broadcast, @newsletter) unchanged.
Wire it at every outbound bridge call site in the WhatsApp adapter
(send, edit, media, typing, get_chat_info, and the standalone cron /
send_message sender).
Co-authored-by: Hermes Agent <noreply@nousresearch.com>
This commit is contained in:
parent
f72690825e
commit
a4b1554c73
4 changed files with 162 additions and 6 deletions
|
|
@ -67,6 +67,57 @@ def normalize_whatsapp_identifier(value: str) -> str:
|
|||
)
|
||||
|
||||
|
||||
# A target that is "just a phone number" — optional leading ``+`` then digits
|
||||
# and the usual human separators (spaces, dots, dashes, parens). Anything that
|
||||
# already carries an ``@`` is a fully-qualified JID and must pass through
|
||||
# untouched (group ``@g.us``, LID ``@lid``, ``status@broadcast`` etc.).
|
||||
_BARE_PHONE_RE = re.compile(r"^\+?[\d\s().\-]+$")
|
||||
|
||||
|
||||
def to_whatsapp_jid(value: str) -> str:
|
||||
"""Normalize an *outbound* WhatsApp target to a bridge-safe JID.
|
||||
|
||||
Baileys' ``jidDecode`` crashes on a bare phone number — it expects a
|
||||
fully-qualified JID such as ``50766715226@s.whatsapp.net``. This helper
|
||||
is the inverse of :func:`normalize_whatsapp_identifier`: instead of
|
||||
stripping a JID down to its numeric core for comparison, it *builds* the
|
||||
JID a send must use.
|
||||
|
||||
Behaviour:
|
||||
|
||||
- ``"+50766715226"`` / ``"50766715226"`` → ``"50766715226@s.whatsapp.net"``
|
||||
- ``"50766715226@s.whatsapp.net"`` → unchanged
|
||||
- ``"group-id@g.us"`` / ``"130631430344750@lid"`` → unchanged
|
||||
- ``"user:device@s.whatsapp.net"`` style colon-before-``@`` → ``@`` form
|
||||
- anything that isn't a recognizable bare phone → returned unchanged so
|
||||
the bridge can surface a meaningful error rather than us mangling it.
|
||||
|
||||
Returns ``""`` for an empty/whitespace input.
|
||||
"""
|
||||
if not value:
|
||||
return ""
|
||||
|
||||
normalized = str(value).strip()
|
||||
# Drop a device suffix before the domain: ``user:device@domain`` is a
|
||||
# legacy Baileys shape whose ``:device`` part is not addressable — collapse
|
||||
# it to ``user@domain``. (Mirrors normalize_whatsapp_identifier, which
|
||||
# splits the bare id on ``:`` for the same reason.)
|
||||
if ":" in normalized and "@" in normalized:
|
||||
prefix, _, domain = normalized.partition("@")
|
||||
normalized = f"{prefix.split(':', 1)[0]}@{domain}"
|
||||
|
||||
# Already a fully-qualified JID — leave it alone.
|
||||
if "@" in normalized:
|
||||
return normalized
|
||||
|
||||
if _BARE_PHONE_RE.fullmatch(normalized):
|
||||
digits = re.sub(r"\D+", "", normalized)
|
||||
if digits:
|
||||
return f"{digits}@s.whatsapp.net"
|
||||
|
||||
return normalized
|
||||
|
||||
|
||||
def expand_whatsapp_aliases(identifier: str) -> Set[str]:
|
||||
"""Resolve WhatsApp phone/LID aliases via bridge session mapping files.
|
||||
|
||||
|
|
|
|||
|
|
@ -182,6 +182,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[3]))
|
|||
|
||||
from gateway.config import Platform, PlatformConfig
|
||||
from gateway.platforms.whatsapp_common import WhatsAppBehaviorMixin
|
||||
from gateway.whatsapp_identity import to_whatsapp_jid
|
||||
from gateway.platforms.base import (
|
||||
BasePlatformAdapter,
|
||||
MessageEvent,
|
||||
|
|
@ -726,6 +727,8 @@ class WhatsAppAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter):
|
|||
if not content or not content.strip():
|
||||
return SendResult(success=True, message_id=None)
|
||||
|
||||
chat_id = to_whatsapp_jid(chat_id)
|
||||
|
||||
try:
|
||||
import aiohttp
|
||||
|
||||
|
|
@ -785,7 +788,7 @@ class WhatsAppAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter):
|
|||
async with self._http_session.post(
|
||||
f"http://127.0.0.1:{self._bridge_port}/edit",
|
||||
json={
|
||||
"chatId": chat_id,
|
||||
"chatId": to_whatsapp_jid(chat_id),
|
||||
"messageId": message_id,
|
||||
"message": content,
|
||||
},
|
||||
|
|
@ -820,7 +823,7 @@ class WhatsAppAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter):
|
|||
return SendResult(success=False, error=f"File not found: {file_path}")
|
||||
|
||||
payload: Dict[str, Any] = {
|
||||
"chatId": chat_id,
|
||||
"chatId": to_whatsapp_jid(chat_id),
|
||||
"filePath": file_path,
|
||||
"mediaType": media_type,
|
||||
}
|
||||
|
|
@ -932,7 +935,7 @@ class WhatsAppAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter):
|
|||
# socket in CLOSE_WAIT. See #18451.
|
||||
async with self._http_session.post(
|
||||
f"http://127.0.0.1:{self._bridge_port}/typing",
|
||||
json={"chatId": chat_id},
|
||||
json={"chatId": to_whatsapp_jid(chat_id)},
|
||||
timeout=aiohttp.ClientTimeout(total=5)
|
||||
):
|
||||
pass
|
||||
|
|
@ -950,7 +953,7 @@ class WhatsAppAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter):
|
|||
import aiohttp
|
||||
|
||||
async with self._http_session.get(
|
||||
f"http://127.0.0.1:{self._bridge_port}/chat/{chat_id}",
|
||||
f"http://127.0.0.1:{self._bridge_port}/chat/{to_whatsapp_jid(chat_id)}",
|
||||
timeout=aiohttp.ClientTimeout(total=10)
|
||||
) as resp:
|
||||
if resp.status == 200:
|
||||
|
|
@ -1238,10 +1241,11 @@ async def _standalone_send(
|
|||
return {"error": "aiohttp not installed. Run: pip install aiohttp"}
|
||||
try:
|
||||
bridge_port = extra.get("bridge_port", 3000)
|
||||
normalized_chat_id = to_whatsapp_jid(chat_id)
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.post(
|
||||
f"http://localhost:{bridge_port}/send",
|
||||
json={"chatId": chat_id, "message": message},
|
||||
json={"chatId": normalized_chat_id, "message": message},
|
||||
timeout=aiohttp.ClientTimeout(total=30),
|
||||
) as resp:
|
||||
if resp.status == 200:
|
||||
|
|
@ -1249,7 +1253,7 @@ async def _standalone_send(
|
|||
return {
|
||||
"success": True,
|
||||
"platform": "whatsapp",
|
||||
"chat_id": chat_id,
|
||||
"chat_id": normalized_chat_id,
|
||||
"message_id": data.get("messageId"),
|
||||
}
|
||||
body = await resp.text()
|
||||
|
|
|
|||
|
|
@ -262,6 +262,51 @@ class TestBridgeRuntimeFailure:
|
|||
mock_fh.close.assert_called_once()
|
||||
assert adapter._bridge_log_fh is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_normalizes_bare_phone_numbers_to_jid(self):
|
||||
"""A bare phone target (with or without +) becomes a full JID.
|
||||
|
||||
Baileys' jidDecode crashes on a bare number (#8637); the adapter
|
||||
must rewrite it to ``<digits>@s.whatsapp.net`` before the bridge
|
||||
call. Regression guard for that crash.
|
||||
"""
|
||||
adapter = _make_adapter()
|
||||
adapter._running = True
|
||||
adapter._bridge_process = None # unmanaged bridge — skip exit check
|
||||
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status = 200
|
||||
mock_resp.json = AsyncMock(return_value={"messageId": "msg-1"})
|
||||
mock_session = MagicMock()
|
||||
mock_session.post = MagicMock(return_value=_AsyncCM(mock_resp))
|
||||
adapter._http_session = mock_session
|
||||
|
||||
result = await adapter.send("+50766715226", "hello")
|
||||
|
||||
assert result.success is True
|
||||
payload = mock_session.post.call_args.kwargs["json"]
|
||||
assert payload["chatId"] == "50766715226@s.whatsapp.net"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_leaves_group_jid_untouched(self):
|
||||
"""A fully-qualified group JID must pass through unchanged."""
|
||||
adapter = _make_adapter()
|
||||
adapter._running = True
|
||||
adapter._bridge_process = None
|
||||
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status = 200
|
||||
mock_resp.json = AsyncMock(return_value={"messageId": "msg-2"})
|
||||
mock_session = MagicMock()
|
||||
mock_session.post = MagicMock(return_value=_AsyncCM(mock_resp))
|
||||
adapter._http_session = mock_session
|
||||
|
||||
result = await adapter.send("123456789-987654321@g.us", "hello")
|
||||
|
||||
assert result.success is True
|
||||
payload = mock_session.post.call_args.kwargs["json"]
|
||||
assert payload["chatId"] == "123456789-987654321@g.us"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_poll_messages_marks_retryable_fatal_when_managed_bridge_exits(self):
|
||||
adapter = _make_adapter()
|
||||
|
|
|
|||
56
tests/gateway/test_whatsapp_to_jid.py
Normal file
56
tests/gateway/test_whatsapp_to_jid.py
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
"""Unit tests for gateway.whatsapp_identity.to_whatsapp_jid.
|
||||
|
||||
``to_whatsapp_jid`` is the outbound inverse of
|
||||
``normalize_whatsapp_identifier``: it builds the bridge-safe JID a send
|
||||
must use. Baileys' ``jidDecode`` crashes on a bare phone number (#8637),
|
||||
so every outbound target must be rewritten to ``<digits>@s.whatsapp.net``
|
||||
before it reaches the bridge.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from gateway.whatsapp_identity import to_whatsapp_jid
|
||||
|
||||
|
||||
class TestToWhatsappJid:
|
||||
@pytest.mark.parametrize(
|
||||
"raw,expected",
|
||||
[
|
||||
# bare phone numbers → user JID
|
||||
("+50766715226", "50766715226@s.whatsapp.net"),
|
||||
("50766715226", "50766715226@s.whatsapp.net"),
|
||||
# human-formatted phone numbers get stripped to digits
|
||||
("+1 (555) 123-4567", "15551234567@s.whatsapp.net"),
|
||||
("+1.555.123.4567", "15551234567@s.whatsapp.net"),
|
||||
],
|
||||
)
|
||||
def test_bare_phone_becomes_user_jid(self, raw, expected):
|
||||
assert to_whatsapp_jid(raw) == expected
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"jid",
|
||||
[
|
||||
"50766715226@s.whatsapp.net", # already a user JID
|
||||
"123456789-987654321@g.us", # group JID
|
||||
"130631430344750@lid", # linked identity
|
||||
"status@broadcast", # broadcast pseudo-chat
|
||||
"123@newsletter", # channel/newsletter
|
||||
],
|
||||
)
|
||||
def test_fully_qualified_jid_passes_through(self, jid):
|
||||
assert to_whatsapp_jid(jid) == jid
|
||||
|
||||
def test_device_suffixed_colon_form_collapses_to_at(self):
|
||||
# ``user:device@domain`` (legacy) → ``user@domain``
|
||||
assert to_whatsapp_jid("60123456789:47@s.whatsapp.net") == (
|
||||
"60123456789@s.whatsapp.net"
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize("empty", ["", " ", None])
|
||||
def test_empty_input_returns_empty(self, empty):
|
||||
assert to_whatsapp_jid(empty) == ""
|
||||
|
||||
def test_unrecognized_target_passes_through_unchanged(self):
|
||||
# Not a phone, no ``@`` — leave it for the bridge to reject with a
|
||||
# meaningful error rather than mangling it into a bogus JID.
|
||||
assert to_whatsapp_jid("not-a-number") == "not-a-number"
|
||||
Loading…
Add table
Add a link
Reference in a new issue