feat(telegram): /topic off + help + auth gate + screenshot debounce
Four production-readiness additions to topic mode: 1. /topic off — clean disable path. Flips telegram_dm_topic_mode.enabled to 0 and clears telegram_dm_topic_bindings for this chat. Previously users had to edit state.db with sqlite3 to turn the feature off. Idempotent: calling /topic off when the chat was never enabled returns a friendly no-op message. 2. /topic help — inline usage printed in the DM so users don't have to visit docs to discover /topic off, /topic <session-id>, etc. 3. Authorization gate. /topic mutates SQLite side tables and flips the root DM into a lobby, so the action must be authorized. Now calls self._is_user_authorized(source); unauthorized DMs get a refusal instead of activation. Defense in depth on top of the gateway's existing pre-route auth. 4. BotFather screenshot debounce. A user repeatedly running /topic while Threads Settings is still disabled would previously re-upload the same screenshot every time. Now rate-limited to one send per 5 minutes per chat. /topic off resets the counter so re-enabling starts fresh. Command-def args hint updated: /topic [off|help|session-id]. Docs: - New /topic subcommands table at the top of the multi-session section - Disable instructions updated to recommend /topic off first, with the raw SQL fallback kept for bulk cleanup - Under-the-hood list extended with the capability-hint debounce and the authorization gate Tests (6 new): - /topic help returns usage and doesn't create topic tables - /topic off disables mode AND clears bindings - /topic off is idempotent when never enabled - Unauthorized users get refusal, no tables created - Capability-hint debounce is per-chat - /topic off resets both lobby and capability debounce counters All 402 targeted tests pass. Full gateway sweep: 4809/4810 (pre-existing test_teams::test_send_typing unrelated).
This commit is contained in:
107
gateway/run.py
107
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 <id> 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 <id> 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, "
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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 <id>" 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
|
||||
|
||||
@ -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 <session-id>` 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. |
|
||||
|
||||
@ -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 <session-id>` | 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 <prompt>` 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 = '<your_chat_id>'"
|
||||
"UPDATE telegram_dm_topic_mode SET enabled = 0 WHERE chat_id = '<your_chat_id>'; \
|
||||
DELETE FROM telegram_dm_topic_bindings WHERE chat_id = '<your_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 = '<your_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.
|
||||
|
||||
Reference in New Issue
Block a user