fix(gateway): route profile into topic prune, cooldowns, and docs
Address hermes-sweeper review on #76487: - Prefer hermes_profile from send metadata when pruning stale topic bindings so profile_routes cannot delete the transport adapter's namespace instead of the routed runtime's - Namespace lobby/capability cooldowns and /topic off cleanup by (profile, chat_id) - Document profile_name PKs and scoped cleanup SQL in telegram.md - Regression: primary-adapter stamp + routed metadata prune isolation
This commit is contained in:
@@ -161,6 +161,12 @@ def _thread_metadata_for_source(source, reply_to_message_id: str | None = None)
|
||||
anchor = reply_to_message_id or getattr(source, "message_id", None)
|
||||
if anchor is not None:
|
||||
metadata["telegram_reply_to_message_id"] = str(anchor)
|
||||
# Routed Hermes profile for shared state.db namespaces (topic bindings
|
||||
# under multiplex / profile_routes). Outbound prune paths must not
|
||||
# assume the transport adapter's static profile stamp.
|
||||
profile = str(getattr(source, "profile", None) or "").strip()
|
||||
if profile:
|
||||
metadata["hermes_profile"] = profile
|
||||
return metadata
|
||||
|
||||
|
||||
|
||||
+34
-14
@@ -8571,6 +8571,17 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
|
||||
_TELEGRAM_LOBBY_REMINDER_COOLDOWN_S = 30.0
|
||||
|
||||
def _telegram_topic_cooldown_key(self, source: SessionSource) -> Optional[str]:
|
||||
"""Cooldown key for topic-mode cooldowns: (profile, chat_id).
|
||||
|
||||
Profiles sharing a Telegram private chat_id under multiplex must not
|
||||
suppress each other's lobby reminders / capability hints (#76423).
|
||||
"""
|
||||
chat_id = str(source.chat_id or "")
|
||||
if not chat_id:
|
||||
return None
|
||||
return f"{self._telegram_topic_profile_name(source)}:{chat_id}"
|
||||
|
||||
def _should_send_telegram_lobby_reminder(self, source: SessionSource) -> bool:
|
||||
"""Rate-limit root-DM lobby reminders to one message per cooldown window.
|
||||
|
||||
@@ -8580,15 +8591,15 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
"""
|
||||
if not hasattr(self, "_telegram_lobby_reminder_ts"):
|
||||
self._telegram_lobby_reminder_ts = {}
|
||||
chat_id = str(source.chat_id or "")
|
||||
if not chat_id:
|
||||
key = self._telegram_topic_cooldown_key(source)
|
||||
if not key:
|
||||
return True
|
||||
import time as _time
|
||||
now = _time.monotonic()
|
||||
last = self._telegram_lobby_reminder_ts.get(chat_id, 0.0)
|
||||
last = self._telegram_lobby_reminder_ts.get(key, 0.0)
|
||||
if now - last < self._TELEGRAM_LOBBY_REMINDER_COOLDOWN_S:
|
||||
return False
|
||||
self._telegram_lobby_reminder_ts[chat_id] = now
|
||||
self._telegram_lobby_reminder_ts[key] = now
|
||||
return True
|
||||
|
||||
def _telegram_topic_root_lobby_message(self) -> str:
|
||||
@@ -25724,15 +25735,15 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
"""
|
||||
if not hasattr(self, "_telegram_capability_hint_ts"):
|
||||
self._telegram_capability_hint_ts = {}
|
||||
chat_id = str(source.chat_id or "")
|
||||
if not chat_id:
|
||||
key = self._telegram_topic_cooldown_key(source)
|
||||
if not key:
|
||||
return True
|
||||
import time as _time
|
||||
now = _time.monotonic()
|
||||
last = self._telegram_capability_hint_ts.get(chat_id, 0.0)
|
||||
last = self._telegram_capability_hint_ts.get(key, 0.0)
|
||||
if now - last < self._TELEGRAM_CAPABILITY_HINT_COOLDOWN_S:
|
||||
return False
|
||||
self._telegram_capability_hint_ts[chat_id] = now
|
||||
self._telegram_capability_hint_ts[key] = now
|
||||
return True
|
||||
|
||||
def _telegram_topic_help_text(self) -> str:
|
||||
@@ -25784,12 +25795,14 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
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)
|
||||
# Reset per-profile+chat debounce state so the user doesn't see a
|
||||
# stale cooldown on the next activation (issue #76423).
|
||||
cooldown_key = self._telegram_topic_cooldown_key(source)
|
||||
if cooldown_key:
|
||||
for attr in ("_telegram_lobby_reminder_ts", "_telegram_capability_hint_ts"):
|
||||
store = getattr(self, attr, None)
|
||||
if isinstance(store, dict):
|
||||
store.pop(cooldown_key, 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 "
|
||||
@@ -26264,6 +26277,13 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
metadata.setdefault("scope_id", str(team_id))
|
||||
if user_id:
|
||||
metadata.setdefault("user_id", str(user_id))
|
||||
# Routed profile for shared state.db namespaces (#76423): the Telegram
|
||||
# prune path needs it because under profile_routes the transport
|
||||
# adapter's stamp is not the profile that wrote the binding.
|
||||
profile = str(getattr(source, "profile", None) or "").strip()
|
||||
if profile and metadata is not None:
|
||||
metadata = dict(metadata)
|
||||
metadata["hermes_profile"] = profile
|
||||
return metadata
|
||||
|
||||
def _thread_metadata_for_target(
|
||||
|
||||
@@ -1686,7 +1686,11 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
return "thread not found" in str(error).lower()
|
||||
|
||||
def _prune_stale_dm_topic_binding(
|
||||
self, chat_id: Any, thread_id: Any,
|
||||
self,
|
||||
chat_id: Any,
|
||||
thread_id: Any,
|
||||
*,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> None:
|
||||
"""Drop the stale ``telegram_dm_topic_bindings`` row for a
|
||||
topic Telegram has confirmed deleted.
|
||||
@@ -1699,6 +1703,12 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
on to a fresh topic). Best-effort: we never raise from a
|
||||
send-fallback path — a failed cleanup must not turn into a
|
||||
failed user-facing send.
|
||||
|
||||
Rows are namespaced by profile (#76423). Under
|
||||
``gateway.profile_routes`` the transport adapter may not be the
|
||||
profile that wrote the binding, so the send's ``hermes_profile``
|
||||
metadata wins over the adapter's own profile stamp; single-profile
|
||||
bots fall back to ``"default"``.
|
||||
"""
|
||||
if chat_id is None or thread_id is None:
|
||||
return
|
||||
@@ -1709,9 +1719,11 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
if db is None or not hasattr(db, "delete_telegram_topic_binding"):
|
||||
return
|
||||
try:
|
||||
# Prefer the profile stamped on this adapter under multiplex
|
||||
# (issue #76423). Fall back to "default" for single-profile bots.
|
||||
profile_name = getattr(self, "_hermes_profile_name", None) or "default"
|
||||
profile_name = (
|
||||
(metadata or {}).get("hermes_profile")
|
||||
or getattr(self, "_hermes_profile_name", None)
|
||||
or "default"
|
||||
)
|
||||
removed = db.delete_telegram_topic_binding(
|
||||
chat_id=str(chat_id),
|
||||
thread_id=str(thread_id),
|
||||
@@ -5595,7 +5607,9 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
self.name, effective_thread_id,
|
||||
)
|
||||
self._prune_stale_dm_topic_binding(
|
||||
chat_id, effective_thread_id,
|
||||
chat_id,
|
||||
effective_thread_id,
|
||||
metadata=metadata,
|
||||
)
|
||||
used_thread_fallback = True
|
||||
effective_thread_id = None
|
||||
@@ -6385,7 +6399,8 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
# Same prune as the streaming send path — the
|
||||
# control-message retry tells us the topic is gone,
|
||||
# so the binding row in state.db must go too
|
||||
# (#31501).
|
||||
# (#31501). Control sends carry no gateway metadata, so
|
||||
# the prune namespaces by this adapter's profile stamp.
|
||||
self._prune_stale_dm_topic_binding(
|
||||
kwargs.get("chat_id"), message_thread_id,
|
||||
)
|
||||
|
||||
@@ -52,3 +52,42 @@ def test_gateway_uses_source_profile_not_global(tmp_path: Path):
|
||||
chat_id=CHAT, thread_id="42", profile_name="default",
|
||||
) is None
|
||||
db.close()
|
||||
|
||||
|
||||
def test_routed_profile_flows_into_prune_via_send_metadata(tmp_path: Path):
|
||||
"""profile_routes: the transport adapter may be the primary (default) bot
|
||||
while the turn is routed to another profile — the outbound metadata built
|
||||
by the gateway carries the routed profile, and prune uses it over the
|
||||
adapter's own stamp (#76423)."""
|
||||
from gateway.run import GatewayRunner
|
||||
from plugins.platforms.telegram.adapter import TelegramAdapter
|
||||
|
||||
runner = object.__new__(GatewayRunner)
|
||||
runner._thread_metadata_for_target = lambda *a, **k: {"thread_id": "99"}
|
||||
meta = runner._thread_metadata_for_source(_source("coder", "99"))
|
||||
assert meta["hermes_profile"] == "coder"
|
||||
assert "hermes_profile" not in runner._thread_metadata_for_source(_source(None, "99"))
|
||||
|
||||
# Cooldowns are keyed (profile, chat): alpha's reminder must not gag beta.
|
||||
assert runner._should_send_telegram_lobby_reminder(_source("alpha")) is True
|
||||
assert runner._should_send_telegram_lobby_reminder(_source("beta")) is True
|
||||
assert runner._should_send_telegram_lobby_reminder(_source("alpha")) is False
|
||||
|
||||
db = SessionDB(db_path=tmp_path / "state.db")
|
||||
db.create_session(session_id="sess-default", source="telegram", user_id=CHAT)
|
||||
db.create_session(session_id="sess-coder", source="telegram", user_id=CHAT, profile_name="coder")
|
||||
for prof, sid in (("default", "sess-default"), ("coder", "sess-coder")):
|
||||
db.bind_telegram_topic(
|
||||
chat_id=CHAT, thread_id="99", user_id=CHAT,
|
||||
session_key=f"k-{prof}", session_id=sid, profile_name=prof,
|
||||
)
|
||||
|
||||
adapter = object.__new__(TelegramAdapter)
|
||||
adapter.platform = Platform.TELEGRAM
|
||||
adapter._session_store = SimpleNamespace(_db=db)
|
||||
adapter._hermes_profile_name = "default" # transport = primary bot
|
||||
adapter._prune_stale_dm_topic_binding(CHAT, "99", metadata=meta)
|
||||
|
||||
assert db.get_telegram_topic_binding(chat_id=CHAT, thread_id="99", profile_name="coder") is None
|
||||
assert db.get_telegram_topic_binding(chat_id=CHAT, thread_id="99", profile_name="default") is not None
|
||||
db.close()
|
||||
|
||||
@@ -848,29 +848,31 @@ Shows the current topic's binding: session title, session ID, and hints for `/ne
|
||||
|
||||
### Under the hood
|
||||
|
||||
- Activation persists to `telegram_dm_topic_mode(chat_id, user_id, enabled, ...)` in `state.db`
|
||||
- Each topic binding persists to `telegram_dm_topic_bindings(chat_id, thread_id, session_id, ...)` with `ON DELETE CASCADE` on `session_id` — pruning a session automatically clears its topic binding
|
||||
- The topic-mode SQLite migration is **opt-in**: it runs on the first `/topic` call, never on gateway startup. Until a user runs `/topic` in this profile, `state.db` is unchanged
|
||||
- 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
|
||||
- Activation persists to `telegram_dm_topic_mode(profile_name, chat_id, user_id, enabled, ...)` in `state.db`. Primary key is `(profile_name, chat_id)` so multiplexed / profile-routed bots sharing one `state.db` do not clobber each other when the same Telegram user DMs multiple bots (private `chat_id` is the user id and is identical across bots).
|
||||
- Each topic binding persists to `telegram_dm_topic_bindings(profile_name, chat_id, thread_id, session_id, ...)` with PK `(profile_name, chat_id, thread_id)` and `ON DELETE CASCADE` on `session_id` — pruning a session automatically clears its topic binding
|
||||
- The topic-mode SQLite migration is **opt-in**: it runs on the first `/topic` call, never on gateway startup. Until a user runs `/topic` in this profile, `state.db` is unchanged. Schema v3 adds `profile_name`; legacy rows migrate into the `default` namespace only
|
||||
- Each inbound DM message looks up its `(profile_name, chat_id, thread_id)` binding using the **routed** profile (`source.profile`, not the process-global active profile). 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
|
||||
- Root-lobby reminders are rate-limited to one message per 30 seconds per **(profile, chat)** — a user who forgets topic mode is on and types ten prompts in the root won't get ten replies, and two multiplexed profiles sharing a chat id do not suppress each other's reminders
|
||||
- BotFather setup screenshots are rate-limited to one send per 5 minutes per **(profile, chat)** — repeated `/topic` attempts while Threads Settings are still disabled won't re-upload the same image
|
||||
- `/bg <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
|
||||
|
||||
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.
|
||||
Send `/topic off` in the root DM. Hermes flips the row off for **this profile's** namespace, clears that profile's `(thread_id → session_id)` bindings for the chat, 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:
|
||||
If you need to clean up by hand (e.g. a bulk reset across many chats), scope rows by `profile_name` (use `default` for single-profile installs):
|
||||
|
||||
```bash
|
||||
sqlite3 ~/.hermes/state.db \
|
||||
"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>';"
|
||||
"UPDATE telegram_dm_topic_mode SET enabled = 0
|
||||
WHERE profile_name = 'default' AND chat_id = '<your_chat_id>';
|
||||
DELETE FROM telegram_dm_topic_bindings
|
||||
WHERE profile_name = 'default' AND chat_id = '<your_chat_id>';"
|
||||
```
|
||||
|
||||
### Downgrading Hermes
|
||||
|
||||
Reference in New Issue
Block a user