diff --git a/gateway/run.py b/gateway/run.py index 6b40532e64..f90b2b1b03 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -9838,6 +9838,84 @@ class GatewayRunner: future.add_done_callback(_log_rename_failure) + _TELEGRAM_CAPABILITY_HINT_COOLDOWN_S = 300.0 + + def _should_send_telegram_capability_hint(self, source: SessionSource) -> bool: + """Rate-limit the BotFather Threads Settings screenshot. + + If a user sends /topic repeatedly while Threads Settings are still + off, we shouldn't keep re-uploading the screenshot every time. + """ + if not hasattr(self, "_telegram_capability_hint_ts"): + self._telegram_capability_hint_ts = {} + chat_id = str(source.chat_id or "") + if not chat_id: + return True + import time as _time + now = _time.monotonic() + last = self._telegram_capability_hint_ts.get(chat_id, 0.0) + if now - last < self._TELEGRAM_CAPABILITY_HINT_COOLDOWN_S: + return False + self._telegram_capability_hint_ts[chat_id] = now + return True + + def _telegram_topic_help_text(self) -> str: + return ( + "/topic — enable multi-session DM mode (one bot, many parallel chats)\n" + "\n" + "Usage:\n" + " /topic Enable topic mode, or show status if already on\n" + " /topic help Show this message\n" + " /topic off Disable topic mode and clear topic bindings\n" + " /topic Inside a topic: restore a previous session by ID\n" + "\n" + "How it works:\n" + "1. Run /topic once in this DM — Hermes checks BotFather Threads\n" + " Settings are enabled and flips on multi-session mode.\n" + "2. Tap All Messages at the top of the bot and send any message.\n" + " Telegram creates a new topic for that message; each topic is\n" + " an independent Hermes session (fresh history, fresh context).\n" + "3. The root DM becomes a system lobby — send /topic, /status,\n" + " /help, /usage there. Normal prompts go in a topic.\n" + "4. /new inside a topic resets just that topic's session.\n" + "5. /topic inside a topic restores an old session into it." + ) + + def _disable_telegram_topic_mode_for_chat(self, source: SessionSource) -> str: + """Cleanly disable topic mode for a chat via /topic off.""" + if not self._session_db: + return "Session database not available." + chat_id = str(source.chat_id or "") + if not chat_id: + return "Could not determine chat ID." + # No-op if never enabled. + try: + currently_enabled = self._session_db.is_telegram_topic_mode_enabled( + chat_id=chat_id, + user_id=str(source.user_id or ""), + ) + except Exception: + currently_enabled = False + if not currently_enabled: + return "Multi-session topic mode is not currently enabled for this chat." + try: + self._session_db.disable_telegram_topic_mode(chat_id=chat_id) + 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) + return ( + "Multi-session topic mode is now OFF for this chat.\n\n" + "Existing topics in Telegram aren't removed — they'll just stop " + "being gated as independent sessions. The root DM works as a " + "normal Hermes chat again. Run /topic to re-enable later." + ) + async def _handle_topic_command(self, event: MessageEvent, args: str = "") -> str: """Handle /topic for Telegram DM user-managed topic sessions.""" source = event.source @@ -9846,7 +9924,28 @@ class GatewayRunner: if not self._session_db: return "Session database not available." + # Authorization: /topic activates multi-session mode and mutates + # SQLite side tables. Unauthorized senders (not in allowlist) must + # not be able to do that. Gateway routes already authorize the + # message before reaching here, but defense in depth. + auth_fn = getattr(self, "_is_user_authorized", None) + if callable(auth_fn): + try: + if not auth_fn(source): + return "You are not authorized to use /topic on this bot." + except Exception: + logger.debug("Topic auth check failed", exc_info=True) + args = event.get_command_args().strip() + + # /topic help — inline usage without leaving the bot. + if args.lower() in {"help", "?", "-h", "--help"}: + return self._telegram_topic_help_text() + + # /topic off — clean disable path so users don't have to edit the DB. + if args.lower() in {"off", "disable", "stop"}: + return self._disable_telegram_topic_mode_for_chat(source) + if args: if not source.thread_id: return ( @@ -9859,7 +9958,10 @@ class GatewayRunner: capabilities = await self._get_telegram_topic_capabilities(source) if capabilities.get("checked"): if capabilities.get("has_topics_enabled") is False: - await self._send_telegram_topic_setup_image(source) + # Debounce the BotFather screenshot: don't re-send on every + # /topic while threads are still disabled. + if self._should_send_telegram_capability_hint(source): + await self._send_telegram_topic_setup_image(source) return ( "Telegram topics are not enabled for this bot yet.\n\n" "How to enable them:\n" @@ -9870,7 +9972,8 @@ class GatewayRunner: "Then send /topic again." ) if capabilities.get("allows_users_to_create_topics") is False: - await self._send_telegram_topic_setup_image(source) + if self._should_send_telegram_capability_hint(source): + await self._send_telegram_topic_setup_image(source) return ( "Telegram topics are enabled, but users are not allowed to create topics.\n\n" "Open @BotFather → choose your bot → Bot Settings → Threads Settings, " diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index cc2365c90d..2cf2c3e9f4 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -66,7 +66,7 @@ COMMAND_REGISTRY: list[CommandDef] = [ CommandDef("new", "Start a new session (fresh session ID + history)", "Session", aliases=("reset",), args_hint="[name]"), CommandDef("topic", "Enable or inspect Telegram DM topic sessions", "Session", - gateway_only=True, args_hint="[session-id]"), + gateway_only=True, args_hint="[off|help|session-id]"), CommandDef("clear", "Clear screen and start a new session", "Session", cli_only=True), CommandDef("redraw", "Force a full UI repaint (recovers from terminal drift)", "Session", diff --git a/hermes_state.py b/hermes_state.py index 7d1a7d03a7..98bd68bee5 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -2297,6 +2297,39 @@ class SessionDB: ) self._execute_write(_do) + def disable_telegram_topic_mode( + self, + *, + chat_id: str, + clear_bindings: bool = True, + ) -> None: + """Disable Telegram DM topic mode for one private chat. + + When ``clear_bindings`` is True (default) the (chat_id, thread_id) + bindings for this chat are also cleared so re-enabling later + starts from a clean slate. Set to False if the operator wants to + preserve bindings for a later re-enable. + + 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. + """ + def _do(conn): + try: + conn.execute( + "UPDATE telegram_dm_topic_mode SET enabled = 0, updated_at = ? " + "WHERE chat_id = ?", + (time.time(), str(chat_id)), + ) + if clear_bindings: + conn.execute( + "DELETE FROM telegram_dm_topic_bindings WHERE chat_id = ?", + (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: """Return whether Telegram DM topic mode is enabled for this chat/user.""" with self._lock: diff --git a/tests/gateway/test_telegram_topic_mode.py b/tests/gateway/test_telegram_topic_mode.py index 665cff03fd..bfa92b4fd0 100644 --- a/tests/gateway/test_telegram_topic_mode.py +++ b/tests/gateway/test_telegram_topic_mode.py @@ -983,3 +983,133 @@ def test_migration_rebuilds_v1_binding_table_with_cascade_fk(tmp_path): "SELECT value FROM state_meta WHERE key = 'telegram_dm_topic_schema_version'" ).fetchone() assert version is not None and version[0] == "2" + + +@pytest.mark.asyncio +async def test_topic_help_subcommand_returns_usage(tmp_path): + """/topic help surfaces usage without activating anything.""" + db = SessionDB(db_path=tmp_path / "state.db") + runner = _make_runner(session_db=db) + + result = await runner._handle_topic_command(_make_event("/topic help")) + + assert "/topic help" in result + assert "/topic off" in result + assert "/topic " in result + # No side effects — topic mode tables should not even exist yet. + tables = { + row[0] + for row in db._conn.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'telegram_dm%'" + ).fetchall() + } + assert tables == set() + + +@pytest.mark.asyncio +async def test_topic_off_disables_mode_and_clears_bindings(tmp_path, monkeypatch): + """/topic off flips the row off AND deletes bindings for this chat.""" + import gateway.run as gateway_run + + db = SessionDB(db_path=tmp_path / "state.db") + db.enable_telegram_topic_mode(chat_id="208214988", user_id="208214988") + db.create_session(session_id="topic-sess", source="telegram", user_id="208214988") + db.bind_telegram_topic( + chat_id="208214988", + thread_id="17585", + user_id="208214988", + session_key="k", + session_id="topic-sess", + ) + runner = _make_runner(session_db=db) + + monkeypatch.setattr( + gateway_run, "_resolve_runtime_agent_kwargs", lambda: {"api_key": "***"} + ) + + result = await runner._handle_topic_command(_make_event("/topic off")) + + assert "OFF" in result or "off" in result + assert db.is_telegram_topic_mode_enabled( + chat_id="208214988", user_id="208214988" + ) is False + # Bindings cleared. + assert db.get_telegram_topic_binding( + chat_id="208214988", thread_id="17585" + ) is None + + +@pytest.mark.asyncio +async def test_topic_off_is_idempotent_when_never_enabled(tmp_path): + """/topic off against a chat that never ran /topic is a no-op message.""" + db = SessionDB(db_path=tmp_path / "state.db") + runner = _make_runner(session_db=db) + + result = await runner._handle_topic_command(_make_event("/topic off")) + + assert "not currently enabled" in result + + +@pytest.mark.asyncio +async def test_topic_refuses_unauthorized_user(tmp_path, monkeypatch): + """Unauthorized DMs cannot flip multi-session mode on.""" + import gateway.run as gateway_run + + db = SessionDB(db_path=tmp_path / "state.db") + runner = _make_runner(session_db=db) + runner._is_user_authorized = lambda _source: False # Deny + + monkeypatch.setattr( + gateway_run, "_resolve_runtime_agent_kwargs", lambda: {"api_key": "***"} + ) + + result = await runner._handle_topic_command(_make_event("/topic")) + + assert "not authorized" in result.lower() + # Tables must not be created for an unauthorized caller. + tables = { + row[0] + for row in db._conn.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'telegram_dm%'" + ).fetchall() + } + assert tables == set() + + +def test_capability_hint_is_debounced_per_chat(tmp_path): + """BotFather screenshot is sent once per cooldown window per chat.""" + db = SessionDB(db_path=tmp_path / "state.db") + runner = _make_runner(session_db=db) + + source = _make_source() + assert runner._should_send_telegram_capability_hint(source) is True + assert runner._should_send_telegram_capability_hint(source) is False + assert runner._should_send_telegram_capability_hint(source) is False + + from dataclasses import replace + other = replace(source, chat_id="999999999") + assert runner._should_send_telegram_capability_hint(other) is True + + +def test_topic_off_resets_debounce_counters(tmp_path): + """Disabling topic mode clears per-chat debounce state.""" + db = SessionDB(db_path=tmp_path / "state.db") + db.enable_telegram_topic_mode(chat_id="208214988", user_id="208214988") + runner = _make_runner(session_db=db) + + source = _make_source() + # Prime the debounce counters. + assert runner._should_send_telegram_lobby_reminder(source) is True + assert runner._should_send_telegram_capability_hint(source) is True + assert runner._should_send_telegram_lobby_reminder(source) is False + assert runner._should_send_telegram_capability_hint(source) is False + + # /topic off resets them. + result = runner._disable_telegram_topic_mode_for_chat(source) + assert "OFF" in result or "off" in result + + # Re-enable and verify counters reset (so the first reminder/hint + # after re-enabling can land immediately). + db.enable_telegram_topic_mode(chat_id="208214988", user_id="208214988") + assert runner._should_send_telegram_lobby_reminder(source) is True + assert runner._should_send_telegram_capability_hint(source) is True diff --git a/website/docs/reference/slash-commands.md b/website/docs/reference/slash-commands.md index c96a6986d5..75158d6a0d 100644 --- a/website/docs/reference/slash-commands.md +++ b/website/docs/reference/slash-commands.md @@ -145,7 +145,7 @@ The messaging gateway supports the following built-in commands inside Telegram, | `/undo` | Remove the last exchange. | | `/sethome` (alias: `/set-home`) | Mark the current chat as the platform home channel for deliveries. | | `/compress [focus topic]` | Manually compress conversation context. Optional focus topic narrows what the summary preserves. | -| `/topic [session-id]` | **Telegram DM only.** Enable or inspect user-managed multi-session topic mode. See [Multi-session DM mode](/docs/user-guide/messaging/telegram#multi-session-dm-mode-topic). | +| `/topic [off\|help\|session-id]` | **Telegram DM only.** Manage user-managed multi-session topic mode. `/topic` enables it or shows status; `/topic off` disables it and clears bindings; `/topic help` shows usage; `/topic ` inside a topic restores a previous session. See [Multi-session DM mode](/docs/user-guide/messaging/telegram#multi-session-dm-mode-topic). | | `/title [name]` | Set or show the session title. | | `/resume [name]` | Resume a previously named session. | | `/usage` | Show token usage, estimated cost breakdown (input/output), context window state, session duration, and — when available from the active provider — an **Account limits** section with remaining quota / credits pulled live from the provider's API. | diff --git a/website/docs/user-guide/messaging/telegram.md b/website/docs/user-guide/messaging/telegram.md index 6a572805bf..eab5212241 100644 --- a/website/docs/user-guide/messaging/telegram.md +++ b/website/docs/user-guide/messaging/telegram.md @@ -400,6 +400,19 @@ Topics created outside of the config (e.g., by manually calling the Telegram API A ChatGPT-style multi-session DM — one bot, many parallel conversations. Unlike the operator-curated `extra.dm_topics` above, this mode is **user-driven**: no config, no pre-declared topic names. The end user flips it on with `/topic`, then taps the Telegram **+** button to create as many topics as they want, each one a fully independent Hermes session. +### `/topic` subcommands + +| Form | Context | Effect | +|------|---------|--------| +| `/topic` | Root DM, not yet enabled | Check BotFather capabilities, enable multi-session mode, create pinned System topic | +| `/topic` | Root DM, already enabled | Show status: unlinked sessions available for restore | +| `/topic` | Inside a topic | Show the current topic's session binding | +| `/topic help` | Any | Inline usage | +| `/topic off` | Root DM | Disable multi-session mode and clear all topic bindings for this chat | +| `/topic ` | Inside a topic | Restore a previous Telegram session into the current topic | + +Only authorized users (allowlist via `TELEGRAM_ALLOWED_USERS` / platform auth config) can run `/topic`. An unauthorized sender gets a refusal instead of activation. + ### DM Topics vs Multi-session DM mode | | `extra.dm_topics` (config-driven) | `/topic` (user-driven) | @@ -487,19 +500,22 @@ Shows the current topic's binding: session title, session ID, and hints for `/ne - Topics declared in `extra.dm_topics` are **never auto-renamed** — the operator-chosen name is preserved even when multi-session mode is enabled - 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 - `/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 -There is no slash command to exit multi-session mode. If you need to turn it off, remove the row manually: +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. + +If you need to clean up by hand (e.g. a bulk reset across many chats), remove the rows directly: ```bash sqlite3 ~/.hermes/state.db \ - "DELETE FROM telegram_dm_topic_mode WHERE chat_id = ''" + "UPDATE telegram_dm_topic_mode SET enabled = 0 WHERE chat_id = ''; \ + DELETE FROM telegram_dm_topic_bindings WHERE chat_id = '';" ``` -Existing topics in Telegram won't disappear — they'll just stop being gated as independent sessions on the Hermes side. The binding rows can also be cleared with `DELETE FROM telegram_dm_topic_bindings WHERE chat_id = ''`. - ### Downgrading Hermes If you downgrade to a Hermes version that predates `/topic`, the feature simply stops working — the `telegram_dm_topic_mode` and `telegram_dm_topic_bindings` tables remain in `state.db` but are ignored by older code. DMs revert to the native per-thread isolation (each `message_thread_id` still gets its own session via `build_session_key`), so your existing Telegram topics keep working as parallel sessions. The root DM is no longer a lobby — messages there go into the agent like they used to. Re-upgrading reactivates multi-session mode exactly where it was.