diff --git a/gateway/platforms/base.py b/gateway/platforms/base.py index c42b9160737d..8c861a97fc60 100644 --- a/gateway/platforms/base.py +++ b/gateway/platforms/base.py @@ -93,6 +93,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 24d501b5b752..4b6ee42a39eb 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -6587,6 +6587,18 @@ def _session_key_for_source(self, source: SessionSource) -> str: profile=_profile, ) + @staticmethod + def _telegram_topic_profile_name(source: SessionSource) -> str: + """Profile namespace for Telegram topic-mode rows (issue #76423). + + Prefer the profile already stamped on the routed event + (``source.profile``). Do **not** fall back to the process-global + active profile here — under multiplex that can mis-attribute + topic state across bots sharing one ``state.db``. + """ + name = str(getattr(source, "profile", None) or "").strip() + return name if name else "default" + def _telegram_topic_mode_enabled(self, source: SessionSource) -> bool: """Return whether Telegram DM topic mode is active for this chat.""" if source.platform != Platform.TELEGRAM or source.chat_type != "dm": @@ -6600,6 +6612,7 @@ def _telegram_topic_mode_enabled(self, source: SessionSource) -> bool: raw = session_db.is_telegram_topic_mode_enabled( chat_id=str(source.chat_id), user_id=str(source.user_id), + profile_name=self._telegram_topic_profile_name(source), ) except Exception: logger.debug("Failed to read Telegram topic mode state", exc_info=True) @@ -6636,6 +6649,17 @@ def _is_telegram_topic_lane(self, source: SessionSource) -> bool: _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. @@ -6645,15 +6669,15 @@ def _should_send_telegram_lobby_reminder(self, source: SessionSource) -> bool: """ 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: @@ -6701,6 +6725,7 @@ def _record_telegram_topic_binding( user_id=str(source.user_id or ""), session_key=session_entry.session_key, session_id=session_entry.session_id, + profile_name=self._telegram_topic_profile_name(source), ) def _sync_telegram_topic_binding( @@ -6768,6 +6793,7 @@ def _recover_telegram_topic_thread_id( try: bindings = session_db.list_telegram_topic_bindings_for_chat( chat_id=str(source.chat_id), + profile_name=self._telegram_topic_profile_name(source), ) except Exception: logger.debug("topic-recover: read failed", exc_info=True) @@ -10981,6 +11007,9 @@ async def start(self) -> bool: adapter.set_topic_recovery_fn(self._recover_telegram_topic_thread_id) adapter.set_authorization_check(self._make_adapter_auth_check(adapter.platform)) adapter._busy_text_mode = self._busy_text_mode + # Topic-mode rows are namespaced by profile in shared state.db + # (issue #76423). Primary adapters own the process-active profile. + adapter._hermes_profile_name = self._active_profile_name() # Try to connect logger.info("Connecting to %s...", platform.value) @@ -12353,6 +12382,7 @@ async def _platform_reconnect_watcher(self) -> None: adapter.set_topic_recovery_fn(self._recover_telegram_topic_thread_id) adapter.set_authorization_check(self._make_adapter_auth_check(adapter.platform)) adapter._busy_text_mode = self._busy_text_mode + adapter._hermes_profile_name = self._active_profile_name() # Reconnect after an outage: preserve the platform's # server-side update queue so messages sent while the bot @@ -13297,6 +13327,9 @@ def _configure_profile_adapter( self._make_adapter_auth_check(platform, profile_name=profile_name) ) adapter._busy_text_mode = self._busy_text_mode + # Secondary adapters always carry the profile they serve so prune + # paths namespace topic bindings correctly under multiplex (#76423). + adapter._hermes_profile_name = profile_name async def _run_secondary_profile_reconnect( self, profile_name: str, platform: Platform @@ -16194,6 +16227,7 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g binding = (await self._session_db.get_telegram_topic_binding( chat_id=str(source.chat_id), thread_id=str(source.thread_id), + profile_name=self._telegram_topic_profile_name(source), )) if self._session_db else None except Exception: logger.debug("Failed to read Telegram topic binding", exc_info=True) @@ -19812,6 +19846,7 @@ async def _rename_telegram_topic_for_session_title( binding = await session_db.get_telegram_topic_binding( chat_id=str(source.chat_id), thread_id=str(source.thread_id), + profile_name=self._telegram_topic_profile_name(source), ) if binding and str(binding.get("session_id") or "") != str(session_id): return @@ -19923,15 +19958,15 @@ def _should_send_telegram_capability_hint(self, source: SessionSource) -> bool: """ 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: @@ -19969,22 +20004,28 @@ async def _disable_telegram_topic_mode_for_chat(self, source: SessionSource) -> currently_enabled = await self._session_db.is_telegram_topic_mode_enabled( chat_id=chat_id, user_id=str(source.user_id or ""), + profile_name=self._telegram_topic_profile_name(source), ) except Exception: currently_enabled = False if not currently_enabled: return "Multi-session topic mode is not currently enabled for this chat." try: - await self._session_db.disable_telegram_topic_mode(chat_id=chat_id) + await self._session_db.disable_telegram_topic_mode( + chat_id=chat_id, + profile_name=self._telegram_topic_profile_name(source), + ) 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 " @@ -20006,6 +20047,7 @@ async def _telegram_topic_root_status_message(self, source: SessionSource) -> st sessions = await self._session_db.list_unlinked_telegram_sessions_for_user( chat_id=str(source.chat_id), user_id=str(source.user_id), + profile_name=self._telegram_topic_profile_name(source), limit=10, ) except Exception: @@ -20055,9 +20097,11 @@ async def _restore_telegram_topic_session(self, event: MessageEvent, raw_session return "That session does not belong to this Telegram user." linked = await self._session_db.is_telegram_session_linked_to_topic(session_id=session_id) + topic_profile = self._telegram_topic_profile_name(source) current_binding = await self._session_db.get_telegram_topic_binding( chat_id=str(source.chat_id), thread_id=str(source.thread_id), + profile_name=topic_profile, ) if linked: if not current_binding or current_binding.get("session_id") != session_id: @@ -20072,6 +20116,7 @@ async def _restore_telegram_topic_session(self, event: MessageEvent, raw_session session_key=session_key, session_id=session_id, managed_mode="restored", + profile_name=topic_profile, ) except ValueError as exc: if "already linked" in str(exc): @@ -20435,6 +20480,13 @@ def _thread_metadata_for_source( if team_id: metadata = dict(metadata or {}) metadata["slack_team_id"] = str(team_id) + # Carry routed profile so Telegram prune under profile_routes + # namespaces shared state.db rows correctly (issue #76423). Only + # stamp when we already have send metadata — do not invent a + # metadata dict solely for profile on unthreaded sends. + if metadata is not None: + metadata = dict(metadata) + metadata["hermes_profile"] = self._telegram_topic_profile_name(source) return metadata def _thread_metadata_for_target( diff --git a/gateway/slash_commands.py b/gateway/slash_commands.py index 7b87e435055c..f45879664055 100644 --- a/gateway/slash_commands.py +++ b/gateway/slash_commands.py @@ -4234,6 +4234,7 @@ async def _handle_topic_command(self, event: MessageEvent, args: str = "") -> st await self._session_db.enable_telegram_topic_mode( chat_id=str(source.chat_id), user_id=str(source.user_id), + profile_name=self._telegram_topic_profile_name(source), has_topics_enabled=capabilities.get("has_topics_enabled"), allows_users_to_create_topics=capabilities.get("allows_users_to_create_topics"), ) @@ -4249,6 +4250,7 @@ async def _handle_topic_command(self, event: MessageEvent, args: str = "") -> st binding = await self._session_db.get_telegram_topic_binding( chat_id=str(source.chat_id), thread_id=str(source.thread_id), + profile_name=self._telegram_topic_profile_name(source), ) except Exception: logger.debug("Failed to read Telegram topic binding", exc_info=True) diff --git a/hermes_state.py b/hermes_state.py index 9b8215390d1e..fb28043eb90e 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -405,6 +405,17 @@ def _strip_background_review_harness( return out +def _normalize_telegram_topic_profile_name(profile_name: Optional[str] = None) -> str: + """Normalize profile namespace for Telegram topic-mode tables. + + Empty / missing values map to ``\"default\"`` so non-multiplexed gateways + keep a single namespace. Multiplexed callers must pass the *routed* + profile (``source.profile``), never the process-global active profile. + """ + name = str(profile_name or "").strip() + return name if name else "default" + + # Matches a bare protocol/tool-name marker such as "[memory]" or "[skill_manage]". _STALE_TOOL_CALL_MARKER_RE = re.compile(r"^\[[A-Za-z_][A-Za-z0-9_.-]*\]$") @@ -8669,12 +8680,26 @@ def apply_telegram_topic_migration(self) -> None: v1 — initial shape (no ON DELETE CASCADE on session_id FK) v2 — session_id FK gets ON DELETE CASCADE so session pruning automatically clears bindings. + v3 — ``profile_name`` dimension on both tables so multiplexed + gateways (shared ``state.db``) isolate topic mode/bindings + per Hermes profile (issue #76423). """ + def _table_columns(conn, table: str) -> set: + try: + return { + row[1] + for row in conn.execute(f"PRAGMA table_info('{table}')").fetchall() + } + except sqlite3.OperationalError: + return set() + def _do(conn): + # Fresh installs get the v3 shape immediately. conn.executescript( """ CREATE TABLE IF NOT EXISTS telegram_dm_topic_mode ( - chat_id TEXT PRIMARY KEY, + profile_name TEXT NOT NULL DEFAULT 'default', + chat_id TEXT NOT NULL, user_id TEXT NOT NULL, enabled INTEGER NOT NULL DEFAULT 1, activated_at REAL NOT NULL, @@ -8683,10 +8708,12 @@ def _do(conn): allows_users_to_create_topics INTEGER, capability_checked_at REAL, intro_message_id TEXT, - pinned_message_id TEXT + pinned_message_id TEXT, + PRIMARY KEY (profile_name, chat_id) ); CREATE TABLE IF NOT EXISTS telegram_dm_topic_bindings ( + profile_name TEXT NOT NULL DEFAULT 'default', chat_id TEXT NOT NULL, thread_id TEXT NOT NULL, user_id TEXT NOT NULL, @@ -8695,26 +8722,25 @@ def _do(conn): managed_mode TEXT NOT NULL DEFAULT 'auto', linked_at REAL NOT NULL, updated_at REAL NOT NULL, - PRIMARY KEY (chat_id, thread_id) + PRIMARY KEY (profile_name, chat_id, thread_id) ); - - CREATE UNIQUE INDEX IF NOT EXISTS idx_telegram_dm_topic_bindings_session - ON telegram_dm_topic_bindings(session_id); - - CREATE INDEX IF NOT EXISTS idx_telegram_dm_topic_bindings_user - ON telegram_dm_topic_bindings(user_id, chat_id); """ ) + # Indexes are created after any v2→v3 rebuild: a legacy table + # still lacking profile_name cannot accept the v3 user index. - # v1 → v2: rebuild telegram_dm_topic_bindings if its session_id FK - # lacks ON DELETE CASCADE. SQLite can't ALTER a foreign key, so we - # rebuild the table. Only runs once per DB (version gate). current = conn.execute( "SELECT value FROM state_meta WHERE key = ?", ("telegram_dm_topic_schema_version",), ).fetchone() current_version = int(current[0]) if current and str(current[0]).isdigit() else 0 - if current_version < 2: + + mode_cols = _table_columns(conn, "telegram_dm_topic_mode") + bind_cols = _table_columns(conn, "telegram_dm_topic_bindings") + + # v1 → v2: rebuild bindings if session_id FK lacks ON DELETE CASCADE + # and the table is still pre-profile (v3 rebuild covers CASCADE too). + if current_version < 2 and "profile_name" not in bind_cols: fk_rows = conn.execute( "PRAGMA foreign_key_list('telegram_dm_topic_bindings')" ).fetchall() @@ -8749,11 +8775,90 @@ def _do(conn): ON telegram_dm_topic_bindings(user_id, chat_id); """ ) + bind_cols = _table_columns(conn, "telegram_dm_topic_bindings") + + # v2 → v3 (or any pre-profile shape): namespace both tables by profile. + # Legacy rows migrate into the "default" namespace only — do not + # replicate across configured profiles (collision-contaminated + # data would otherwise be multiplied). + if "profile_name" not in mode_cols: + conn.executescript( + """ + CREATE TABLE telegram_dm_topic_mode_new ( + profile_name TEXT NOT NULL DEFAULT 'default', + chat_id TEXT NOT NULL, + user_id TEXT NOT NULL, + enabled INTEGER NOT NULL DEFAULT 1, + activated_at REAL NOT NULL, + updated_at REAL NOT NULL, + has_topics_enabled INTEGER, + allows_users_to_create_topics INTEGER, + capability_checked_at REAL, + intro_message_id TEXT, + pinned_message_id TEXT, + PRIMARY KEY (profile_name, chat_id) + ); + INSERT INTO telegram_dm_topic_mode_new ( + profile_name, chat_id, user_id, enabled, activated_at, + updated_at, has_topics_enabled, allows_users_to_create_topics, + capability_checked_at, intro_message_id, pinned_message_id + ) + SELECT + 'default', chat_id, user_id, enabled, activated_at, + updated_at, has_topics_enabled, allows_users_to_create_topics, + capability_checked_at, intro_message_id, pinned_message_id + FROM telegram_dm_topic_mode; + DROP TABLE telegram_dm_topic_mode; + ALTER TABLE telegram_dm_topic_mode_new + RENAME TO telegram_dm_topic_mode; + """ + ) + + if "profile_name" not in bind_cols: + conn.executescript( + """ + CREATE TABLE telegram_dm_topic_bindings_new ( + profile_name TEXT NOT NULL DEFAULT 'default', + chat_id TEXT NOT NULL, + thread_id TEXT NOT NULL, + user_id TEXT NOT NULL, + session_key TEXT NOT NULL, + session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, + managed_mode TEXT NOT NULL DEFAULT 'auto', + linked_at REAL NOT NULL, + updated_at REAL NOT NULL, + PRIMARY KEY (profile_name, chat_id, thread_id) + ); + INSERT INTO telegram_dm_topic_bindings_new ( + profile_name, chat_id, thread_id, user_id, session_key, + session_id, managed_mode, linked_at, updated_at + ) + SELECT + 'default', chat_id, thread_id, user_id, session_key, + session_id, managed_mode, linked_at, updated_at + FROM telegram_dm_topic_bindings; + DROP TABLE telegram_dm_topic_bindings; + ALTER TABLE telegram_dm_topic_bindings_new + RENAME TO telegram_dm_topic_bindings; + """ + ) + + # Indexes after any rebuild so they always target the v3 shape. + conn.execute( + "CREATE UNIQUE INDEX IF NOT EXISTS " + "idx_telegram_dm_topic_bindings_session " + "ON telegram_dm_topic_bindings(session_id)" + ) + conn.execute( + "CREATE INDEX IF NOT EXISTS " + "idx_telegram_dm_topic_bindings_user " + "ON telegram_dm_topic_bindings(profile_name, user_id, chat_id)" + ) conn.execute( "INSERT INTO state_meta (key, value) VALUES (?, ?) " "ON CONFLICT(key) DO UPDATE SET value = excluded.value", - ("telegram_dm_topic_schema_version", "2"), + ("telegram_dm_topic_schema_version", "3"), ) self._execute_write(_do) @@ -8762,6 +8867,7 @@ def enable_telegram_topic_mode( *, chat_id: str, user_id: str, + profile_name: str = "default", has_topics_enabled: Optional[bool] = None, allows_users_to_create_topics: Optional[bool] = None, ) -> None: @@ -8769,9 +8875,15 @@ def enable_telegram_topic_mode( This method intentionally owns the explicit topic migration. Ordinary SessionDB startup must not create these side tables. + + ``profile_name`` namespaces rows under a shared multiplex ``state.db`` + (issue #76423). Callers handling a multiplexed event must pass the + routed profile from ``source.profile``, not the process-global active + profile. """ self.apply_telegram_topic_migration() now = time.time() + profile_name = _normalize_telegram_topic_profile_name(profile_name) def _to_int(value: Optional[bool]) -> Optional[int]: if value is None: @@ -8782,11 +8894,11 @@ def _do(conn): conn.execute( """ INSERT INTO telegram_dm_topic_mode ( - chat_id, user_id, enabled, activated_at, updated_at, + profile_name, chat_id, user_id, enabled, activated_at, updated_at, has_topics_enabled, allows_users_to_create_topics, capability_checked_at - ) VALUES (?, ?, 1, ?, ?, ?, ?, ?) - ON CONFLICT(chat_id) DO UPDATE SET + ) VALUES (?, ?, ?, 1, ?, ?, ?, ?, ?) + ON CONFLICT(profile_name, chat_id) DO UPDATE SET user_id = excluded.user_id, enabled = 1, updated_at = excluded.updated_at, @@ -8795,6 +8907,7 @@ def _do(conn): capability_checked_at = excluded.capability_checked_at """, ( + profile_name, str(chat_id), str(user_id), now, @@ -8810,6 +8923,7 @@ def disable_telegram_topic_mode( self, *, chat_id: str, + profile_name: str = "default", clear_bindings: bool = True, ) -> None: """Disable Telegram DM topic mode for one private chat. @@ -8822,33 +8936,43 @@ def disable_telegram_topic_mode( Never creates the topic-mode tables from scratch; if they don't exist there is nothing to disable and the call is a no-op. """ + profile_name = _normalize_telegram_topic_profile_name(profile_name) + def _do(conn): try: conn.execute( "UPDATE telegram_dm_topic_mode SET enabled = 0, updated_at = ? " - "WHERE chat_id = ?", - (time.time(), str(chat_id)), + "WHERE profile_name = ? AND chat_id = ?", + (time.time(), profile_name, str(chat_id)), ) if clear_bindings: conn.execute( - "DELETE FROM telegram_dm_topic_bindings WHERE chat_id = ?", - (str(chat_id),), + "DELETE FROM telegram_dm_topic_bindings " + "WHERE profile_name = ? AND chat_id = ?", + (profile_name, str(chat_id)), ) except sqlite3.OperationalError: # Tables don't exist yet — nothing to disable. return self._execute_write(_do) - def is_telegram_topic_mode_enabled(self, *, chat_id: str, user_id: str) -> bool: + def is_telegram_topic_mode_enabled( + self, + *, + chat_id: str, + user_id: str, + profile_name: str = "default", + ) -> bool: """Return whether Telegram DM topic mode is enabled for this chat/user.""" + profile_name = _normalize_telegram_topic_profile_name(profile_name) with self._lock: try: row = self._conn.execute( """ SELECT enabled FROM telegram_dm_topic_mode - WHERE chat_id = ? AND user_id = ? + WHERE profile_name = ? AND chat_id = ? AND user_id = ? """, - (str(chat_id), str(user_id)), + (profile_name, str(chat_id), str(user_id)), ).fetchone() except sqlite3.OperationalError: return False @@ -8862,16 +8986,18 @@ def get_telegram_topic_binding( *, chat_id: str, thread_id: str, + profile_name: str = "default", ) -> Optional[Dict[str, Any]]: """Return the session binding for a Telegram DM topic, if present.""" + profile_name = _normalize_telegram_topic_profile_name(profile_name) with self._lock: try: row = self._conn.execute( """ SELECT * FROM telegram_dm_topic_bindings - WHERE chat_id = ? AND thread_id = ? + WHERE profile_name = ? AND chat_id = ? AND thread_id = ? """, - (str(chat_id), str(thread_id)), + (profile_name, str(chat_id), str(thread_id)), ).fetchone() except sqlite3.OperationalError: return None @@ -8881,18 +9007,21 @@ def list_telegram_topic_bindings_for_chat( self, *, chat_id: str, + profile_name: str = "default", ) -> List[Dict[str, Any]]: """All Telegram DM topic bindings for one chat, newest first. Read-only; returns [] if the bindings table doesn't exist yet (does not trigger the topic-mode migration). """ + profile_name = _normalize_telegram_topic_profile_name(profile_name) with self._lock: try: rows = self._conn.execute( "SELECT * FROM telegram_dm_topic_bindings " - "WHERE chat_id = ? ORDER BY updated_at DESC", - (str(chat_id),), + "WHERE profile_name = ? AND chat_id = ? " + "ORDER BY updated_at DESC", + (profile_name, str(chat_id)), ).fetchall() except sqlite3.OperationalError: return [] @@ -8927,6 +9056,7 @@ def delete_telegram_topic_binding( *, chat_id: str, thread_id: str, + profile_name: str = "default", ) -> int: """Remove the binding row for a single (chat, thread) pair. @@ -8957,6 +9087,7 @@ def delete_telegram_topic_binding( """ chat_id = str(chat_id) thread_id = str(thread_id) + profile_name = _normalize_telegram_topic_profile_name(profile_name) deleted = {"count": 0} def _do(conn): @@ -8964,9 +9095,9 @@ def _do(conn): cursor = conn.execute( """ DELETE FROM telegram_dm_topic_bindings - WHERE chat_id = ? AND thread_id = ? + WHERE profile_name = ? AND chat_id = ? AND thread_id = ? """, - (chat_id, thread_id), + (profile_name, chat_id, thread_id), ) deleted["count"] = cursor.rowcount or 0 except sqlite3.OperationalError: @@ -8982,15 +9113,16 @@ def _do(conn): remaining = conn.execute( """ SELECT 1 FROM telegram_dm_topic_bindings - WHERE chat_id = ? LIMIT 1 + WHERE profile_name = ? AND chat_id = ? LIMIT 1 """, - (chat_id,), + (profile_name, chat_id), ).fetchone() if remaining is None: conn.execute( "UPDATE telegram_dm_topic_mode " - "SET enabled = 0, updated_at = ? WHERE chat_id = ?", - (time.time(), chat_id), + "SET enabled = 0, updated_at = ? " + "WHERE profile_name = ? AND chat_id = ?", + (time.time(), profile_name, chat_id), ) except sqlite3.OperationalError: # telegram_dm_topic_mode absent — binding prune still stands. @@ -9008,6 +9140,7 @@ def bind_telegram_topic( session_key: str, session_id: str, managed_mode: str = "auto", + profile_name: str = "default", ) -> None: """Bind one Telegram DM topic thread to one Hermes session. @@ -9022,28 +9155,38 @@ def bind_telegram_topic( user_id = str(user_id) session_key = str(session_key) session_id = str(session_id) + profile_name = _normalize_telegram_topic_profile_name(profile_name) def _do(conn): existing_session = conn.execute( """ - SELECT chat_id, thread_id FROM telegram_dm_topic_bindings + SELECT profile_name, chat_id, thread_id + FROM telegram_dm_topic_bindings WHERE session_id = ? """, (session_id,), ).fetchone() if existing_session is not None: - linked_chat = existing_session["chat_id"] if isinstance(existing_session, sqlite3.Row) else existing_session[0] - linked_thread = existing_session["thread_id"] if isinstance(existing_session, sqlite3.Row) else existing_session[1] - if str(linked_chat) != chat_id or str(linked_thread) != thread_id: + if isinstance(existing_session, sqlite3.Row): + linked_profile = existing_session["profile_name"] + linked_chat = existing_session["chat_id"] + linked_thread = existing_session["thread_id"] + else: + linked_profile, linked_chat, linked_thread = existing_session + if ( + str(linked_profile) != profile_name + or str(linked_chat) != chat_id + or str(linked_thread) != thread_id + ): raise ValueError("session is already linked to another Telegram topic") conn.execute( """ INSERT INTO telegram_dm_topic_bindings ( - chat_id, thread_id, user_id, session_key, session_id, + profile_name, chat_id, thread_id, user_id, session_key, session_id, managed_mode, linked_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(chat_id, thread_id) DO UPDATE SET + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(profile_name, chat_id, thread_id) DO UPDATE SET user_id = excluded.user_id, session_key = excluded.session_key, session_id = excluded.session_id, @@ -9051,6 +9194,7 @@ def _do(conn): updated_at = excluded.updated_at """, ( + profile_name, chat_id, thread_id, user_id, @@ -9090,6 +9234,7 @@ def list_unlinked_telegram_sessions_for_user( *, chat_id: str, user_id: str, + profile_name: str = "default", limit: int = 10, ) -> List[Dict[str, Any]]: """List previous Telegram sessions for this user that are not bound to a topic. @@ -9098,7 +9243,13 @@ def list_unlinked_telegram_sessions_for_user( topic-mode tables are absent, fall back to a simpler query that just returns this user's Telegram sessions — there can't be any bindings yet. + + Scoped by ``profile_name`` so multiplexed profiles do not surface + each other's unlinked sessions (issue #76423). """ + profile_name = _normalize_telegram_topic_profile_name(profile_name) + # sessions.profile_name is NULL/empty for legacy rows → treat as default. + profile_clause = "AND COALESCE(NULLIF(TRIM(s.profile_name), ''), 'default') = ?" with self._lock: try: rows = self._conn.execute( @@ -9119,6 +9270,7 @@ def list_unlinked_telegram_sessions_for_user( ON sp.hash = s.system_prompt_hash WHERE s.source = 'telegram' AND s.user_id = ? + {profile_clause} AND NOT EXISTS ( SELECT 1 FROM telegram_dm_topic_bindings b WHERE b.session_id = s.id @@ -9126,11 +9278,11 @@ def list_unlinked_telegram_sessions_for_user( ORDER BY last_active DESC, s.started_at DESC LIMIT ? """, - (str(user_id), int(limit)), + (str(user_id), profile_name, int(limit)), ).fetchall() except sqlite3.OperationalError: - # telegram_dm_topic_bindings doesn't exist yet — no bindings - # means every telegram session for this user is "unlinked". + # bindings table (or profile_name on sessions) missing — + # fall back to user-only list; no bindings can exist yet. rows = self._conn.execute( f""" SELECT s.*, diff --git a/plugins/platforms/telegram/adapter.py b/plugins/platforms/telegram/adapter.py index ea6258b9f2dc..58049ccb6b9b 100644 --- a/plugins/platforms/telegram/adapter.py +++ b/plugins/platforms/telegram/adapter.py @@ -1296,7 +1296,12 @@ def _is_thread_not_found_error(error: Exception) -> bool: 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, + profile_name: Optional[str] = None, ) -> None: """Drop the stale ``telegram_dm_topic_bindings`` row for a topic Telegram has confirmed deleted. @@ -1309,6 +1314,16 @@ def _prune_stale_dm_topic_binding( 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. + + Namespace resolution (issue #76423 / profile_routes): + 1. Explicit ``profile_name`` argument + 2. Routed profile on this send's metadata (``hermes_profile``) + 3. Adapter stamp (``_hermes_profile_name``) for secondary bots + 4. ``\"default\"`` + + Under ``gateway.profile_routes`` the receiving *transport* adapter + may not own the runtime that wrote the binding — metadata carries + the routed profile so we prune the correct namespace. """ if chat_id is None or thread_id is None: return @@ -1319,8 +1334,16 @@ def _prune_stale_dm_topic_binding( if db is None or not hasattr(db, "delete_telegram_topic_binding"): return try: + resolved = profile_name + if not resolved and metadata: + resolved = metadata.get("hermes_profile") or metadata.get("profile") + if not resolved: + resolved = getattr(self, "_hermes_profile_name", None) or "default" + resolved = str(resolved).strip() or "default" removed = db.delete_telegram_topic_binding( - chat_id=str(chat_id), thread_id=str(thread_id), + chat_id=str(chat_id), + thread_id=str(thread_id), + profile_name=resolved, ) except Exception: logger.debug( @@ -4618,7 +4641,9 @@ async def send( 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 @@ -5377,9 +5402,13 @@ async def _send_message_with_thread_fallback(self, **kwargs): # 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). Prefer routed profile from kwargs metadata + # when present (profile_routes / multiplex). self._prune_stale_dm_topic_binding( - kwargs.get("chat_id"), message_thread_id, + kwargs.get("chat_id"), + message_thread_id, + metadata=kwargs.get("metadata") if isinstance(kwargs.get("metadata"), dict) else None, + profile_name=kwargs.get("hermes_profile") or kwargs.get("profile_name"), ) retry_kwargs = dict(kwargs) retry_kwargs.pop("message_thread_id", None) diff --git a/tests/gateway/test_telegram_topic_profile_isolation_76423.py b/tests/gateway/test_telegram_topic_profile_isolation_76423.py new file mode 100644 index 000000000000..a0e77a7d8396 --- /dev/null +++ b/tests/gateway/test_telegram_topic_profile_isolation_76423.py @@ -0,0 +1,138 @@ +"""Issue #76423 — SessionDB: telegram topic tables namespace by profile.""" + +from __future__ import annotations + +import sqlite3 +from pathlib import Path + +from hermes_state import SessionDB, _normalize_telegram_topic_profile_name + + +CHAT = "208214988" + + +def _session(db, sid, profile_name=None): + db.create_session(session_id=sid, source="telegram", user_id=CHAT, profile_name=profile_name) + + +def test_normalize_profile_name(): + assert _normalize_telegram_topic_profile_name(None) == "default" + assert _normalize_telegram_topic_profile_name("") == "default" + assert _normalize_telegram_topic_profile_name(" coder ") == "coder" + + +def test_fresh_schema_is_v3(tmp_path: Path): + db = SessionDB(db_path=tmp_path / "fresh.db") + db.apply_telegram_topic_migration() + assert db.get_meta("telegram_dm_topic_schema_version") == "3" + cols = {r[1] for r in db._conn.execute("PRAGMA table_info('telegram_dm_topic_mode')")} + assert "profile_name" in cols + db.close() + + +def test_legacy_v2_rows_migrate_only_to_default(tmp_path: Path): + db_path = tmp_path / "legacy.db" + conn = sqlite3.connect(str(db_path)) + conn.executescript( + f""" + CREATE TABLE state_meta (key TEXT PRIMARY KEY, value TEXT); + INSERT INTO state_meta(key, value) VALUES ('telegram_dm_topic_schema_version', '2'); + CREATE TABLE sessions ( + id TEXT PRIMARY KEY, source TEXT, user_id TEXT, model TEXT, + model_config TEXT, system_prompt TEXT, parent_session_id TEXT, + started_at REAL, ended_at REAL, end_reason TEXT, + message_count INTEGER DEFAULT 0, tool_call_count INTEGER DEFAULT 0, + input_tokens INTEGER DEFAULT 0, output_tokens INTEGER DEFAULT 0 + ); + INSERT INTO sessions(id, source, user_id, started_at) + VALUES ('legacy-sess', 'telegram', '{CHAT}', 1.0); + CREATE TABLE telegram_dm_topic_mode ( + chat_id TEXT PRIMARY KEY, user_id TEXT NOT NULL, + enabled INTEGER NOT NULL DEFAULT 1, + activated_at REAL NOT NULL, updated_at REAL NOT NULL, + has_topics_enabled INTEGER, allows_users_to_create_topics INTEGER, + capability_checked_at REAL, intro_message_id TEXT, pinned_message_id TEXT + ); + INSERT INTO telegram_dm_topic_mode(chat_id, user_id, enabled, activated_at, updated_at) + VALUES ('{CHAT}', '{CHAT}', 1, 1.0, 1.0); + CREATE TABLE telegram_dm_topic_bindings ( + chat_id TEXT NOT NULL, thread_id TEXT NOT NULL, user_id TEXT NOT NULL, + session_key TEXT NOT NULL, + session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, + managed_mode TEXT NOT NULL DEFAULT 'auto', + linked_at REAL NOT NULL, updated_at REAL NOT NULL, + PRIMARY KEY (chat_id, thread_id) + ); + INSERT INTO telegram_dm_topic_bindings + VALUES ('{CHAT}', '99', '{CHAT}', 'k', 'legacy-sess', 'auto', 1.0, 1.0); + """ + ) + conn.close() + + db = SessionDB(db_path=db_path) + db.apply_telegram_topic_migration() + assert db.get_meta("telegram_dm_topic_schema_version") == "3" + assert db.is_telegram_topic_mode_enabled( + chat_id=CHAT, user_id=CHAT, profile_name="default", + ) + assert not db.is_telegram_topic_mode_enabled( + chat_id=CHAT, user_id=CHAT, profile_name="coder", + ) + assert db.get_telegram_topic_binding( + chat_id=CHAT, thread_id="99", profile_name="default", + )["session_id"] == "legacy-sess" + assert db.get_telegram_topic_binding( + chat_id=CHAT, thread_id="99", profile_name="coder", + ) is None + db.close() + + +def test_mode_and_bindings_isolated_across_profiles(tmp_path: Path): + db = SessionDB(db_path=tmp_path / "state.db") + _session(db, "sess-a", "alpha") + _session(db, "sess-b", "beta") + + db.enable_telegram_topic_mode(chat_id=CHAT, user_id=CHAT, profile_name="alpha") + db.enable_telegram_topic_mode(chat_id=CHAT, user_id=CHAT, profile_name="beta") + db.disable_telegram_topic_mode(chat_id=CHAT, profile_name="alpha") + assert not db.is_telegram_topic_mode_enabled(chat_id=CHAT, user_id=CHAT, profile_name="alpha") + assert db.is_telegram_topic_mode_enabled(chat_id=CHAT, user_id=CHAT, profile_name="beta") + + db.bind_telegram_topic( + chat_id=CHAT, thread_id="77", user_id=CHAT, + session_key="ka", session_id="sess-a", profile_name="alpha", + ) + db.bind_telegram_topic( + chat_id=CHAT, thread_id="77", user_id=CHAT, + session_key="kb", session_id="sess-b", profile_name="beta", + ) + assert db.get_telegram_topic_binding( + chat_id=CHAT, thread_id="77", profile_name="alpha", + )["session_id"] == "sess-a" + assert db.get_telegram_topic_binding( + chat_id=CHAT, thread_id="77", profile_name="beta", + )["session_id"] == "sess-b" + + assert db.delete_telegram_topic_binding( + chat_id=CHAT, thread_id="77", profile_name="alpha", + ) == 1 + assert db.get_telegram_topic_binding( + chat_id=CHAT, thread_id="77", profile_name="alpha", + ) is None + assert db.get_telegram_topic_binding( + chat_id=CHAT, thread_id="77", profile_name="beta", + ) is not None + db.close() + + +def test_default_kwarg_keeps_single_profile_behavior(tmp_path: Path): + db = SessionDB(db_path=tmp_path / "state.db") + _session(db, "sess-d") + db.enable_telegram_topic_mode(chat_id=CHAT, user_id=CHAT) + db.bind_telegram_topic( + chat_id=CHAT, thread_id="1", user_id=CHAT, + session_key="k", session_id="sess-d", + ) + assert db.is_telegram_topic_mode_enabled(chat_id=CHAT, user_id=CHAT) + assert db.get_telegram_topic_binding(chat_id=CHAT, thread_id="1")["session_id"] == "sess-d" + db.close() diff --git a/tests/gateway/test_telegram_topic_profile_routing_76423.py b/tests/gateway/test_telegram_topic_profile_routing_76423.py new file mode 100644 index 000000000000..fab8090bf81d --- /dev/null +++ b/tests/gateway/test_telegram_topic_profile_routing_76423.py @@ -0,0 +1,118 @@ +"""Issue #76423 — Gateway routes source.profile into telegram topic state.""" + +from __future__ import annotations + +from pathlib import Path +from types import SimpleNamespace + +from hermes_state import SessionDB +from gateway.config import Platform +from gateway.session import SessionSource + + +CHAT = "208214988" + + +def _source(profile=None, thread_id="42"): + return SessionSource( + platform=Platform.TELEGRAM, + user_id=CHAT, + chat_id=CHAT, + user_name="tester", + chat_type="dm", + thread_id=thread_id, + profile=profile, + ) + + +def test_gateway_uses_source_profile_not_global(tmp_path: Path): + from gateway.run import GatewayRunner + + assert GatewayRunner._telegram_topic_profile_name(_source("coder")) == "coder" + assert GatewayRunner._telegram_topic_profile_name(_source(None)) == "default" + + db = SessionDB(db_path=tmp_path / "state.db") + db.create_session(session_id="sess-coder", source="telegram", user_id=CHAT, profile_name="coder") + db.enable_telegram_topic_mode(chat_id=CHAT, user_id=CHAT, profile_name="coder") + + runner = object.__new__(GatewayRunner) + runner._session_db = db + assert runner._telegram_topic_mode_enabled(_source("coder")) is True + assert runner._telegram_topic_mode_enabled(_source("other")) is False + assert runner._telegram_topic_mode_enabled(_source(None)) is False + + runner._record_telegram_topic_binding( + _source("coder", "42"), + SimpleNamespace(session_key="k", session_id="sess-coder"), + ) + assert db.get_telegram_topic_binding( + chat_id=CHAT, thread_id="42", profile_name="coder", + ) is not None + assert db.get_telegram_topic_binding( + chat_id=CHAT, thread_id="42", profile_name="default", + ) is None + db.close() + + +def test_thread_metadata_carries_routed_profile(): + """Outbound send metadata must include hermes_profile for prune (#76423).""" + from gateway.run import GatewayRunner + + runner = object.__new__(GatewayRunner) + # Avoid full constructor; stub the target builder to a simple dict. + runner._thread_metadata_for_target = lambda *a, **k: {"thread_id": "42"} + meta = runner._thread_metadata_for_source(_source("coder", "42")) + assert meta is not None + assert meta["hermes_profile"] == "coder" + + +def test_cooldowns_namespaced_by_profile(): + from gateway.run import GatewayRunner + + runner = object.__new__(GatewayRunner) + a = _source("alpha") + b = _source("beta") + # First send for each profile should be allowed independently. + assert runner._should_send_telegram_lobby_reminder(a) is True + assert runner._should_send_telegram_lobby_reminder(b) is True + # Immediate re-hit same profile is suppressed; the other profile is not. + assert runner._should_send_telegram_lobby_reminder(a) is False + assert runner._should_send_telegram_capability_hint(a) is True + assert runner._should_send_telegram_capability_hint(b) is True + assert runner._should_send_telegram_capability_hint(a) is False + + +def test_primary_adapter_prunes_routed_profile_not_stamp(tmp_path: Path): + """profile_routes: transport adapter may be primary (default) while the + turn is routed to another profile — prune must use send metadata.""" + from plugins.platforms.telegram.adapter import TelegramAdapter + + 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") + db.bind_telegram_topic( + chat_id=CHAT, thread_id="99", user_id=CHAT, + session_key="kd", session_id="sess-default", profile_name="default", + ) + db.bind_telegram_topic( + chat_id=CHAT, thread_id="99", user_id=CHAT, + session_key="kc", session_id="sess-coder", profile_name="coder", + ) + + adapter = object.__new__(TelegramAdapter) + adapter.platform = Platform.TELEGRAM + adapter._session_store = SimpleNamespace(_db=db) + # Transport is the primary/default adapter stamp... + adapter._hermes_profile_name = "default" + # ...but this send is for the routed coder profile. + adapter._prune_stale_dm_topic_binding( + CHAT, "99", metadata={"hermes_profile": "coder"}, + ) + + 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/tests/test_hermes_state.py b/tests/test_hermes_state.py index 74a028c75471..33277ef315d5 100644 --- a/tests/test_hermes_state.py +++ b/tests/test_hermes_state.py @@ -1447,7 +1447,7 @@ def test_telegram_topic_binding_roundtrip_requires_explicit_schema(self, tmp_pat assert binding["user_id"] == "208214988" assert binding["session_key"] == "telegram:dm:208214988:thread:17585" assert binding["session_id"] == "topic-session" - assert db.get_meta("telegram_dm_topic_schema_version") == "2" + assert db.get_meta("telegram_dm_topic_schema_version") == "3" db.close() diff --git a/website/docs/user-guide/messaging/telegram.md b/website/docs/user-guide/messaging/telegram.md index 346a00456c33..51992a61f572 100644 --- a/website/docs/user-guide/messaging/telegram.md +++ b/website/docs/user-guide/messaging/telegram.md @@ -827,29 +827,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 - `/background ` 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