feat(telegram): add disable_topic_auto_rename gateway flag

When Hermes auto-titles a session in a Telegram DM topic it currently
renames the topic itself to the generated title. That works for
operator-managed lanes (extra.dm_topics) but is disruptive for
ad-hoc Threaded-Mode topics that users name by hand — every first
exchange overwrites their chosen title.

Add gateway.platforms.telegram.extra.disable_topic_auto_rename (default
False, preserving prior behaviour). When set, both
_schedule_telegram_topic_title_rename and the underlying
_rename_telegram_topic_for_session_title short-circuit before touching
the Telegram API. Internal session titles (sessions list, TUI) keep
working unchanged.

Also bridge the legacy top-level telegram.disable_topic_auto_rename key
through to gateway.platforms.telegram.extra so users on the older
config layout don't have to migrate to enable it.

- Tests cover the runtime flag, the scheduling entry-point, and string
  truthiness coercion for YAML-loaded values.
- Docs updated in messaging/telegram.md with an example block.
This commit is contained in:
B0Tch1 2026-05-18 06:08:54 +08:00 committed by Teknium
parent 3ec28f34ca
commit 9d789f3a5b
4 changed files with 134 additions and 0 deletions

View file

@ -1002,6 +1002,16 @@ def load_gateway_config() -> GatewayConfig:
# Telegram settings → env vars (env vars take precedence)
telegram_cfg = yaml_cfg.get("telegram", {})
if isinstance(telegram_cfg, dict):
# Bridge top-level legacy `telegram.disable_topic_auto_rename` into
# gateway.platforms.telegram.extra so the runtime config sees it.
# Read as a runtime-config flag, not env-var (no need for env override).
if "disable_topic_auto_rename" in telegram_cfg:
_tg_plat = platforms_data.setdefault(Platform.TELEGRAM.value, {})
_tg_extra = _tg_plat.setdefault("extra", {})
_tg_extra.setdefault(
"disable_topic_auto_rename",
telegram_cfg["disable_topic_auto_rename"],
)
# Prefer telegram.require_mention; fall back to the top-level shorthand.
_effective_rm = telegram_cfg.get("require_mention", yaml_cfg.get("require_mention"))
if _effective_rm is not None and not os.getenv("TELEGRAM_REQUIRE_MENTION"):

View file

