fix(gateway): match WhatsApp profile_routes across JID/LID/number forms

`ProfileRoute.matches()` compared `chat_id` as an exact string, so a
WhatsApp route written as a phone number never matched the JID
(`…@s.whatsapp.net`) or LID (`…@lid`) the bridge actually delivers, and
the inbound fell through to the default profile. Allowlists and session
keys already canonicalize these via `gateway.whatsapp_identity`.

Exact compare still wins first; only whatsapp / whatsapp_cloud *user*
chats get the alias intersection fallback (applied to both `chat_id` and
`parent_chat_id`). Groups, broadcasts and every other platform stay exact.

Salvaged from #85081 (Kong); parent_chat_id fallback added on top.
This commit is contained in:
Kong
2026-09-02 03:41:02 -07:00
committed by Teknium
parent db639e1023
commit dca9a54427
3 changed files with 117 additions and 1 deletions
+52 -1
View File
@@ -35,6 +35,11 @@ Configuration (config.yaml):
chat_id: "YOUR_CHANNEL_ID"
thread_id: "YOUR_THREAD_ID"
profile: thread-profile
- name: owner-whatsapp
platform: whatsapp
chat_id: "15551234567" # phone, JID, or LID — all equivalent
profile: owner
"""
from __future__ import annotations
@@ -46,6 +51,43 @@ import logging
logger = logging.getLogger(__name__)
# Baileys and Cloud share phone/JID/LID identity rules. Other platforms keep
# exact string compare so Telegram numeric ids and Discord snowflakes stay
# unchanged.
_WHATSAPP_IDENTITY_PLATFORMS = {"whatsapp", "whatsapp_cloud"}
_WHATSAPP_NON_USER_SUFFIXES = ("@g.us", "@broadcast", "@newsletter")
def _is_whatsapp_non_user_chat(chat_id: Optional[str]) -> bool:
"""True for group / broadcast / newsletter JIDs — not a sender identity."""
if not chat_id:
return False
cid = str(chat_id).strip().lower()
return any(cid.endswith(suffix) for suffix in _WHATSAPP_NON_USER_SUFFIXES)
def _whatsapp_user_chat_ids_match(platform: str, left: Optional[str], right: Optional[str]) -> bool:
"""True when two WhatsApp *user* chat_ids refer to the same person.
Reuses :func:`gateway.whatsapp_identity.expand_whatsapp_aliases` so a
bare phone number, a ``@s.whatsapp.net`` JID, and a ``@lid`` LID collapse
to one identity — the same helper session keys and adapter allowlists
already use. Group/broadcast JIDs are excluded: those are chats, not
senders. Returns False for non-WhatsApp platforms (exact match only).
"""
if (platform or "").strip().lower() not in _WHATSAPP_IDENTITY_PLATFORMS:
return False
if not left or not right:
return False
if _is_whatsapp_non_user_chat(left) or _is_whatsapp_non_user_chat(right):
return False
from gateway.whatsapp_identity import expand_whatsapp_aliases
left_aliases = expand_whatsapp_aliases(str(left))
if not left_aliases:
return False
return bool(left_aliases & expand_whatsapp_aliases(str(right)))
class ProfileRouteRejected(RuntimeError):
"""An explicit route matched a profile this gateway does not serve."""
@@ -92,6 +134,11 @@ class ProfileRoute:
- Thread in channel: parent_chat_id == route.chat_id
A route declaring both ``guild_id`` and ``chat_id`` requires both to
match (a chat match alone does not satisfy a guild constraint).
WhatsApp / WhatsApp Cloud ``chat_id`` also matches across user-identity
forms (bare number, JID, LID) after the exact-string check. Exact
matches always win first, so existing configs keep working. Groups
(``@g.us``) and broadcasts stay exact-only.
"""
if not self.enabled:
return False
@@ -100,7 +147,11 @@ class ProfileRoute:
if self.thread_id and self.thread_id != thread_id:
return False
if self.chat_id and self.chat_id != chat_id and self.chat_id != parent_chat_id:
return False
if not (
_whatsapp_user_chat_ids_match(platform, self.chat_id, chat_id)
or _whatsapp_user_chat_ids_match(platform, self.chat_id, parent_chat_id)
):
return False
if self.guild_id and self.guild_id != guild_id:
return False
return True
+47
View File
@@ -1,5 +1,7 @@
"""Tests for gateway/profile_routing.py — profile-based routing."""
import json
import pytest
from gateway.profile_routing import (
ProfileRoute,
@@ -106,3 +108,48 @@ class TestForumPostMatching:
parent_chat_id="forum_channel_123")
assert m is not None
assert m.profile == "forum_profile"
class TestWhatsAppChatIdIdentityMatching:
"""WhatsApp ``chat_id`` routes match across number / JID / LID forms (the
same alias canonicalization allowlists and session keys already use);
every other platform, and WhatsApp groups, stay exact-compare."""
PHONE = "15551234567"
LID = "999999999999999"
def _write_lid_mapping(self, tmp_path, monkeypatch):
mapping_dir = tmp_path / "platforms" / "whatsapp" / "session"
mapping_dir.mkdir(parents=True)
(mapping_dir / f"lid-mapping-{self.PHONE}.json").write_text(json.dumps(f"{self.LID}@lid"))
(mapping_dir / f"lid-mapping-{self.LID}_reverse.json").write_text(
json.dumps(f"{self.PHONE}@s.whatsapp.net")
)
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
def test_number_route_matches_jid_and_mapped_lid_forms(self, tmp_path, monkeypatch):
self._write_lid_mapping(tmp_path, monkeypatch)
for platform in ("whatsapp", "whatsapp_cloud"):
r = ProfileRoute(name="owner", platform=platform, profile="owner", chat_id=self.PHONE)
assert r.matches(platform, chat_id=f"{self.PHONE}@s.whatsapp.net")
assert r.matches(platform, chat_id=f"{self.PHONE}:47@s.whatsapp.net")
assert r.matches(platform, chat_id=f"{self.LID}@lid")
# Alias fallback also applies to the thread-parent slot.
assert r.matches(platform, chat_id="thread-1", parent_chat_id=f"{self.LID}@lid")
assert not r.matches(platform, chat_id="15550001111@s.whatsapp.net")
def test_groups_and_other_platforms_stay_exact(self, tmp_path, monkeypatch):
self._write_lid_mapping(tmp_path, monkeypatch)
group = "120363012345678901@g.us"
owner = ProfileRoute(name="owner", platform="whatsapp", profile="owner", chat_id=self.PHONE)
assert not owner.matches("whatsapp", chat_id=group)
grp = ProfileRoute(name="grp", platform="whatsapp", profile="grp", chat_id=group)
assert grp.matches("whatsapp", chat_id=group)
assert not grp.matches("whatsapp", chat_id=f"{self.PHONE}@s.whatsapp.net")
# Stripping @g.us must never turn a group into a phone-identity match.
assert not ProfileRoute(
name="oops", platform="whatsapp", profile="owner", chat_id=group.split("@", 1)[0]
).matches("whatsapp", chat_id=group)
tg = ProfileRoute(name="tg", platform="telegram", profile="owner", chat_id="640466638")
assert tg.matches("telegram", chat_id="640466638")
assert not tg.matches("telegram", chat_id="640466638@s.whatsapp.net")
@@ -278,6 +278,12 @@ gateway:
platform: telegram
chat_id: "-1001234567890"
profile: tg-profile
# A WhatsApp DM — write the phone number; JID and LID forms also match
- name: owner-whatsapp
platform: whatsapp
chat_id: "15551234567"
profile: owner
```
Routes are matched most-specific-first (`thread_id` > `chat_id` > `guild_id`),
@@ -287,6 +293,18 @@ no route stay on the default/active profile. The routed profile gets the full
per-profile isolation described above (config, skills, memory, credentials,
session namespace). Routing works on every platform adapter, not just Discord.
On WhatsApp and WhatsApp Cloud, a `chat_id` route matches across user-identity
forms: a bare phone number (`15551234567`), a JID
(`15551234567@s.whatsapp.net`), and a LID (`…@lid`) all refer to the same
person once the bridge has paired them (the same canonicalization session keys
and adapter allowlists already use). You can put the phone number in
`profile_routes` and inbound DMs still match whether WhatsApp delivers a JID or
a LID. Without a LID mapping yet, the number form still matches a JID (the
suffix is stripped) but cannot resolve an unknown LID — that inbound falls
through to the default profile until the mapping appears. Group chats
(`…@g.us`) are not sender identities and still match exactly. Telegram numeric
ids are unchanged.
`profile_routes` requires `gateway.multiplex_profiles: true`; with
multiplexing off the routes are ignored. If an explicit route matches but its
target profile is not installed or is outside `multiplex_profile_allowlist`,