diff --git a/gateway/platforms/base.py b/gateway/platforms/base.py index 696547e097..baa009bb2b 100644 --- a/gateway/platforms/base.py +++ b/gateway/platforms/base.py @@ -4151,11 +4151,15 @@ class BasePlatformAdapter(ABC): chat_id_alt=chat_id_alt, is_bot=is_bot, scope_id=_opt(scope_id), guild_id=_opt(guild_id), parent_chat_id=_opt(parent_chat_id), message_id=_opt(message_id)) - profile, profile_route_rejected = None, False # profile from configured routes, if any + # Profile from configured routes, else the owning profile of a dedicated secondary bot (so no + # later ``source.profile``-less fallback can re-route the message through the default bot's routes). + owner_profile = getattr(self, "_owner_profile", None) + profile, profile_route_rejected = owner_profile, False if self.gateway_runner is not None: from gateway.profile_routing import ProfileRouteRejected try: - profile = self.gateway_runner._profile_name_for_source(SessionSource(**fields)) + profile = self.gateway_runner._profile_name_for_source( + SessionSource(**fields), adapter_profile=owner_profile) or owner_profile except ProfileRouteRejected: profile_route_rejected = True except Exception: diff --git a/gateway/profile_routing.py b/gateway/profile_routing.py index ae19d6a808..683a952f01 100644 --- a/gateway/profile_routing.py +++ b/gateway/profile_routing.py @@ -4,6 +4,11 @@ Matching priority, most specific first (``gateway.profile_routes`` in config.yam platform + chat_id + thread_id (14) → platform + chat_id (6) → platform + guild_id (2) → default profile. For Discord threads/forum posts ``parent_chat_id`` carries the direct parent, so a channel route also matches any thread/post under it. + +A route applies only to messages received by the bot of its ``bot_profile`` (default: the +default profile's shared bot). Telegram DM ``chat_id == user_id`` for EVERY bot, so without +this a ``chat_id`` route meant for the shared bot would re-home the same user's DM with a +dedicated secondary bot into another profile (#104933). """ from __future__ import annotations @@ -58,6 +63,7 @@ class ProfileRoute: chat_id: Optional[str] = None thread_id: Optional[str] = None enabled: bool = True + bot_profile: Optional[str] = None # None = the default profile's bot @property def specificity(self) -> int: @@ -67,14 +73,19 @@ class ProfileRoute: def matches( self, platform: str, guild_id: Optional[str] = None, chat_id: Optional[str] = None, thread_id: Optional[str] = None, parent_chat_id: Optional[str] = None, + adapter_profile: Optional[str] = None, ) -> bool: """True if every discriminator the route declares holds (AND). ``chat_id`` matches the channel directly or as the parent of a thread/forum post; WhatsApp ``chat_id`` also matches across number/JID/LID after the exact check (groups/broadcasts stay exact-only). + ``adapter_profile`` is the profile owning the receiving bot (``None`` = default); it must equal + the route's ``bot_profile``. """ if not self.enabled or self.platform != platform: return False + if _bot_profile_key(self.bot_profile) != _bot_profile_key(adapter_profile): + return False if self.thread_id and self.thread_id != thread_id: return False if ( @@ -87,6 +98,12 @@ class ProfileRoute: return not (self.guild_id and self.guild_id != guild_id) +def _bot_profile_key(name: Optional[str]) -> Optional[str]: + """``None`` for the default profile, else the profile name (mirrors ``set_owner_profile``).""" + name = (name or "").strip() + return None if not name or name == "default" else name + + def _coerce_route_id(value: Any) -> Optional[str]: """Normalize a route discriminator to str for strict equality matching. @@ -139,6 +156,7 @@ def parse_profile_routes(raw: Optional[List[Dict[str, Any]]]) -> List[ProfileRou chat_id=_coerce_route_id(entry.get("chat_id")), thread_id=_coerce_route_id(entry.get("thread_id")), enabled=entry.get("enabled", True), + bot_profile=_bot_profile_key(entry.get("bot_profile")), )) routes.sort(key=lambda r: r.specificity, reverse=True) logger.debug("Loaded %d profile routes (most-specific-first)", len(routes)) @@ -148,9 +166,11 @@ def parse_profile_routes(raw: Optional[List[Dict[str, Any]]]) -> List[ProfileRou def match_profile_route( routes: List[ProfileRoute], platform: str, guild_id: Optional[str] = None, chat_id: Optional[str] = None, thread_id: Optional[str] = None, parent_chat_id: Optional[str] = None, + adapter_profile: Optional[str] = None, ) -> Optional[ProfileRoute]: """Return the first (most specific) matching route, or None.""" for route in routes: - if route.matches(platform, guild_id=guild_id, chat_id=chat_id, thread_id=thread_id, parent_chat_id=parent_chat_id): + if route.matches(platform, guild_id=guild_id, chat_id=chat_id, thread_id=thread_id, + parent_chat_id=parent_chat_id, adapter_profile=adapter_profile): return route return None diff --git a/gateway/run.py b/gateway/run.py index 64eb47ebbc..c0c88d717a 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -4278,23 +4278,32 @@ class GatewayRunner( agent._last_flushed_db_idx = 0 agent._api_call_count = 0 - def _profile_name_for_source(self, source: SessionSource) -> Optional[str]: + def _profile_name_for_source( + self, source: SessionSource, adapter_profile: Optional[str] = None, + ) -> Optional[str]: """Resolve the profile name for an inbound source via configured routes (most specific wins). - ``None`` = default/active profile. Gated on ``multiplex_profiles``, since the scoped run only - activates under multiplexing; otherwise keys would be profile-namespaced while the agent ran in - ``agent:main``.""" + ``None`` = default/active profile (or, for a secondary adapter, its own profile — the caller + stamps it). Gated on ``multiplex_profiles``, since the scoped run only activates under + multiplexing; otherwise keys would be profile-namespaced while the agent ran in ``agent:main``. + ``adapter_profile`` is the profile owning the receiving bot; only routes declaring it as + ``bot_profile`` apply (#104933).""" config = getattr(self, "config", None) if not getattr(config, "multiplex_profiles", False): return None routes = getattr(config, "profile_routes", None) if not routes: return None + if adapter_profile is None: + # Sources built outside ``build_source`` may still carry the receiving adapter as provenance. + owner = self._transport_owner(source) if callable(getattr(source, "_transport_adapter_ref", None)) else None + if isinstance(owner, tuple): + adapter_profile = owner[1] from gateway.profile_routing import ProfileRouteRejected, match_profile_route try: matched = match_profile_route( routes, platform=source.platform.value, guild_id=getattr(source, "guild_id", None), chat_id=source.chat_id, thread_id=getattr(source, "thread_id", None), - parent_chat_id=getattr(source, "parent_chat_id", None)) + parent_chat_id=getattr(source, "parent_chat_id", None), adapter_profile=adapter_profile) except Exception: logger.warning( "Profile route matching failed for %s/%s, falling back to default", diff --git a/website/docs/user-guide/multi-profile-gateways.md b/website/docs/user-guide/multi-profile-gateways.md index 0afd4f1cb6..013d7d9ac5 100644 --- a/website/docs/user-guide/multi-profile-gateways.md +++ b/website/docs/user-guide/multi-profile-gateways.md @@ -369,6 +369,29 @@ 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. +A route applies only to messages received by the **default profile's bot** +unless it names another bot with `bot_profile: `. Telegram DMs use the +same `chat_id` for every bot (the user's id), so without this a +`chat_id` route meant for the shared bot would also capture that user's DMs +with a secondary profile's dedicated bot. Messages arriving at a secondary +profile's own bot stay in that profile: + +```yaml + # Pin one user's DM with team_b's OWN bot to a third profile + - name: teamb-owner-dm + platform: telegram + bot_profile: team_b + chat_id: "72719239" + profile: ops-for-team-b +``` + +Authorization for a routed message is always decided by the **receiving bot's +profile** (its token and allowlist), including follow-ups sent while the agent +is busy and mid-turn checks such as `/topic` or `/stop`; the routed profile +itself needs no copy of the allowlist. A routed profile without a bot of its +own also receives background notifications (process completions, heartbeats, +async delegation results) through the shared bot after a gateway restart. + 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