fix(gateway): profile_routes no longer hijack DMs to a dedicated secondary bot (#104933)
Telegram DM chat_id == user_id for every bot, so a `chat_id` route meant to pin a user's DM with the SHARED bot to profile `ops` also captured that user's DM with team_b's dedicated bot: the turn, session key, state.db and secrets were ops's while the reply left via team_b's transport. `ProfileRoute` gains an optional `bot_profile` discriminator (None = the default profile's bot) and `matches()` requires it to equal the receiving adapter's owner profile. `build_source` passes `_owner_profile` and stamps it as the fallback profile for secondary adapters; `_profile_name_for_source` derives it from the transport ref for sources built elsewhere. Secondary→secondary routes remain possible by naming the bot explicitly. Salvages the discriminator idea from #105040 and #105049 in a smaller shape (no bot-username sniffing across adapter internals; the owning profile is already declared at `set_owner_profile`). Co-authored-by: 0xAlyDev <agentai891@gmail.com> Co-authored-by: Ahmett101 <Ahmett101@users.noreply.github.com>
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
+14
-5
@@ -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",
|
||||
|
||||
@@ -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: <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
|
||||
|
||||
Reference in New Issue
Block a user