WhatsApp send_message target routing and JID handling
NousResearch/hermes-agent · cluster 1945 · agent
Data as of
4 open items. Fix merged in #3860. Check it covers your case.
The short answer
- Known?
- Yes. 41 related items grouped here, 13 issues already closed.
- Fix event
- A fix was merged to main in PR #3860. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
partially resolved, deciding PRs #3860, #35257, #50379, #53588. That is separate from the fix event. - Still open
- 2 issues and 2 PRs.
Next step: read #3860, then check whether your report is one of the 2 still open.
Read #3860Pick up a Lab taskThese issues and fixes concern failures in send_message target resolution for WhatsApp, including unrecognized JID formats (groups, DMs, LIDs), bare phone numbers missing the @s.whatsapp.net suffix, and incorrect fallback to home channel instead of the intended target. Some adjacent items touch email and Slack target recognition in the same routing logic.
Open issues2
Open PRs2
Fixes on record
-
#3860 fix(cron): resolve human-friendly delivery labels via channel directory
files:
cron/scheduler.py,tests/cron/test_scheduler.py -
#3862 fix(auth): stop silently falling back to OpenRouter when no provider is configured
files:
cli-config.yaml.example,cli.py,cron/scheduler.py,gateway/run.py,hermes_cli/auth.py,tests/test_api_key_providers.py -
#35257 fix(update/windows): robustly exclude launcher-shim ancestors from concurrent check
files:
hermes_cli/main.py,tests/hermes_cli/test_update_concurrent_quarantine.py -
#35258 fix(tools): recognize email addresses as explicit send_message targets
files:
tests/tools/test_send_message_tool.py,tools/send_message_tool.pyShow the diff
-
tests/tools/test_send_message_tool.py@@ -1220,6 +1220,90 @@ def test_slack_id_not_explicit_for_other_platforms(self): assert _parse_target_ref("telegram", "C0B0QV5434G")[2] is False +class TestParseTargetRefEmail: + """_parse_target_ref recognizes email addresses as explicit for the email platform.""" + + def test_standard_email_is_explicit(self): + chat_id, thread_id, is_explicit = _parse_target_ref("email", "[redacted:email]") + assert chat_id == "[redacted:email]" + assert thread_id is None + assert is_explicit is True + + def test_email_with_dots_in_local_part(self): + chat_id, _, is_explicit = _parse_target_ref("email", "[redacted:email]") + assert chat_id == "[redacted:email]" + assert is_explicit is True + + def test_email_with_plus_tag(self): + chat_id, _, is_explicit = _parse_target_ref("email", "[redacted:email]") + assert chat_id == "[redacted:email]" + assert is_explicit is True + + def test_email_strips_whitespace(self): + chat_id, _, is_explicit = _parse_target_ref("email", " [redacted:email] ") + assert chat_id == "[redacted:email]" + assert is_explicit is True + + def test_invalid_email_not_explicit(self): + assert _parse_target_ref("email", "not-an-email")[2] is False + assert _parse_target_ref("email", "@example.com")[2] is False + assert _parse_target_ref("email", "user@")[2] is False + assert _parse_target_ref("email", "[email protected]")[2] is False + + def test_email_not_explicit_for_other_platforms(self): + assert _parse_target_ref("telegram", "[redacted:email]")[2] is False + assert _parse_target_ref("discord", "[redacted:email]")[2] is False + assert _parse_target_ref("slack", "[redacted:email]")[2] is False + + +class TestEmailHomeChannelErrorHint: + """The no-home-channel error for email points at the real env var. + + Email reads its home channel from EMAIL_HOME_ADDRESS (gateway/config.py), + not the generic EMAIL_HOME_CHANNEL. The error guidance must name the + variable that is actually consulted so users who follow it succeed. + """ + + def test_email_error_names_email_home_address(self): + email_cfg = SimpleNamespace(enabled=True, token="", extra={}) + config = SimpleNamespace( + platforms={Platform.EMAIL: email_cfg}, + get_home_channel=lambda _platform: None, + ) + with patch("gateway.config.load_gateway_config", return_value=config), \ + patch("tools.interrupt.is_interrupted", return_value=False): + result = json.loads( + send_message_tool( + { + "action": "send", + "target": "email", + "message": "hi", + } + ) + ) + assert "EMAIL_HOME_ADDRESS" in result["error"] + assert "EMAIL_HOME_CHANNEL" not in result["error"] + + def test_non_email_platform_keeps_generic_home_channel_hint(self): + telegram_cfg = SimpleNamespace(enabled=True, token="***", extra={}) + config = SimpleNamespace( + platforms={Platform.TELEGRAM: telegram_cfg}, + get_home_channel=lambda _platform: None, + ) + with patch("gateway.config.load_gateway_config", return_value=config), \ + patch("tools.interrupt.is_interrupted", return_value=False): + result = json.loads( + send_message_tool( + { + "action": "send", + "target": "telegram", + "message": "hi", + } + ) + ) + assert "TELEGRAM_HOME_CHANNEL" in result["error"] + + class TestSendDiscordThreadId: """_send_discord uses thread_id when provided.""" -
tools/send_message_tool.py@@ -40,6 +40,15 @@ # downstream adapters (signal, etc.) expect. _PHONE_PLATFORMS = frozenset({"signal", "sms", "whatsapp"}) _E164_TARGET_RE = re.compile(r"^\s*\+(\d{7,15})\s*$") +# Email addresses — a valid email like "[redacted:email]" should be treated as +# an explicit target for the email platform, not fall through to channel-name +# resolution which has no way to resolve a raw address. +_EMAIL_TARGET_RE = re.compile(r"^\s*[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\s*$") +# Most platforms read their home channel from "<PLATFORM>_HOME_CHANNEL", but a +# few diverge. Email reads EMAIL_HOME_ADDRESS (see gateway/config.py), so the +# generic "<PLATFORM>_HOME_CHANNEL" hint would point users at a variable that is +# never read. Map the exceptions so the error guidance is actually actionable. +_HOME_CHANNEL_ENV_OVERRIDES = {"email": "EMAIL_HOME_ADDRESS"} _IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".webp", ".gif"} _VIDEO_EXTS = {".mp4", ".mov", ".avi", ".mkv", ".3gp"} _AUDIO_EXTS = {".ogg", ".opus", ".mp3", ".wav", ".m4a", ".flac"} @@ -265,10 +274,13 @@ def _handle_send(args): chat_id = home.chat_id used_home_channel = True else: + home_env = _HOME_CHANNEL_ENV_OVERRIDES.get( + platform_name, f"{platform_name.upper()}_HOME_CHANNEL" + ) return json.dumps({ "error": f"No home channel set for {platform_name} to determine where to send the message. " f"Either specify a channel directly with '{platform_name}:CHANNEL_NAME', " - f"or set a home channel via: hermes config set {platform_name.upper()}_HOME_CHANNEL <channel_id>" + f"or set a home channel via: hermes config set {home_env} <channel_id>" }) duplicate_skip = _maybe_skip_cron_duplicate_send(platform_name, chat_id, thread_id) @@ -383,6 +395,10 @@ def _parse_target_ref(platform_name: str, target_ref: str): if target_ref.strip().isdigit(): return f"group:{target_ref.strip()}", None, True return None, None, False + if platform_name == "email": + match = _EMAIL_TARGET_RE.fullmatch(target_ref) + if match: + return target_ref.strip(), None, True if platform_name in _PHONE_PLATFORMS: match = _E164_TARGET_RE.fullmatch(target_ref) if match:
-
-
#50379 fix(whatsapp): normalize bare phone targets to JIDs (salvage #8639)
files:
gateway/whatsapp_identity.py,plugins/platforms/whatsapp/adapter.py,tests/gateway/test_whatsapp_connect.py,tests/gateway/test_whatsapp_to_jid.pyShow the diff
-
gateway/whatsapp_identity.py@@ -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 ``[redacted:email]``. 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"`` → ``"[redacted:email]"`` + - ``"[redacted:email]"`` → unchanged + - ``"[redacted:email]"`` / ``"130631430344750@lid"`` → unchanged + - ``"user:[redacted:email]"`` 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. -
plugins/platforms/whatsapp/adapter.py@@ -182,6 +182,7 @@ def _terminate_bridge_process(proc, *, force: bool = False) -> None: 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 @@ async def send( 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 @@ async def edit_message( 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 @@ async def _send_media_to_bridge( 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 @@ async def send_typing(self, chat_id: str, metadata=None) -> None: # 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 @@ async def get_chat_info(self, chat_id: str) -> Dict[str, Any]: 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() -
tests/gateway/test_whatsapp_connect.py@@ -262,6 +262,51 @@ async def test_send_marks_retryable_fatal_when_managed_bridge_exits(self): 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"] == "[redacted:email]" + + @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("[redacted:email]", "hello") + + assert result.success is True + payload = mock_session.post.call_args.kwargs["json"] + assert payload["chatId"] == "[redacted:email]" + @pytest.mark.asyncio async def test_poll_messages_marks_retryable_fatal_when_managed_bridge_exits(self): adapter = _make_adapter() -
tests/gateway/test_whatsapp_to_jid.py@@ -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", "[redacted:email]"), + ("50766715226", "[redacted:email]"), + # human-formatted phone numbers get stripped to digits + ("+1 (555) 123-4567", "[redacted:email]"), + ("+1.555.123.4567", "[redacted:email]"), + ], + ) + def test_bare_phone_becomes_user_jid(self, raw, expected): + assert to_whatsapp_jid(raw) == expected + + @pytest.mark.parametrize( + "jid", + [ + "[redacted:email]", # already a user JID + "[redacted:email]", # 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:[redacted:email]") == ( + "[redacted:email]" + ) + + @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"
-
-
#53588 fix(whatsapp): resolve phone↔LID aliases in adapter DM/group allowlist
files:
gateway/platforms/whatsapp_common.py,tests/gateway/test_whatsapp_allowlist_lid_resolution.py
18 PRs closed without merging
From the close comments: done elsewhere 7 · duplicate 5 · merged via another change 4 · won't fix / by design 1 · other 1
-
#1950 fix(messaging): resolve explicit delivery labels consistently · merged via another change →
#3860,#1945 -
#2001 fix(messaging): resolve explicit delivery labels consistently (#1945) · won't fix / by design →
#1945 - #8639 fix(whatsapp): normalize bare phone targets to JIDs · merged via another change
-
#14738 fix(tools): resolve whatsapp phone numbers to lid format · done elsewhere →
#8639 -
#17189 fix(whatsapp): normalise JIDs and resolve LID aliases in dm_policy / group_policy allowlist check · done elsewhere →
#53588,#14486
Show 13 more
-
#17449 fix(gateway): load WhatsApp home channel from env overrides · duplicate →
#17295,#9220,#12520 -
#31398 fix(send_message): accept WhatsApp JID targets · duplicate →
#20718,#18899 -
#34748 fix(tools): recognize email addresses as explicit targets in send_message · merged via another change →
d3724c0,#35258 -
#37924 fix(whatsapp): recognise @lid and @s.whatsapp.net JIDs in send_message target resolution · done elsewhere →
ea49a79 -
#41415 fix(#41407): recognize WhatsApp JID formats (groups, DMs, LIDs) · merged via another change →
ea49a79,#46622 - #41581 fix(whatsapp): recognize JID targets and reject bare digits (#41407) · other
-
#41667 fix(whatsapp): normalise bare phone numbers to JIDs before bridge send · done elsewhere →
#8639 -
#41689 Normalize bare WhatsApp phone targets to JIDs · duplicate →
#41667,#41660 -
#41866 fix(whatsapp): normalize bare phone numbers to full JID for outgoing sends (#41660) · done elsewhere →
#8639 -
#45438 fix(send_message): recognize WhatsApp JIDs as explicit send targets · duplicate →
#12620,#37924 -
#51036 fix(whatsapp): resolve LID↔phone aliases in Python DM allowlist · done elsewhere →
#53588,#14486 -
#59166 fix(send_message): accept WhatsApp LID targets · done elsewhere →
ea49a79,#59136 -
#59199 fix(tools): recognize WhatsApp LID format in send_message target parsing · duplicate →
#59166,#59136,#41415,#41581
Closed issues · 13
- Closed#1945[Bug]: Cron jobs to WhatsApp fail with Baileys jidDecode error when deliver uses human-readable contact label
- Closed#2899[Bug]: Slack send_message can fall back to home DM instead of explicit channel target
- Closed#8637WhatsApp bridge crashes on bare phone numbers — missing JID normalization
- Closed#14486[Bug]: WhatsApp bridge fails to resolve contacts from allowlist when using phone numbers
- Closed#17295[WhatsApp] send_message tool: "No home channel set" even when WHATSAPP_HOME_CHANNEL is configured
- Closed#18646[Bug]: send_message ignores WhatsApp group target, routes to home channel instead
- Closed#31396[Bug]: send_message silently delivers to home channel and reports success when target resolution fails (WhatsApp LID format)
- Closed#32538[Bug]: WhatsApp Integration Failure
- Closed#34740[Bug]: send_message does not recognize explicit email targets and incorrectly falls back to home target resolution
- Closed#41407[Bug] send_message WhatsApp targets: group/LID JIDs silently fall back to home channel
- Closed#41660WhatsApp send fails with bare phone number — missing @s.whatsapp.net JID suffix
- Closed#59136WhatsApp LID format not recognized in send_message tool
- Closed#67941Plugin platforms cannot define explicit target formats for `hermes send`
How this cluster was grouped
-
Link evidence (
partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text. - Members outside that structural core may belong to a different underlying problem.
-
Pairwise link density
0.05: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means every pair is linked.
Pick up a Lab task
Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.
Open the Lab