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:
teknium1
2026-05-03 10:39:47 -07:00
committed by Teknium
parent 1381c89e56
commit d35efb9898
6 changed files with 290 additions and 8 deletions

View File

@ -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, "

View File

@ -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",

View File

@ -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:

View File

@ -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

View File

@ -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. |

View File

@ -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.