diff --git a/gateway/profile_routing.py b/gateway/profile_routing.py index 5d2b3b60be..9c265a4d00 100644 --- a/gateway/profile_routing.py +++ b/gateway/profile_routing.py @@ -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 diff --git a/tests/gateway/test_profile_routing.py b/tests/gateway/test_profile_routing.py index 73934ac52d..727831dffa 100644 --- a/tests/gateway/test_profile_routing.py +++ b/tests/gateway/test_profile_routing.py @@ -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") diff --git a/website/docs/user-guide/multi-profile-gateways.md b/website/docs/user-guide/multi-profile-gateways.md index 6e3482c5ef..d031c342d1 100644 --- a/website/docs/user-guide/multi-profile-gateways.md +++ b/website/docs/user-guide/multi-profile-gateways.md @@ -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`,