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:
teknium1 2026-05-03 08:08:13 -07:00 committed by Teknium
parent 1a9542cf75
commit 1381c89e56
5 changed files with 291 additions and 21 deletions

View file

@ -461,7 +461,7 @@ async def test_topic_root_command_explicitly_migrates_and_enables_topic_mode(tmp
assert "Telegram multi-session topics are enabled" in result
assert "All Messages" in result
assert session_db.get_meta("telegram_dm_topic_schema_version") == "1"
assert session_db.get_meta("telegram_dm_topic_schema_version") == "2"
assert session_db.is_telegram_topic_mode_enabled(chat_id="208214988", user_id="208214988")
assert runner._telegram_topic_mode_enabled(_make_source()) is True
runner._run_agent.assert_not_called()
@ -825,3 +825,161 @@ async def test_auto_generated_title_does_not_rename_topic_bound_to_other_session
)
runner.adapters[Platform.TELEGRAM].rename_dm_topic.assert_not_called()
@pytest.mark.asyncio
async def test_operator_declared_topic_is_not_auto_renamed(tmp_path):
"""Topics registered in extra.dm_topics keep their operator-chosen name."""
db = SessionDB(db_path=tmp_path / "state.db")
db.enable_telegram_topic_mode(chat_id="208214988", user_id="208214988")
db.create_session(session_id="sess-topic", source="telegram", user_id="208214988")
db.bind_telegram_topic(
chat_id="208214988",
thread_id="17585",
user_id="208214988",
session_key=build_session_key(_make_source(thread_id="17585")),
session_id="sess-topic",
)
runner = _make_runner(session_db=db)
runner._telegram_topic_mode_enabled = lambda source: True
# Give the adapter a concrete class with _get_dm_topic_info so the
# class-based lookup in _rename_telegram_topic_for_session_title
# actually finds it (a MagicMock auto-attr would be skipped).
class _FakeAdapter:
def _get_dm_topic_info(self, chat_id, thread_id):
return {"name": "Research", "skill": "arxiv"}
async def rename_dm_topic(self, **kwargs):
return None
fake = _FakeAdapter()
fake.rename_dm_topic = AsyncMock()
runner.adapters[Platform.TELEGRAM] = fake
await runner._rename_telegram_topic_for_session_title(
_make_source(thread_id="17585"),
"sess-topic",
"Auto-generated title",
)
fake.rename_dm_topic.assert_not_called()
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")
db.enable_telegram_topic_mode(chat_id="208214988", user_id="208214988")
runner = _make_runner(session_db=db)
general_source = _make_source(thread_id="1")
assert runner._is_telegram_topic_root_lobby(general_source) is True
assert runner._is_telegram_topic_lane(general_source) is False
no_thread_source = _make_source(thread_id=None)
assert runner._is_telegram_topic_root_lobby(no_thread_source) is True
assert runner._is_telegram_topic_lane(no_thread_source) is False
real_topic = _make_source(thread_id="17585")
assert runner._is_telegram_topic_root_lobby(real_topic) is False
assert runner._is_telegram_topic_lane(real_topic) is True
def test_lobby_reminder_is_debounced_per_chat(tmp_path):
"""Consecutive root-DM prompts should only surface one lobby reminder per cooldown."""
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(thread_id=None)
assert runner._should_send_telegram_lobby_reminder(source) is True
# Next call inside the cooldown window must return False.
assert runner._should_send_telegram_lobby_reminder(source) is False
assert runner._should_send_telegram_lobby_reminder(source) is False
# A different chat gets its own window.
other = _make_source(thread_id=None)
# Swap chat_id so the debounce key is different.
from dataclasses import replace
other = replace(other, chat_id="999999999")
assert runner._should_send_telegram_lobby_reminder(other) is True
def test_binding_survives_session_deletion_via_cascade(tmp_path):
"""Deleting a session with a topic binding must not raise FK errors."""
import sqlite3
db = SessionDB(db_path=tmp_path / "state.db")
db.enable_telegram_topic_mode(chat_id="208214988", user_id="208214988")
db.create_session(session_id="sess-to-delete", source="telegram", user_id="208214988")
db.bind_telegram_topic(
chat_id="208214988",
thread_id="17585",
user_id="208214988",
session_key="agent:main:telegram:dm:208214988:17585",
session_id="sess-to-delete",
)
# Before: binding exists.
binding = db.get_telegram_topic_binding(chat_id="208214988", thread_id="17585")
assert binding is not None
# Delete the session. Without ON DELETE CASCADE this would raise
# sqlite3.IntegrityError: FOREIGN KEY constraint failed.
db._conn.execute("DELETE FROM sessions WHERE id = ?", ("sess-to-delete",))
db._conn.commit()
# After: binding row automatically cleared.
binding_after = db.get_telegram_topic_binding(chat_id="208214988", thread_id="17585")
assert binding_after is None
def test_migration_rebuilds_v1_binding_table_with_cascade_fk(tmp_path):
"""v1 → v2 migration rebuilds the bindings table when FK lacks ON DELETE CASCADE."""
import sqlite3
db_path = tmp_path / "state.db"
db = SessionDB(db_path=db_path)
# Simulate a v1-shaped DB: migration ran without ON DELETE CASCADE.
db.apply_telegram_topic_migration() # Creates v2 (our new shape)
# Drop the v2 bindings table and recreate it in the old v1 shape.
with db._lock:
db._conn.execute("DROP TABLE telegram_dm_topic_bindings")
db._conn.execute(
"""
CREATE TABLE telegram_dm_topic_bindings (
chat_id TEXT NOT NULL,
thread_id TEXT NOT NULL,
user_id TEXT NOT NULL,
session_key TEXT NOT NULL,
session_id TEXT NOT NULL REFERENCES sessions(id),
managed_mode TEXT NOT NULL DEFAULT 'auto',
linked_at REAL NOT NULL,
updated_at REAL NOT NULL,
PRIMARY KEY (chat_id, thread_id)
)
"""
)
# Also rewind the version marker so migration treats this as v1.
db._conn.execute(
"UPDATE state_meta SET value = '1' WHERE key = 'telegram_dm_topic_schema_version'"
)
db._conn.commit()
# Sanity check: FK has no CASCADE action yet.
fk_rows = db._conn.execute(
"PRAGMA foreign_key_list('telegram_dm_topic_bindings')"
).fetchall()
assert any(row[2] == "sessions" and (row[6] or "") != "CASCADE" for row in fk_rows)
# Re-run migration — should upgrade to v2 shape.
db.apply_telegram_topic_migration()
fk_rows_after = db._conn.execute(
"PRAGMA foreign_key_list('telegram_dm_topic_bindings')"
).fetchall()
assert any(row[2] == "sessions" and row[6] == "CASCADE" for row in fk_rows_after)
version = db._conn.execute(
"SELECT value FROM state_meta WHERE key = 'telegram_dm_topic_schema_version'"
).fetchone()
assert version is not None and version[0] == "2"