From b20d936b6da94ae276a72cdeda2a807d3e44455a Mon Sep 17 00:00:00 2001 From: Celio Monteiro Date: Sat, 1 Aug 2026 21:43:18 -0300 Subject: [PATCH 1/3] fix(state): namespace telegram topic tables by profile_name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue #76423: under multiplex_profiles a shared state.db keyed topic mode and bindings only by Telegram chat_id/thread_id, so private-chat ids collided across bots/profiles. - Add profile_name to telegram_dm_topic_mode and telegram_dm_topic_bindings - Schema v2→v3 rebuild; legacy rows migrate into the "default" namespace - Keyword-only profile_name="default" on SessionDB topic APIs (compat) --- hermes_state.py | 240 ++++++++++++++---- ..._telegram_topic_profile_isolation_76423.py | 138 ++++++++++ tests/test_hermes_state.py | 2 +- 3 files changed, 335 insertions(+), 45 deletions(-) create mode 100644 tests/gateway/test_telegram_topic_profile_isolation_76423.py diff --git a/hermes_state.py b/hermes_state.py index 4f7b6453461a9..9485c7c2b9752 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -397,6 +397,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" + + def format_session_db_unavailable(prefix: str = "Session database not available") -> str: """Format a user-facing 'session DB unavailable' message with cause. @@ -7958,12 +7969,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, @@ -7972,10 +7997,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, @@ -7984,26 +8011,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() @@ -8038,11 +8064,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) @@ -8051,6 +8156,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: @@ -8058,9 +8164,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: @@ -8071,11 +8183,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, @@ -8084,6 +8196,7 @@ def _do(conn): capability_checked_at = excluded.capability_checked_at """, ( + profile_name, str(chat_id), str(user_id), now, @@ -8099,6 +8212,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. @@ -8111,33 +8225,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 @@ -8151,16 +8275,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 @@ -8170,18 +8296,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 [] @@ -8216,6 +8345,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. @@ -8246,6 +8376,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): @@ -8253,9 +8384,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: @@ -8271,15 +8402,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. @@ -8297,6 +8429,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. @@ -8311,28 +8444,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, @@ -8340,6 +8483,7 @@ def _do(conn): updated_at = excluded.updated_at """, ( + profile_name, chat_id, thread_id, user_id, @@ -8379,6 +8523,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. @@ -8387,7 +8532,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( @@ -8407,6 +8558,7 @@ def list_unlinked_telegram_sessions_for_user( FROM sessions s 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 @@ -8414,11 +8566,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/tests/gateway/test_telegram_topic_profile_isolation_76423.py b/tests/gateway/test_telegram_topic_profile_isolation_76423.py new file mode 100644 index 0000000000000..a0e77a7d83960 --- /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/test_hermes_state.py b/tests/test_hermes_state.py index dd54682ef5d26..b845851913bc6 100644 --- a/tests/test_hermes_state.py +++ b/tests/test_hermes_state.py @@ -1272,7 +1272,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() From 762d0a15a887390015c439c130047ec6e27e3cdf Mon Sep 17 00:00:00 2001 From: Celio Monteiro Date: Sat, 1 Aug 2026 21:43:18 -0300 Subject: [PATCH 2/3] fix(gateway): pass routed source.profile into telegram topic state Issue #76423 follow-up: wire SessionDB profile_name through gateway paths. - Resolve profile from source.profile (never process-global active profile) - Stamp adapter._hermes_profile_name for prune under multiplex - /topic enable/status and binding record/recover/disable/restore paths --- gateway/run.py | 34 +++++++++++- gateway/slash_commands.py | 2 + plugins/platforms/telegram/adapter.py | 7 ++- ...st_telegram_topic_profile_routing_76423.py | 54 +++++++++++++++++++ 4 files changed, 95 insertions(+), 2 deletions(-) create mode 100644 tests/gateway/test_telegram_topic_profile_routing_76423.py diff --git a/gateway/run.py b/gateway/run.py index a00af86cf6b82..ab23da9baf4b7 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -6370,6 +6370,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": @@ -6383,6 +6395,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) @@ -6484,6 +6497,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( @@ -6551,6 +6565,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) @@ -10650,6 +10665,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) @@ -11751,6 +11769,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 @@ -12695,6 +12714,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 @@ -15576,6 +15598,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) @@ -19107,6 +19130,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 @@ -19264,13 +19288,17 @@ 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}" @@ -19301,6 +19329,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: @@ -19350,9 +19379,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: @@ -19367,6 +19398,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): diff --git a/gateway/slash_commands.py b/gateway/slash_commands.py index f7e96c3375cff..01a05a3340282 100644 --- a/gateway/slash_commands.py +++ b/gateway/slash_commands.py @@ -4201,6 +4201,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"), ) @@ -4216,6 +4217,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/plugins/platforms/telegram/adapter.py b/plugins/platforms/telegram/adapter.py index ef31587d223d7..69f5d0251a7f6 100644 --- a/plugins/platforms/telegram/adapter.py +++ b/plugins/platforms/telegram/adapter.py @@ -1318,8 +1318,13 @@ def _prune_stale_dm_topic_binding( 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" 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=profile_name, ) except Exception: logger.debug( 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 0000000000000..3ce892cea9495 --- /dev/null +++ b/tests/gateway/test_telegram_topic_profile_routing_76423.py @@ -0,0 +1,54 @@ +"""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() From dca8dc73c892cd6e5833051e471bc66b75e77b00 Mon Sep 17 00:00:00 2001 From: Celio Monteiro Date: Sat, 1 Aug 2026 22:31:53 -0300 Subject: [PATCH 3/3] fix(gateway): route profile into topic prune, cooldowns, and docs Address hermes-sweeper review on #76487: - Prefer hermes_profile from send metadata when pruning stale topic bindings so profile_routes cannot delete the transport adapter's namespace instead of the routed runtime's - Namespace lobby/capability cooldowns and /topic off cleanup by (profile, chat_id) - Document profile_name PKs and scoped cleanup SQL in telegram.md - Regression: primary-adapter stamp + routed metadata prune isolation --- gateway/platforms/base.py | 6 ++ gateway/run.py | 48 ++++++++++---- plugins/platforms/telegram/adapter.py | 40 +++++++++--- ...st_telegram_topic_profile_routing_76423.py | 64 +++++++++++++++++++ website/docs/user-guide/messaging/telegram.md | 22 ++++--- 5 files changed, 148 insertions(+), 32 deletions(-) diff --git a/gateway/platforms/base.py b/gateway/platforms/base.py index d7654ff6c147a..2e60b7f33022c 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 ab23da9baf4b7..7c943147cceaa 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -6432,6 +6432,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. @@ -6441,15 +6452,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: @@ -19242,15 +19253,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: @@ -19302,12 +19313,14 @@ async def _disable_telegram_topic_mode_for_chat(self, source: SessionSource) -> 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 " @@ -19762,6 +19775,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/plugins/platforms/telegram/adapter.py b/plugins/platforms/telegram/adapter.py index 69f5d0251a7f6..6a80d22e7bf64 100644 --- a/plugins/platforms/telegram/adapter.py +++ b/plugins/platforms/telegram/adapter.py @@ -1295,7 +1295,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. @@ -1308,6 +1313,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 @@ -1318,13 +1333,16 @@ def _prune_stale_dm_topic_binding( 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" + 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), - profile_name=profile_name, + profile_name=resolved, ) except Exception: logger.debug( @@ -4593,7 +4611,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 @@ -5352,9 +5372,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_routing_76423.py b/tests/gateway/test_telegram_topic_profile_routing_76423.py index 3ce892cea9495..fab8090bf81df 100644 --- a/tests/gateway/test_telegram_topic_profile_routing_76423.py +++ b/tests/gateway/test_telegram_topic_profile_routing_76423.py @@ -52,3 +52,67 @@ 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_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/website/docs/user-guide/messaging/telegram.md b/website/docs/user-guide/messaging/telegram.md index 346a00456c333..51992a61f5722 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