diff --git a/gateway/platforms/base.py b/gateway/platforms/base.py index ddbd836040..27724da9db 100644 --- a/gateway/platforms/base.py +++ b/gateway/platforms/base.py @@ -161,6 +161,12 @@ def _thread_metadata_for_source(source, reply_to_message_id: str | None = None) anchor = reply_to_message_id or getattr(source, "message_id", None) if anchor is not None: metadata["telegram_reply_to_message_id"] = str(anchor) + # Routed Hermes profile for shared state.db namespaces (topic bindings + # under multiplex / profile_routes). Outbound prune paths must not + # assume the transport adapter's static profile stamp. + profile = str(getattr(source, "profile", None) or "").strip() + if profile: + metadata["hermes_profile"] = profile return metadata diff --git a/gateway/run.py b/gateway/run.py index 652c904341..efedf4305c 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -8571,6 +8571,17 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew _TELEGRAM_LOBBY_REMINDER_COOLDOWN_S = 30.0 + def _telegram_topic_cooldown_key(self, source: SessionSource) -> Optional[str]: + """Cooldown key for topic-mode cooldowns: (profile, chat_id). + + Profiles sharing a Telegram private chat_id under multiplex must not + suppress each other's lobby reminders / capability hints (#76423). + """ + chat_id = str(source.chat_id or "") + if not chat_id: + return None + return f"{self._telegram_topic_profile_name(source)}:{chat_id}" + def _should_send_telegram_lobby_reminder(self, source: SessionSource) -> bool: """Rate-limit root-DM lobby reminders to one message per cooldown window. @@ -8580,15 +8591,15 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew """ if not hasattr(self, "_telegram_lobby_reminder_ts"): self._telegram_lobby_reminder_ts = {} - chat_id = str(source.chat_id or "") - if not chat_id: + key = self._telegram_topic_cooldown_key(source) + if not key: return True import time as _time now = _time.monotonic() - last = self._telegram_lobby_reminder_ts.get(chat_id, 0.0) + last = self._telegram_lobby_reminder_ts.get(key, 0.0) if now - last < self._TELEGRAM_LOBBY_REMINDER_COOLDOWN_S: return False - self._telegram_lobby_reminder_ts[chat_id] = now + self._telegram_lobby_reminder_ts[key] = now return True def _telegram_topic_root_lobby_message(self) -> str: @@ -25724,15 +25735,15 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew """ if not hasattr(self, "_telegram_capability_hint_ts"): self._telegram_capability_hint_ts = {} - chat_id = str(source.chat_id or "") - if not chat_id: + key = self._telegram_topic_cooldown_key(source) + if not key: return True import time as _time now = _time.monotonic() - last = self._telegram_capability_hint_ts.get(chat_id, 0.0) + last = self._telegram_capability_hint_ts.get(key, 0.0) if now - last < self._TELEGRAM_CAPABILITY_HINT_COOLDOWN_S: return False - self._telegram_capability_hint_ts[chat_id] = now + self._telegram_capability_hint_ts[key] = now return True def _telegram_topic_help_text(self) -> str: @@ -25784,12 +25795,14 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew except Exception as exc: logger.exception("Failed to disable Telegram topic mode") return f"Failed to disable topic mode: {exc}" - # Reset per-chat debounce state so the user doesn't see a stale - # cooldown on the next activation. - for attr in ("_telegram_lobby_reminder_ts", "_telegram_capability_hint_ts"): - store = getattr(self, attr, None) - if isinstance(store, dict): - store.pop(chat_id, None) + # Reset per-profile+chat debounce state so the user doesn't see a + # stale cooldown on the next activation (issue #76423). + cooldown_key = self._telegram_topic_cooldown_key(source) + if cooldown_key: + for attr in ("_telegram_lobby_reminder_ts", "_telegram_capability_hint_ts"): + store = getattr(self, attr, None) + if isinstance(store, dict): + store.pop(cooldown_key, None) return ( "Multi-session topic mode is now OFF for this chat.\n\n" "Existing topics in Telegram aren't removed — they'll just stop " @@ -26264,6 +26277,13 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew metadata.setdefault("scope_id", str(team_id)) if user_id: metadata.setdefault("user_id", str(user_id)) + # Routed profile for shared state.db namespaces (#76423): the Telegram + # prune path needs it because under profile_routes the transport + # adapter's stamp is not the profile that wrote the binding. + profile = str(getattr(source, "profile", None) or "").strip() + if profile and metadata is not None: + metadata = dict(metadata) + metadata["hermes_profile"] = profile return metadata def _thread_metadata_for_target( diff --git a/plugins/platforms/telegram/adapter.py b/plugins/platforms/telegram/adapter.py index c81a942368..436a52cb8d 100644 --- a/plugins/platforms/telegram/adapter.py +++ b/plugins/platforms/telegram/adapter.py @@ -1686,7 +1686,11 @@ class TelegramAdapter(BasePlatformAdapter): return "thread not found" in str(error).lower() def _prune_stale_dm_topic_binding( - self, chat_id: Any, thread_id: Any, + self, + chat_id: Any, + thread_id: Any, + *, + metadata: Optional[Dict[str, Any]] = None, ) -> None: """Drop the stale ``telegram_dm_topic_bindings`` row for a topic Telegram has confirmed deleted. @@ -1699,6 +1703,12 @@ class TelegramAdapter(BasePlatformAdapter): on to a fresh topic). Best-effort: we never raise from a send-fallback path — a failed cleanup must not turn into a failed user-facing send. + + Rows are namespaced by profile (#76423). Under + ``gateway.profile_routes`` the transport adapter may not be the + profile that wrote the binding, so the send's ``hermes_profile`` + metadata wins over the adapter's own profile stamp; single-profile + bots fall back to ``"default"``. """ if chat_id is None or thread_id is None: return @@ -1709,9 +1719,11 @@ class TelegramAdapter(BasePlatformAdapter): if db is None or not hasattr(db, "delete_telegram_topic_binding"): return try: - # Prefer the profile stamped on this adapter under multiplex - # (issue #76423). Fall back to "default" for single-profile bots. - profile_name = getattr(self, "_hermes_profile_name", None) or "default" + profile_name = ( + (metadata or {}).get("hermes_profile") + or getattr(self, "_hermes_profile_name", None) + or "default" + ) removed = db.delete_telegram_topic_binding( chat_id=str(chat_id), thread_id=str(thread_id), @@ -5595,7 +5607,9 @@ class TelegramAdapter(BasePlatformAdapter): self.name, effective_thread_id, ) self._prune_stale_dm_topic_binding( - chat_id, effective_thread_id, + chat_id, + effective_thread_id, + metadata=metadata, ) used_thread_fallback = True effective_thread_id = None @@ -6385,7 +6399,8 @@ class TelegramAdapter(BasePlatformAdapter): # Same prune as the streaming send path — the # control-message retry tells us the topic is gone, # so the binding row in state.db must go too - # (#31501). + # (#31501). Control sends carry no gateway metadata, so + # the prune namespaces by this adapter's profile stamp. self._prune_stale_dm_topic_binding( kwargs.get("chat_id"), message_thread_id, ) diff --git a/tests/gateway/test_telegram_topic_profile_routing_76423.py b/tests/gateway/test_telegram_topic_profile_routing_76423.py index 3ce892cea9..9f9f16e1ac 100644 --- a/tests/gateway/test_telegram_topic_profile_routing_76423.py +++ b/tests/gateway/test_telegram_topic_profile_routing_76423.py @@ -52,3 +52,42 @@ def test_gateway_uses_source_profile_not_global(tmp_path: Path): chat_id=CHAT, thread_id="42", profile_name="default", ) is None db.close() + + +def test_routed_profile_flows_into_prune_via_send_metadata(tmp_path: Path): + """profile_routes: the transport adapter may be the primary (default) bot + while the turn is routed to another profile — the outbound metadata built + by the gateway carries the routed profile, and prune uses it over the + adapter's own stamp (#76423).""" + from gateway.run import GatewayRunner + from plugins.platforms.telegram.adapter import TelegramAdapter + + runner = object.__new__(GatewayRunner) + runner._thread_metadata_for_target = lambda *a, **k: {"thread_id": "99"} + meta = runner._thread_metadata_for_source(_source("coder", "99")) + assert meta["hermes_profile"] == "coder" + assert "hermes_profile" not in runner._thread_metadata_for_source(_source(None, "99")) + + # Cooldowns are keyed (profile, chat): alpha's reminder must not gag beta. + assert runner._should_send_telegram_lobby_reminder(_source("alpha")) is True + assert runner._should_send_telegram_lobby_reminder(_source("beta")) is True + assert runner._should_send_telegram_lobby_reminder(_source("alpha")) is False + + db = SessionDB(db_path=tmp_path / "state.db") + db.create_session(session_id="sess-default", source="telegram", user_id=CHAT) + db.create_session(session_id="sess-coder", source="telegram", user_id=CHAT, profile_name="coder") + for prof, sid in (("default", "sess-default"), ("coder", "sess-coder")): + db.bind_telegram_topic( + chat_id=CHAT, thread_id="99", user_id=CHAT, + session_key=f"k-{prof}", session_id=sid, profile_name=prof, + ) + + adapter = object.__new__(TelegramAdapter) + adapter.platform = Platform.TELEGRAM + adapter._session_store = SimpleNamespace(_db=db) + adapter._hermes_profile_name = "default" # transport = primary bot + adapter._prune_stale_dm_topic_binding(CHAT, "99", metadata=meta) + + assert db.get_telegram_topic_binding(chat_id=CHAT, thread_id="99", profile_name="coder") is None + assert db.get_telegram_topic_binding(chat_id=CHAT, thread_id="99", profile_name="default") is not None + db.close() diff --git a/website/docs/user-guide/messaging/telegram.md b/website/docs/user-guide/messaging/telegram.md index cd651d7df5..2becfab4ca 100644 --- a/website/docs/user-guide/messaging/telegram.md +++ b/website/docs/user-guide/messaging/telegram.md @@ -848,29 +848,31 @@ Shows the current topic's binding: session title, session ID, and hints for `/ne ### Under the hood -- Activation persists to `telegram_dm_topic_mode(chat_id, user_id, enabled, ...)` in `state.db` -- Each topic binding persists to `telegram_dm_topic_bindings(chat_id, thread_id, session_id, ...)` with `ON DELETE CASCADE` on `session_id` — pruning a session automatically clears its topic binding -- The topic-mode SQLite migration is **opt-in**: it runs on the first `/topic` call, never on gateway startup. Until a user runs `/topic` in this profile, `state.db` is unchanged -- Each inbound DM message looks up its `(chat_id, thread_id)` binding. If present, the lookup routes the message to the bound session via `SessionStore.switch_session()` so the session-key-to-session-id mapping stays consistent on disk +- Activation persists to `telegram_dm_topic_mode(profile_name, chat_id, user_id, enabled, ...)` in `state.db`. Primary key is `(profile_name, chat_id)` so multiplexed / profile-routed bots sharing one `state.db` do not clobber each other when the same Telegram user DMs multiple bots (private `chat_id` is the user id and is identical across bots). +- Each topic binding persists to `telegram_dm_topic_bindings(profile_name, chat_id, thread_id, session_id, ...)` with PK `(profile_name, chat_id, thread_id)` and `ON DELETE CASCADE` on `session_id` — pruning a session automatically clears its topic binding +- The topic-mode SQLite migration is **opt-in**: it runs on the first `/topic` call, never on gateway startup. Until a user runs `/topic` in this profile, `state.db` is unchanged. Schema v3 adds `profile_name`; legacy rows migrate into the `default` namespace only +- Each inbound DM message looks up its `(profile_name, chat_id, thread_id)` binding using the **routed** profile (`source.profile`, not the process-global active profile). If present, the lookup routes the message to the bound session via `SessionStore.switch_session()` so the session-key-to-session-id mapping stays consistent on disk - `/new` inside a topic rewrites the binding row to point at the new session ID, so the next message stays on the fresh session - Topics declared in `extra.dm_topics` are **never auto-renamed** — the operator-chosen name is preserved even when multi-session mode is enabled - Set `extra.disable_topic_auto_rename: true` to turn off auto-rename for **all** topics in the chat (ad-hoc topics created via Threaded Mode included) - The General (pinned top) topic in a forum-enabled DM is treated as the root lobby, regardless of whether Telegram delivers its messages with `message_thread_id=1` or with no thread_id -- Root-lobby reminders are rate-limited to one message per 30 seconds per chat — a user who forgets topic mode is on and types ten prompts in the root won't get ten replies -- BotFather setup screenshots are rate-limited to one send per 5 minutes per chat — repeated `/topic` attempts while Threads Settings are still disabled won't re-upload the same image +- Root-lobby reminders are rate-limited to one message per 30 seconds per **(profile, chat)** — a user who forgets topic mode is on and types ten prompts in the root won't get ten replies, and two multiplexed profiles sharing a chat id do not suppress each other's reminders +- BotFather setup screenshots are rate-limited to one send per 5 minutes per **(profile, chat)** — repeated `/topic` attempts while Threads Settings are still disabled won't re-upload the same image - `/bg ` started inside a topic delivers its result back to the same topic; background sessions don't trigger auto-rename of the owning topic - `/topic` itself is gated by the bot's user authorization check — unauthorized DMs get a refusal instead of activation ### Disabling multi-session mode -Send `/topic off` in the root DM. Hermes flips the row off, clears the chat's `(thread_id → session_id)` bindings, and the root DM reverts to a normal Hermes chat. Existing topics in Telegram aren't deleted — they just stop being gated as independent sessions. Re-run `/topic` later to turn it back on. +Send `/topic off` in the root DM. Hermes flips the row off for **this profile's** namespace, clears that profile's `(thread_id → session_id)` bindings for the chat, and the root DM reverts to a normal Hermes chat. Existing topics in Telegram aren't deleted — they just stop being gated as independent sessions. Re-run `/topic` later to turn it back on. -If you need to clean up by hand (e.g. a bulk reset across many chats), remove the rows directly: +If you need to clean up by hand (e.g. a bulk reset across many chats), scope rows by `profile_name` (use `default` for single-profile installs): ```bash sqlite3 ~/.hermes/state.db \ - "UPDATE telegram_dm_topic_mode SET enabled = 0 WHERE chat_id = ''; \ - DELETE FROM telegram_dm_topic_bindings WHERE chat_id = '';" + "UPDATE telegram_dm_topic_mode SET enabled = 0 + WHERE profile_name = 'default' AND chat_id = ''; + DELETE FROM telegram_dm_topic_bindings + WHERE profile_name = 'default' AND chat_id = '';" ``` ### Downgrading Hermes