@ -11808,6 +11808,13 @@ class GatewayRunner:
if not self._is_telegram_topic_lane(source) or not source.chat_id or not source.thread_id:
return
# Operator can fully disable per-topic auto-rename via
# extra.disable_topic_auto_rename. Useful when topics are managed
# by the user (ad-hoc Threaded Mode) and auto-rename would
# overwrite their chosen names every time the auto-title fires.
if self._telegram_topic_auto_rename_disabled(source):
return
# Skip rename when the topic is operator-declared via
# extra.dm_topics. Those topics have fixed names chosen by the
# operator (plus optional skill binding); auto-renaming would
@ -11876,6 +11883,29 @@ class GatewayRunner:
except Exception:
logger.debug("Failed to rename Telegram topic for auto-generated title", exc_info=True)
def _telegram_topic_auto_rename_disabled(self, source: SessionSource) -> bool:
"""Return True when operator disabled per-topic auto-rename for this Telegram chat.
Controlled via ``gateway.platforms.telegram.extra.disable_topic_auto_rename``.
Default is False (auto-rename enabled, preserves prior behaviour).
"""
platform_cfg = (
self.config.platforms.get(source.platform)
if getattr(self, "config", None) and getattr(self.config, "platforms", None)
else None
)
if platform_cfg is None:
return False
extra = getattr(platform_cfg, "extra", None) or {}
value = extra.get("disable_topic_auto_rename")
if value is None:
return False
if isinstance(value, bool):
return value
if isinstance(value, str):
return value.strip().lower() in {"1", "true", "yes", "on"}
return bool(value)
def _schedule_telegram_topic_title_rename(
self,
source: SessionSource,
@ -11885,6 +11915,8 @@ class GatewayRunner:
"""Schedule a topic rename from the auto-title background thread."""
if not title or not self._is_telegram_topic_lane(source):
return
if self._telegram_topic_auto_rename_disabled(source):
return
try:
loop = asyncio.get_running_loop()
except RuntimeError:

View file

@ -840,6 +840,85 @@ async def test_operator_declared_topic_is_not_auto_renamed(tmp_path):
fake.rename_dm_topic.assert_not_called()
@pytest.mark.asyncio
async def test_disable_topic_auto_rename_extra_skips_rename(tmp_path):
"""extra.disable_topic_auto_rename=True must short-circuit auto-rename."""
db = SessionDB(db_path=tmp_path / "state.db")
db.apply_telegram_topic_migration()
db.create_session("sess-topic", source="telegram", user_id="208214988")
db.bind_telegram_topic(
chat_id="208214988",
thread_id="42",
user_id="208214988",
session_key="agent:main:telegram:dm:208214988:42",
session_id="sess-topic",
)
runner = _make_runner(session_db=db)
runner._telegram_topic_mode_enabled = lambda source: True
# Flip the operator switch.
runner.config.platforms[Platform.TELEGRAM].extra["disable_topic_auto_rename"] = True
await runner._rename_telegram_topic_for_session_title(
_make_source(thread_id="42"),
"sess-topic",
"Auto-generated title",
)
runner.adapters[Platform.TELEGRAM].rename_dm_topic.assert_not_called()
@pytest.mark.asyncio
async def test_schedule_topic_rename_respects_disable_flag(tmp_path):
"""The scheduling entry-point must also honour disable_topic_auto_rename."""
db = SessionDB(db_path=tmp_path / "state.db")
runner = _make_runner(session_db=db)
runner._telegram_topic_mode_enabled = lambda source: True
runner.config.platforms[Platform.TELEGRAM].extra["disable_topic_auto_rename"] = "yes"
# If the flag is honoured we never schedule the coroutine, so
# _rename_telegram_topic_for_session_title is never invoked.
called = False
async def _spy(*args, **kwargs):
nonlocal called
called = True
runner._rename_telegram_topic_for_session_title = _spy
runner._schedule_telegram_topic_title_rename(
_make_source(thread_id="42"),
"sess-topic",
"Auto-generated title",
)
# Give any (incorrectly scheduled) coroutine a chance to run.
import asyncio
await asyncio.sleep(0)
assert called is False
def test_telegram_topic_auto_rename_disabled_string_truthy(tmp_path):
"""Common truthy string forms ('1', 'true', 'on', 'yes') must disable rename."""
db = SessionDB(db_path=tmp_path / "state.db")
runner = _make_runner(session_db=db)
source = _make_source(thread_id="42")
cfg_extra = runner.config.platforms[Platform.TELEGRAM].extra
for value in ("1", "true", "TRUE", "yes", "on"):
cfg_extra["disable_topic_auto_rename"] = value
assert runner._telegram_topic_auto_rename_disabled(source) is True, value
for value in ("0", "false", "no", "off", "", None):
cfg_extra["disable_topic_auto_rename"] = value
assert runner._telegram_topic_auto_rename_disabled(source) is False, value
# Explicit bools still work.
cfg_extra["disable_topic_auto_rename"] = True
assert runner._telegram_topic_auto_rename_disabled(source) is True
cfg_extra["disable_topic_auto_rename"] = False
assert runner._telegram_topic_auto_rename_disabled(source) is False
def test_general_topic_is_treated_as_root_lobby(tmp_path):
"""Messages in the Telegram General topic (thread_id=1) route to the lobby, not a lane."""
db = SessionDB(db_path=tmp_path / "state.db")

View file

@ -497,6 +497,18 @@ Every topic gets its own conversation history, model state, tool execution, and
When Hermes generates a session title for a topic (via the auto-title pipeline, after the first exchange), the Telegram topic itself is renamed to match — e.g. "New Topic" becomes "Database migration plan". The rename is best-effort: failures are logged but don't break the session.
To disable this and keep your manually-chosen topic names untouched, set:
```yaml
gateway:
platforms:
telegram:
extra:
disable_topic_auto_rename: true
```
When this flag is on, Hermes still generates an internal session title (used by `hermes sessions`, the TUI, etc.) but never edits the Telegram topic name. Useful when you organise topics by hand under BotFather Threaded Mode and don't want every first reply to overwrite the title.
### `/new` inside a topic
Resets the current topic's session (new session ID, fresh history) without touching other topics. Hermes replies with a reminder that for parallel work, creating another topic (via **All Messages**) is usually what you want.
@ -530,6 +542,7 @@ Shows the current topic's binding: session title, session ID, and hints for `/ne
- 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
- `/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