mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-07 02:51:50 +00:00
fix(telegram): polish topic mode — CASCADE, General-topic handling, rename guard, debounce
Five follow-ups to topic mode based on integration audit: 1. ON DELETE CASCADE on telegram_dm_topic_bindings.session_id. Session pruning (manual /delete, auto-cleanup, any future prune job) would have thrown 'FOREIGN KEY constraint failed' for sessions bound to a topic. Migration bumped to v2, rebuilds the bindings table in place if FK lacks CASCADE. Idempotent; only runs once per DB. 2. Never auto-rename operator-declared topics. If an operator has extra.dm_topics configured AND a user runs /topic, messages in those pre-declared topics would previously trigger auto-rename and silently mutate operator config. _rename_telegram_topic_for_session_title now early-returns when _get_dm_topic_info returns a dict for this (chat_id, thread_id). Uses class-based lookup (not hasattr) so MagicMock test fixtures don't accidentally trip the guard. 3. General topic handling. Telegram's General (pinned top) topic in a forum-enabled private chat may send messages with message_thread_id=1 or omit thread_id entirely depending on client. Both are now treated as the root lobby, not a topic lane. Prevents users from accidentally burning a session on the General topic. 4. Debounce the root-lobby reminder. 30-second cooldown per chat so a user who forgets topic mode is enabled and types ten messages in the root gets one reminder, not ten. Explicit command replies (/new-in-lobby, /topic <session-id>) still land every time. 5. Docs: added under-the-hood invariants for the above, plus a Downgrade section explaining that rolling back to a pre-/topic Hermes build leaves the DB tables orphaned but harmless — DMs just revert to native per-thread isolation. Tests: - test_operator_declared_topic_is_not_auto_renamed - test_general_topic_is_treated_as_root_lobby - test_lobby_reminder_is_debounced_per_chat - test_binding_survives_session_deletion_via_cascade - test_migration_rebuilds_v1_binding_table_with_cascade_fk Validated: 4803/4804 tests pass (tests/gateway/ + tests/test_hermes_state.py). Sole failure is a pre-existing test_teams::test_send_typing flake unrelated to this PR.
This commit is contained in:
parent
1a9542cf75
commit
1381c89e56
5 changed files with 291 additions and 21 deletions
|
|
@ -1475,23 +1475,52 @@ class GatewayRunner:
|
|||
# opt into topic mode) means topic mode is off for this chat.
|
||||
return raw is True
|
||||
|
||||
# Telegram's General (pinned top) topic in forum-enabled private chats.
|
||||
# Bot API behavior varies: some clients omit message_thread_id for
|
||||
# General, others send "1". Treat both as "root" for lobby/lane purposes.
|
||||
_TELEGRAM_GENERAL_TOPIC_IDS = frozenset({"", "1"})
|
||||
|
||||
def _is_telegram_topic_root_lobby(self, source: SessionSource) -> bool:
|
||||
"""True for the main Telegram DM when topic mode has made it a lobby."""
|
||||
return (
|
||||
source.platform == Platform.TELEGRAM
|
||||
and source.chat_type == "dm"
|
||||
and not source.thread_id
|
||||
and self._telegram_topic_mode_enabled(source)
|
||||
)
|
||||
"""True for the main Telegram DM (or General topic) when topic mode has made it a lobby."""
|
||||
if source.platform != Platform.TELEGRAM or source.chat_type != "dm":
|
||||
return False
|
||||
if not self._telegram_topic_mode_enabled(source):
|
||||
return False
|
||||
tid = str(source.thread_id or "")
|
||||
return tid in self._TELEGRAM_GENERAL_TOPIC_IDS
|
||||
|
||||
def _is_telegram_topic_lane(self, source: SessionSource) -> bool:
|
||||
"""True for a user-created Telegram private-chat topic lane."""
|
||||
return (
|
||||
source.platform == Platform.TELEGRAM
|
||||
and source.chat_type == "dm"
|
||||
and bool(source.thread_id)
|
||||
and self._telegram_topic_mode_enabled(source)
|
||||
)
|
||||
if source.platform != Platform.TELEGRAM or source.chat_type != "dm":
|
||||
return False
|
||||
if not self._telegram_topic_mode_enabled(source):
|
||||
return False
|
||||
tid = str(source.thread_id or "")
|
||||
if not tid or tid in self._TELEGRAM_GENERAL_TOPIC_IDS:
|
||||
return False
|
||||
return True
|
||||
|
||||
_TELEGRAM_LOBBY_REMINDER_COOLDOWN_S = 30.0
|
||||
|
||||
def _should_send_telegram_lobby_reminder(self, source: SessionSource) -> bool:
|
||||
"""Rate-limit root-DM lobby reminders to one message per cooldown window.
|
||||
|
||||
A user who forgets multi-session mode is enabled and types several
|
||||
prompts in the root DM would otherwise get a reminder for every
|
||||
message. Cap it so the first one lands and the rest stay quiet.
|
||||
"""
|
||||
if not hasattr(self, "_telegram_lobby_reminder_ts"):
|
||||
self._telegram_lobby_reminder_ts = {}
|
||||
chat_id = str(source.chat_id or "")
|
||||
if not chat_id:
|
||||
return True
|
||||
import time as _time
|
||||
now = _time.monotonic()
|
||||
last = self._telegram_lobby_reminder_ts.get(chat_id, 0.0)
|
||||
if now - last < self._TELEGRAM_LOBBY_REMINDER_COOLDOWN_S:
|
||||
return False
|
||||
self._telegram_lobby_reminder_ts[chat_id] = now
|
||||
return True
|
||||
|
||||
def _telegram_topic_root_lobby_message(self) -> str:
|
||||
return (
|
||||
|
|
@ -5617,7 +5646,11 @@ class GatewayRunner:
|
|||
# execution of a dangerous command.
|
||||
|
||||
if self._is_telegram_topic_root_lobby(source):
|
||||
return self._telegram_topic_root_lobby_message()
|
||||
# Debounce the lobby reminder so a user who forgets about
|
||||
# topic mode and fires ten prompts doesn't get ten copies.
|
||||
if self._should_send_telegram_lobby_reminder(source):
|
||||
return self._telegram_topic_root_lobby_message()
|
||||
return None
|
||||
|
||||
# ── Claim this session before any await ───────────────────────
|
||||
# Between here and _run_agent registering the real AIAgent, there
|
||||
|
|
@ -9705,6 +9738,28 @@ class GatewayRunner:
|
|||
"""Best-effort rename of a Telegram DM topic when Hermes auto-titles a session."""
|
||||
if not self._is_telegram_topic_lane(source) or not source.chat_id or not source.thread_id:
|
||||
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
|
||||
# silently mutate operator config.
|
||||
#
|
||||
# Check the class, not the instance — getattr() on MagicMock
|
||||
# auto-creates attributes, so `hasattr(adapter, "_get_dm_topic_info")`
|
||||
# would return True for every test double.
|
||||
adapter = self.adapters.get(source.platform) if getattr(self, "adapters", None) else None
|
||||
if adapter is not None:
|
||||
get_info = getattr(type(adapter), "_get_dm_topic_info", None)
|
||||
if callable(get_info):
|
||||
try:
|
||||
operator_topic = get_info(adapter, str(source.chat_id), str(source.thread_id))
|
||||
except Exception:
|
||||
operator_topic = None
|
||||
# Only treat dict-shaped returns as operator-declared; a
|
||||
# bare MagicMock or other sentinel shouldn't count.
|
||||
if isinstance(operator_topic, dict):
|
||||
return
|
||||
|
||||
session_db = getattr(self, "_session_db", None)
|
||||
if session_db is not None:
|
||||
try:
|
||||
|
|
@ -9718,7 +9773,6 @@ class GatewayRunner:
|
|||
logger.debug("Failed to verify Telegram topic binding before rename", exc_info=True)
|
||||
return
|
||||
|
||||
adapter = self.adapters.get(source.platform) if getattr(self, "adapters", None) else None
|
||||
if adapter is None:
|
||||
return
|
||||
topic_name = self._sanitize_telegram_topic_title(title)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue