NousResearch/hermes-agent

WhatsApp send_message target parsing and JID normalization

Cluster 2899 · agent. Data as of

1 open item. Fix merged in #50379 on 21 Jun 2026. Check it covers your case.

The short answer

1open issue
0open PRs
21items grouped
Known?
Yes. 21 related items grouped here, 8 issues already closed.
Fix event
A fix was merged to main in PR #50379. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this partially resolved, deciding PR #50379. That is separate from the fix event.
Still open
1 issue and 0 PRs.

Most members concern send_message handling of WhatsApp targets: recognizing JID formats (group, DM, @lid) as explicit targets, normalizing bare phone numbers to JIDs, and avoiding fallback to the home channel. One member is about Slack falling back to a home DM, and one generic WhatsApp failure report is vaguer, so the group is not entirely uniform.

Open issues1

  • Issue#37906send_message tool fails for WhatsApp: @lid JIDs not recognized, home channel fallback, and raw phone numbers cause jidDecode error

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #50379 fix(whatsapp): normalize bare phone targets to JIDs (salvage #8639)

    merged to main

    files: gateway/whatsapp_identity.py, plugins/platforms/whatsapp/adapter.py, tests/gateway/test_whatsapp_connect.py, tests/gateway/test_whatsapp_to_jid.py

    Show 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"

11 PRs closed without merging

From the close comments: done elsewhere 4 · duplicate 4 · merged via another change 2 · other 1

  • #8639 fix(whatsapp): normalize bare phone targets to JIDs · merged via another change
  • #31398 fix(send_message): accept WhatsApp JID targets · duplicate → #20718, #18899
  • #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
Show 6 more
  • #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
  • #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 · 8
  • 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#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#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
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.09: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. 1.00 means 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

At a glance

Subsystem
agent
Merged PRs
#50379
Closed issues
8
Attempts
11 PRs closed without merging
Fix link checked
21 Jun 2026

For your agent

The same facts, typed.