diff --git a/gateway/run.py b/gateway/run.py index 646a4856928eb..40f202509470b 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -2290,6 +2290,102 @@ def _load_gateway_runtime_config() -> dict: return expanded if isinstance(expanded, dict) else {} +def _gateway_profile_route_candidate(routes: Any, *, chat_id: str, thread_id: str) -> Optional[str]: + """Return a routed profile name from a mapping/list route config. + + Supported shapes are intentionally small and platform-neutral: + ``{"": "profile"}``, ``{":": + "profile"}``, or ``[{chats: [...], threads: [...], profile: "profile"}]``. + """ + if not chat_id: + return None + + if isinstance(routes, dict): + candidates: list[Any] = [] + if thread_id: + candidates.append(f"{chat_id}:{thread_id}") + candidates.append(chat_id) + if chat_id.lstrip("-").isdigit(): + try: + candidates.append(int(chat_id)) + except ValueError: + pass + for candidate in candidates: + routed = routes.get(candidate) + if routed: + return str(routed).strip() or None + return None + + if isinstance(routes, list): + for item in routes: + if not isinstance(item, dict): + continue + chats = item.get("chats") or item.get("chat_ids") or item.get("channels") or [] + if isinstance(chats, (str, int)): + chats = [chats] + if chat_id not in {str(chat) for chat in chats}: + continue + threads = item.get("threads") or item.get("thread_ids") + if threads is not None: + if isinstance(threads, (str, int)): + threads = [threads] + if not thread_id or thread_id not in {str(thread) for thread in threads}: + continue + routed = item.get("profile") or item.get("name") + if routed: + return str(routed).strip() or None + return None + + +def _resolve_shared_credential_profile_route(config: dict, source: Any) -> Optional[str]: + """Resolve optional chat/topic → profile routing for a shared adapter. + + This is the companion to ``gateway.multiplex_profiles`` for operators who + want one inbound bot token to serve multiple profile homes. The adapter + remains owned by the active/default gateway profile; only the agent turn is + routed by stamping ``SessionSource.profile`` before session-key generation. + """ + if not isinstance(config, dict): + return None + platform = getattr(getattr(source, "platform", None), "value", None) or str(getattr(source, "platform", "") or "") + platform_cfg = config.get(platform) or {} + if not isinstance(platform_cfg, dict): + return None + + chat_id = str(getattr(source, "chat_id", "") or "") + thread_id = str(getattr(source, "thread_id", "") or "") + raw_profile = None + for key in ("profile_routes", "chat_profiles", "channel_profiles"): + raw_profile = _gateway_profile_route_candidate( + platform_cfg.get(key), + chat_id=chat_id, + thread_id=thread_id, + ) + if raw_profile: + break + if not raw_profile: + return None + + try: + from hermes_cli.profiles import normalize_profile_name, profile_exists + profile_name = normalize_profile_name(str(raw_profile)) + if profile_name == "default": + return None + if not profile_exists(profile_name): + logger.warning( + "Gateway profile route for %s chat=%s thread=%s points to missing profile %r", + platform, + chat_id, + thread_id, + profile_name, + ) + return None + return profile_name + except Exception as exc: + logger.warning("Gateway profile route resolution failed: %s", exc) + return None + + def _resolve_gateway_model(config: dict | None = None) -> str: """Read model from config.yaml — single source of truth. @@ -8676,7 +8772,35 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]: # Record rate limit so subsequent messages are silently ignored self.pairing_store._record_rate_limit(platform_name, source.user_id) return None - + + # Optional shared-credential profile routing. This lets one adapter/bot + # token receive many chats and route selected chats/topics into named + # profile homes. Authorization above intentionally used the owning + # adapter/default profile; from here on, session keys and agent runtime + # use the routed profile. + if ( + not is_internal + and not getattr(source, "profile", None) + and getattr(getattr(self, "config", None), "multiplex_profiles", False) + ): + _route_profile = _resolve_shared_credential_profile_route( + _load_gateway_runtime_config(), + source, + ) + if _route_profile: + event = dataclasses.replace( + event, + source=dataclasses.replace(source, profile=_route_profile), + ) + source = event.source + logger.info( + "Gateway shared-credential profile route active: platform=%s chat=%s thread=%s profile=%s", + source.platform.value if source.platform else "unknown", + source.chat_id, + source.thread_id, + _route_profile, + ) + # Intercept messages that are responses to a pending /update prompt. # The update process (detached) wrote .update_prompt.json; the watcher # forwarded it to the user; now the user's reply goes back via diff --git a/tests/gateway/test_multiplex_shared_profile_routes.py b/tests/gateway/test_multiplex_shared_profile_routes.py new file mode 100644 index 0000000000000..6c8b0e430bbfc --- /dev/null +++ b/tests/gateway/test_multiplex_shared_profile_routes.py @@ -0,0 +1,78 @@ +"""Shared-credential chat/topic routes for multiplexed gateways.""" +from unittest.mock import patch + +from gateway.config import Platform +from gateway.session import SessionSource, build_session_key +from gateway.run import _resolve_shared_credential_profile_route + + +def _src(**kw) -> SessionSource: + kw.setdefault("platform", Platform.TELEGRAM) + kw.setdefault("chat_id", "-100111") + kw.setdefault("chat_type", "group") + return SessionSource(**kw) + + +def _resolve(config, source): + with patch("hermes_cli.profiles.profile_exists", return_value=True): + return _resolve_shared_credential_profile_route(config, source) + + +def test_mapping_routes_chat_to_profile(): + profile = _resolve( + {"telegram": {"profile_routes": {"-100111": "research"}}}, + _src(), + ) + assert profile == "research" + + +def test_mapping_prefers_topic_specific_route(): + profile = _resolve( + { + "telegram": { + "profile_routes": { + "-100111": "general", + "-100111:42": "support", + } + } + }, + _src(thread_id="42"), + ) + assert profile == "support" + + +def test_list_routes_can_scope_threads(): + profile = _resolve( + { + "telegram": { + "profile_routes": [ + {"chats": ["-100111"], "threads": ["42"], "profile": "support"}, + {"chats": ["-100111"], "profile": "fallback"}, + ] + } + }, + _src(thread_id="42"), + ) + assert profile == "support" + + +def test_aliases_are_accepted(): + profile = _resolve( + {"telegram": {"chat_profiles": {"-100111": "writer"}}}, + _src(), + ) + assert profile == "writer" + + +def test_missing_profile_is_ignored(): + with patch("hermes_cli.profiles.profile_exists", return_value=False): + profile = _resolve_shared_credential_profile_route( + {"telegram": {"profile_routes": {"-100111": "ghost"}}}, + _src(), + ) + assert profile is None + + +def test_routed_source_uses_namespaced_session_key(): + source = _src(profile="research") + assert build_session_key(source, profile=source.profile) == "agent:research:telegram:group:-100111" diff --git a/website/docs/user-guide/multi-profile-gateways.md b/website/docs/user-guide/multi-profile-gateways.md index 533a3d3c70439..7198af9020927 100644 --- a/website/docs/user-guide/multi-profile-gateways.md +++ b/website/docs/user-guide/multi-profile-gateways.md @@ -158,11 +158,46 @@ its `/p//` prefix. #### 3. Per-credential platforms still need their own token per profile Polling/connection platforms (Telegram, Discord, Slack, Matrix, Signal, …) work -fine multiplexed, but each profile that enables one must supply its **own** bot -token — the same token cannot be polled by two profiles at once. If two profiles -configure the same `(platform, token)`, startup fails fast naming both profiles -(see [Token-conflict safety](#token-conflict-safety) — the rule is unchanged, -it's just enforced inside the one process now). +fine multiplexed, but each profile that enables its own adapter must supply its +**own** bot token — the same token cannot be polled by two adapters at once. If +two profiles configure the same `(platform, token)` as separate adapters, startup +fails fast naming both profiles (see [Token-conflict safety](#token-conflict-safety) +— the rule is unchanged, it's just enforced inside the one process now). + +#### 3a. Shared-token chat routes + +Sometimes you want a different tradeoff: **one bot/account, many profile homes**. +For example, a single Telegram bot may be invited to several groups, where each +group should have isolated skills, memory, cron state, and sessions — but you do +not want to create a BotFather bot for every profile. + +In multiplex mode, keep the polling platform enabled only on the default profile +and route selected chats/topics into named profiles: + +```yaml +gateway: + multiplex_profiles: true + +telegram: + profile_routes: + "-1001111111111": research + "-1002222222222:42": support + +# Equivalent aliases are accepted: +# telegram.chat_profiles: {...} +# telegram.channel_profiles: {...} +``` + +The default gateway owns the inbound credential and authorization gate. After the +message is accepted, Hermes stamps the message source with the routed profile, so +the agent turn uses that profile's `config.yaml`, `.env`, SOUL, skills, memory, +and namespaced session key. The routed profile does **not** start its own Telegram +adapter, so there is no duplicate polling and no token-lock conflict. + +Use shared-token routes when identity continuity matters (one public bot name, +existing group membership, one Slack app) and process-level isolation is less +important than profile-scoped state. Use separate tokens/adapters when each +profile should be independently reachable and restartable. #### 4. Session keys are namespaced by profile