feat: add Telegram DM topic-mode sessions

This commit is contained in:
EmelyanenkoK 2026-05-02 18:04:57 +03:00 committed by Teknium
parent 0ce1b9fe20
commit d6615d8ec7
8 changed files with 1890 additions and 18 deletions

View file

@ -35,6 +35,7 @@ class TestSessionLifecycle:
assert session["model"] == "test-model"
assert session["ended_at"] is None
def test_get_nonexistent_session(self, db):
assert db.get_session("nonexistent") is None
@ -1421,6 +1422,242 @@ class TestSchemaInit:
columns = {row[1] for row in cursor.fetchall()}
assert "title" in columns
def test_topic_mode_schema_is_not_auto_migrated_on_open(self, tmp_path):
"""Opening an old DB should not add topic-mode columns until /topic opts in.
The gateway must remain rollback-safe: simply upgrading Hermes and starting
the old bot should not eagerly mutate the state DB for this feature.
"""
old_db = tmp_path / "old.db"
import sqlite3
conn = sqlite3.connect(old_db)
conn.executescript(
"""
CREATE TABLE schema_version (version INTEGER NOT NULL);
INSERT INTO schema_version VALUES (11);
CREATE TABLE sessions (
id TEXT PRIMARY KEY,
source TEXT NOT NULL,
user_id TEXT,
model TEXT,
model_config TEXT,
system_prompt TEXT,
parent_session_id TEXT,
started_at REAL NOT NULL,
ended_at REAL,
end_reason TEXT,
message_count INTEGER DEFAULT 0,
tool_call_count INTEGER DEFAULT 0,
input_tokens INTEGER DEFAULT 0,
output_tokens INTEGER DEFAULT 0,
cache_read_tokens INTEGER DEFAULT 0,
cache_write_tokens INTEGER DEFAULT 0,
reasoning_tokens INTEGER DEFAULT 0,
billing_provider TEXT,
billing_base_url TEXT,
billing_mode TEXT,
estimated_cost_usd REAL,
actual_cost_usd REAL,
cost_status TEXT,
cost_source TEXT,
pricing_version TEXT,
title TEXT,
api_call_count INTEGER DEFAULT 0,
FOREIGN KEY (parent_session_id) REFERENCES sessions(id)
);
CREATE TABLE messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL REFERENCES sessions(id),
role TEXT NOT NULL,
content TEXT,
tool_call_id TEXT,
tool_calls TEXT,
tool_name TEXT,
timestamp REAL NOT NULL,
token_count INTEGER,
finish_reason TEXT,
reasoning TEXT,
reasoning_content TEXT,
reasoning_details TEXT,
codex_reasoning_items TEXT,
codex_message_items TEXT
);
"""
)
conn.close()
db = SessionDB(db_path=old_db)
cursor = db._conn.execute("PRAGMA table_info(sessions)")
columns = {row[1] for row in cursor.fetchall()}
assert {"chat_id", "chat_type", "thread_id", "session_key"}.isdisjoint(columns)
db.close()
def test_apply_telegram_topic_migration_creates_topic_tables_explicitly(self, tmp_path):
"""The /topic opt-in path owns the DB migration for Telegram topic mode."""
old_db = tmp_path / "old.db"
import sqlite3
conn = sqlite3.connect(old_db)
conn.executescript(
"""
CREATE TABLE schema_version (version INTEGER NOT NULL);
INSERT INTO schema_version VALUES (11);
CREATE TABLE sessions (
id TEXT PRIMARY KEY,
source TEXT NOT NULL,
user_id TEXT,
model TEXT,
model_config TEXT,
system_prompt TEXT,
parent_session_id TEXT,
started_at REAL NOT NULL,
ended_at REAL,
end_reason TEXT,
message_count INTEGER DEFAULT 0,
tool_call_count INTEGER DEFAULT 0,
input_tokens INTEGER DEFAULT 0,
output_tokens INTEGER DEFAULT 0,
cache_read_tokens INTEGER DEFAULT 0,
cache_write_tokens INTEGER DEFAULT 0,
reasoning_tokens INTEGER DEFAULT 0,
billing_provider TEXT,
billing_base_url TEXT,
billing_mode TEXT,
estimated_cost_usd REAL,
actual_cost_usd REAL,
cost_status TEXT,
cost_source TEXT,
pricing_version TEXT,
title TEXT,
api_call_count INTEGER DEFAULT 0,
FOREIGN KEY (parent_session_id) REFERENCES sessions(id)
);
CREATE TABLE messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL REFERENCES sessions(id),
role TEXT NOT NULL,
content TEXT,
tool_call_id TEXT,
tool_calls TEXT,
tool_name TEXT,
timestamp REAL NOT NULL,
token_count INTEGER,
finish_reason TEXT,
reasoning TEXT,
reasoning_content TEXT,
reasoning_details TEXT,
codex_reasoning_items TEXT,
codex_message_items TEXT
);
"""
)
conn.close()
db = SessionDB(db_path=old_db)
db.apply_telegram_topic_migration()
tables = {
row[0]
for row in db._conn.execute(
"SELECT name FROM sqlite_master WHERE type = 'table'"
).fetchall()
}
assert "telegram_dm_topic_mode" in tables
assert "telegram_dm_topic_bindings" in tables
assert db.get_meta("telegram_dm_topic_schema_version") == "1"
db.close()
def test_telegram_topic_binding_roundtrip_requires_explicit_schema(self, tmp_path):
db = SessionDB(db_path=tmp_path / "state.db")
db.create_session(
session_id="topic-session",
source="telegram",
user_id="208214988",
)
assert db.get_telegram_topic_binding(chat_id="208214988", thread_id="17585") is None
db.bind_telegram_topic(
chat_id="208214988",
thread_id="17585",
user_id="208214988",
session_key="telegram:dm:208214988:thread:17585",
session_id="topic-session",
)
binding = db.get_telegram_topic_binding(chat_id="208214988", thread_id="17585")
assert binding is not None
assert binding["chat_id"] == "208214988"
assert binding["thread_id"] == "17585"
assert binding["user_id"] == "208214988"
assert binding["session_key"] == "telegram:dm:208214988:thread:17585"
assert binding["session_id"] == "topic-session"
assert db.get_meta("telegram_dm_topic_schema_version") == "1"
db.close()
def test_telegram_topic_binding_refuses_to_relink_session_to_another_topic(self, tmp_path):
db = SessionDB(db_path=tmp_path / "state.db")
db.create_session(
session_id="topic-session",
source="telegram",
user_id="208214988",
)
db.bind_telegram_topic(
chat_id="208214988",
thread_id="17585",
user_id="208214988",
session_key="key-17585",
session_id="topic-session",
)
with pytest.raises(ValueError, match="already linked"):
db.bind_telegram_topic(
chat_id="208214988",
thread_id="99999",
user_id="208214988",
session_key="key-99999",
session_id="topic-session",
)
db.close()
def test_list_unlinked_telegram_sessions_for_user_excludes_bound_and_other_users(self, tmp_path):
db = SessionDB(db_path=tmp_path / "state.db")
db.create_session(
session_id="old-unlinked",
source="telegram",
user_id="208214988",
)
db.set_session_title("old-unlinked", "Old research")
db.append_message("old-unlinked", "user", "first prompt")
db.create_session(
session_id="already-linked",
source="telegram",
user_id="208214988",
)
db.bind_telegram_topic(
chat_id="208214988",
thread_id="17585",
user_id="208214988",
session_key="key-17585",
session_id="already-linked",
)
db.create_session(
session_id="other-user",
source="telegram",
user_id="someone-else",
)
sessions = db.list_unlinked_telegram_sessions_for_user(
chat_id="208214988",
user_id="208214988",
)
assert [s["id"] for s in sessions] == ["old-unlinked"]
assert sessions[0]["title"] == "Old research"
assert sessions[0]["preview"] == "first prompt"
db.close()
def test_migration_from_v2(self, tmp_path):
"""Simulate a v2 database and verify migration adds title column."""
import sqlite3