From 24c61468d7ae0fe5fbaf1be8a40b170d1ced4daf Mon Sep 17 00:00:00 2001 From: Mikhail Putilovskij Date: Thu, 2 Apr 2026 13:23:40 +0300 Subject: [PATCH] =?UTF-8?q?feat(tg):=20forum-first=20adapter=20complete=20?= =?UTF-8?q?=E2=80=94=20handlers,=20bot.py,=2046=20tests=20pass?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- adapter/telegram/bot.py | 76 +++++++++ adapter/telegram/handlers/commands.py | 74 +++++++++ adapter/telegram/handlers/message.py | 81 ++++++++++ adapter/telegram/handlers/settings.py | 171 ++++++++++++++++++++ adapter/telegram/handlers/start.py | 75 +++++++++ adapter/telegram/handlers/topic_events.py | 44 +++++ adapter/telegram/states.py | 8 + tests/adapter/telegram/test_commands.py | 76 +++++++++ tests/adapter/telegram/test_topic_events.py | 70 ++++++++ 9 files changed, 675 insertions(+) create mode 100644 adapter/telegram/bot.py create mode 100644 adapter/telegram/handlers/commands.py create mode 100644 adapter/telegram/handlers/message.py create mode 100644 adapter/telegram/handlers/settings.py create mode 100644 adapter/telegram/handlers/start.py create mode 100644 adapter/telegram/handlers/topic_events.py create mode 100644 adapter/telegram/states.py create mode 100644 tests/adapter/telegram/test_commands.py create mode 100644 tests/adapter/telegram/test_topic_events.py diff --git a/adapter/telegram/bot.py b/adapter/telegram/bot.py new file mode 100644 index 0000000..3303629 --- /dev/null +++ b/adapter/telegram/bot.py @@ -0,0 +1,76 @@ +from __future__ import annotations + +import asyncio +import os + +import structlog +from aiogram import Bot, Dispatcher +from aiogram.fsm.storage.memory import MemoryStorage +from aiogram.types import BotCommand + +from adapter.telegram import db +from adapter.telegram.handlers import commands, message, settings, start, topic_events +from core.auth import AuthManager +from core.chat import ChatManager +from core.handler import EventDispatcher +from core.settings import SettingsManager +from core.store import InMemoryStore +from sdk.mock import MockPlatformClient + +logger = structlog.get_logger(__name__) + + +class PlatformMiddleware: + def __init__(self, dispatcher: EventDispatcher) -> None: + self._dispatcher = dispatcher + + async def __call__(self, handler, event, data): + data["dispatcher"] = self._dispatcher + return await handler(event, data) + + +def build_event_dispatcher() -> EventDispatcher: + platform = MockPlatformClient() + store = InMemoryStore() + return EventDispatcher( + platform=platform, + chat_mgr=ChatManager(platform, store), + auth_mgr=AuthManager(platform, store), + settings_mgr=SettingsManager(platform, store), + ) + + +async def main() -> None: + token = os.environ.get("BOT_TOKEN") + if not token: + raise RuntimeError("BOT_TOKEN env variable is not set") + + db.init_db() + + bot = Bot(token=token) + dp = Dispatcher(storage=MemoryStorage()) + event_dispatcher = build_event_dispatcher() + + dp.message.middleware(PlatformMiddleware(event_dispatcher)) + dp.callback_query.middleware(PlatformMiddleware(event_dispatcher)) + + dp.include_router(topic_events.router) + dp.include_router(start.router) + dp.include_router(commands.router) + dp.include_router(settings.router) + dp.include_router(message.router) + + await bot.set_my_commands([ + BotCommand(command="start", description="Начать / восстановить сессию"), + BotCommand(command="new", description="Создать новый чат"), + BotCommand(command="archive", description="Архивировать текущий чат"), + BotCommand(command="rename", description="Переименовать текущий чат"), + BotCommand(command="settings", description="Настройки"), + ]) + + logger.info("bot_starting") + await dp.start_polling(bot, allowed_updates=["message", "callback_query"]) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/adapter/telegram/handlers/commands.py b/adapter/telegram/handlers/commands.py new file mode 100644 index 0000000..a18e2af --- /dev/null +++ b/adapter/telegram/handlers/commands.py @@ -0,0 +1,74 @@ +from __future__ import annotations + +import structlog +from aiogram import Router +from aiogram.filters import Command +from aiogram.types import Message + +from adapter.telegram import db +from adapter.telegram.keyboards.settings import settings_main_keyboard + +logger = structlog.get_logger(__name__) + +router = Router(name="commands") + + +@router.message(Command("new")) +async def cmd_new(message: Message) -> None: + """Create a new topic and register it as a new chat.""" + user_id = message.from_user.id + chat_id = message.chat.id + n = db.count_active_chats(user_id) + 1 + new_name = f"Чат #{n}" + topic = await message.bot.create_forum_topic(chat_id=chat_id, name=new_name) + thread_id = topic.message_thread_id + db.create_chat(user_id=user_id, thread_id=thread_id, chat_name=new_name) + await message.bot.send_message( + chat_id=chat_id, + message_thread_id=thread_id, + text=f"Создан {new_name}. Напиши что-нибудь.", + ) + logger.info("cmd_new", user_id=user_id, thread_id=thread_id, name=new_name) + + +@router.message(Command("archive")) +async def cmd_archive(message: Message) -> None: + """Archive the current topic.""" + user_id = message.from_user.id + thread_id = message.message_thread_id + chat = db.get_chat(user_id=user_id, thread_id=thread_id) + if chat is None or chat["archived_at"] is not None: + await message.answer("Этот чат не найден или уже архивирован.") + return + await message.bot.close_forum_topic(chat_id=message.chat.id, message_thread_id=thread_id) + db.archive_chat(user_id=user_id, thread_id=thread_id) + logger.info("cmd_archive", user_id=user_id, thread_id=thread_id) + + +@router.message(Command("rename")) +async def cmd_rename(message: Message) -> None: + """Rename the current topic. Usage: /rename New Name""" + user_id = message.from_user.id + thread_id = message.message_thread_id + parts = (message.text or "").split(maxsplit=1) + new_name = parts[1].strip() if len(parts) > 1 else "" + if not new_name: + await message.answer("Использование: /rename Новое название") + return + chat = db.get_chat(user_id=user_id, thread_id=thread_id) + if chat is None: + await message.answer("Этот чат не найден.") + return + await message.bot.edit_forum_topic( + chat_id=message.chat.id, + message_thread_id=thread_id, + name=new_name[:128], + ) + db.rename_chat(user_id=user_id, thread_id=thread_id, new_name=new_name[:128]) + logger.info("cmd_rename", user_id=user_id, thread_id=thread_id, new_name=new_name) + + +@router.message(Command("settings")) +async def cmd_settings(message: Message) -> None: + """Open settings menu.""" + await message.answer("⚙️ Настройки", reply_markup=settings_main_keyboard()) diff --git a/adapter/telegram/handlers/message.py b/adapter/telegram/handlers/message.py new file mode 100644 index 0000000..3693042 --- /dev/null +++ b/adapter/telegram/handlers/message.py @@ -0,0 +1,81 @@ +from __future__ import annotations + +import time + +import structlog +from aiogram import F, Router +from aiogram.exceptions import TelegramBadRequest +from aiogram.types import Message + +from adapter.telegram import converter, db +from core.handler import EventDispatcher + +logger = structlog.get_logger(__name__) + +router = Router(name="message") + +STREAM_EDIT_INTERVAL = 1.5 +STREAM_MIN_DELTA = 100 +TELEGRAM_MAX_LEN = 4096 + + +@router.message(F.text & F.message_thread_id) +async def handle_topic_message(message: Message, dispatcher: EventDispatcher) -> None: + """Route a text message in a topic to the platform and stream the response.""" + user_id = message.from_user.id + thread_id = message.message_thread_id + + chat = db.get_chat(user_id=user_id, thread_id=thread_id) + if chat is None or chat["archived_at"] is not None: + return + + incoming = converter.from_message(message) + if incoming is None: + return + + platform_user = await dispatcher._platform.get_or_create_user( + external_id=str(user_id), + platform="telegram", + display_name=message.from_user.full_name, + ) + + placeholder = await message.reply("...") + + accumulated = "" + last_edit_time = 0.0 + last_edit_len = 0 + + try: + async for chunk in dispatcher._platform.stream_message( + user_id=platform_user.user_id, + chat_id=str(thread_id), + text=incoming.text, + attachments=None, + ): + accumulated += chunk.delta + now = time.monotonic() + delta = len(accumulated) - last_edit_len + if delta >= STREAM_MIN_DELTA and (now - last_edit_time) >= STREAM_EDIT_INTERVAL: + await _safe_edit(placeholder, accumulated) + last_edit_time = now + last_edit_len = len(accumulated) + + await _safe_edit(placeholder, accumulated or "...") + + except TelegramBadRequest as e: + if "thread not found" in str(e).lower(): + db.archive_chat(user_id=user_id, thread_id=thread_id) + logger.warning("topic_deleted_during_message", thread_id=thread_id) + else: + logger.error("telegram_error", error=str(e)) + except Exception: + logger.exception("platform_error", user_id=user_id, thread_id=thread_id) + await _safe_edit(placeholder, "Сервис временно недоступен, попробуй позже") + + +async def _safe_edit(message: Message, text: str) -> None: + try: + await message.edit_text(text[:TELEGRAM_MAX_LEN]) + except TelegramBadRequest as e: + if "not modified" not in str(e).lower(): + raise diff --git a/adapter/telegram/handlers/settings.py b/adapter/telegram/handlers/settings.py new file mode 100644 index 0000000..afab801 --- /dev/null +++ b/adapter/telegram/handlers/settings.py @@ -0,0 +1,171 @@ +# adapter/telegram/handlers/settings.py +from __future__ import annotations + +from aiogram import F, Router +from aiogram.filters import Command +from aiogram.fsm.context import FSMContext +from aiogram.types import CallbackQuery, Message + +from adapter.telegram.keyboards.settings import ( + back_keyboard, + safety_keyboard, + settings_main_keyboard, + skills_keyboard, +) +from adapter.telegram.states import SettingsState +from core.handler import EventDispatcher +from core.protocol import SettingsAction + +router = Router(name="settings") + + +@router.message(Command("settings")) +async def cmd_settings(message: Message, state: FSMContext) -> None: + await state.set_state(SettingsState.menu) + await message.answer("⚙️ Настройки", reply_markup=settings_main_keyboard()) + + +@router.callback_query(F.data == "settings:back") +async def cb_settings_back(callback: CallbackQuery, state: FSMContext) -> None: + await state.set_state(SettingsState.menu) + await callback.message.edit_text("⚙️ Настройки", reply_markup=settings_main_keyboard()) + await callback.answer() + + +@router.callback_query(F.data == "settings:skills") +async def cb_skills(callback: CallbackQuery, state: FSMContext, dispatcher: EventDispatcher) -> None: + data = await state.get_data() + active_chat_id = data.get("active_chat_id", "") + # Get platform user id + platform_user_id = str(callback.from_user.id) + + settings = await dispatcher._platform.get_settings(platform_user_id) + await callback.message.edit_text( + "🧩 Скиллы\nНажмите для переключения:", + reply_markup=skills_keyboard(settings.skills), + ) + await callback.answer() + + +@router.callback_query(F.data.startswith("toggle_skill:")) +async def cb_toggle_skill( + callback: CallbackQuery, + state: FSMContext, + dispatcher: EventDispatcher, +) -> None: + skill = callback.data.split(":", 1)[1] + platform_user_id = str(callback.from_user.id) + + settings = await dispatcher._platform.get_settings(platform_user_id) + current = settings.skills.get(skill, False) + action = SettingsAction( + action="toggle_skill", + payload={"skill": skill, "enabled": not current}, + ) + await dispatcher._platform.update_settings(platform_user_id, action) + + settings = await dispatcher._platform.get_settings(platform_user_id) + await callback.message.edit_reply_markup(reply_markup=skills_keyboard(settings.skills)) + await callback.answer(f"{'Включён' if not current else 'Выключен'}: {skill}") + + +@router.callback_query(F.data == "settings:safety") +async def cb_safety(callback: CallbackQuery, state: FSMContext, dispatcher: EventDispatcher) -> None: + platform_user_id = str(callback.from_user.id) + + settings = await dispatcher._platform.get_settings(platform_user_id) + await callback.message.edit_text( + "🔒 Безопасность\nПодтверждение перед выполнением:", + reply_markup=safety_keyboard(settings.safety), + ) + await callback.answer() + + +@router.callback_query(F.data.startswith("toggle_safety:")) +async def cb_toggle_safety( + callback: CallbackQuery, + state: FSMContext, + dispatcher: EventDispatcher, +) -> None: + trigger = callback.data.split(":", 1)[1] + platform_user_id = str(callback.from_user.id) + + settings = await dispatcher._platform.get_settings(platform_user_id) + current = settings.safety.get(trigger, False) + action = SettingsAction( + action="set_safety", + payload={"trigger": trigger, "enabled": not current}, + ) + await dispatcher._platform.update_settings(platform_user_id, action) + + settings = await dispatcher._platform.get_settings(platform_user_id) + await callback.message.edit_reply_markup(reply_markup=safety_keyboard(settings.safety)) + await callback.answer() + + +@router.callback_query(F.data == "settings:soul") +async def cb_soul_menu(callback: CallbackQuery, state: FSMContext) -> None: + await state.set_state(SettingsState.soul_editing) + await state.update_data(soul_field=None) + await callback.message.edit_text( + "🧠 Личность агента\n\nЧто хотите изменить?\n\n" + "Отправьте: name: <имя агента>\n" + "Или: instructions: <инструкции>\n\n" + "Или нажмите Назад.", + reply_markup=back_keyboard(), + ) + await callback.answer() + + +@router.message(SettingsState.soul_editing) +async def handle_soul_input( + message: Message, + state: FSMContext, + dispatcher: EventDispatcher, +) -> None: + text = message.text or "" + platform_user_id = str(message.from_user.id) + + if ":" in text: + field, _, value = text.partition(":") + field = field.strip().lower() + value = value.strip() + if field in ("name", "instructions"): + action = SettingsAction( + action="set_soul", + payload={"field": field, "value": value}, + ) + await dispatcher._platform.update_settings(platform_user_id, action) + await message.answer(f"✅ {field} обновлено.") + await state.set_state(SettingsState.menu) + await message.answer("⚙️ Настройки", reply_markup=settings_main_keyboard()) + return + + await message.answer( + "Формат: name: <имя> или instructions: <инструкции>\n" + "Пример: name: Алекс" + ) + + +@router.callback_query(F.data == "settings:connectors") +async def cb_connectors(callback: CallbackQuery) -> None: + await callback.message.edit_text( + "🔗 Коннекторы\n\nОAuth-интеграции — скоро.", + reply_markup=back_keyboard(), + ) + await callback.answer() + + +@router.callback_query(F.data == "settings:plan") +async def cb_plan(callback: CallbackQuery, dispatcher: EventDispatcher) -> None: + platform_user_id = str(callback.from_user.id) + + settings = await dispatcher._platform.get_settings(platform_user_id) + plan = settings.plan + text = ( + f"💳 Подписка\n\n" + f"Тариф: {plan.get('name', '?')}\n" + f"Токены: {plan.get('tokens_used', 0)} / {plan.get('tokens_limit', 0)}" + ) + await callback.message.edit_text(text, reply_markup=back_keyboard()) + await callback.answer() diff --git a/adapter/telegram/handlers/start.py b/adapter/telegram/handlers/start.py new file mode 100644 index 0000000..a33dd04 --- /dev/null +++ b/adapter/telegram/handlers/start.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +import structlog +from aiogram import Router +from aiogram.exceptions import TelegramBadRequest +from aiogram.filters import CommandStart +from aiogram.types import Message + +from adapter.telegram import db + +logger = structlog.get_logger(__name__) + +router = Router(name="start") + + +@router.message(CommandStart()) +async def cmd_start(message: Message) -> None: + """ + Bootstrap the user's forum. + + First visit: create Чат #1, hide General topic. + Returning visit: health-check all active topics, archive stale ones. + """ + user_id = message.from_user.id + chat_id = message.chat.id + + await _check_and_prune_stale_topics(message, user_id, chat_id) + + active = db.get_active_chats(user_id) + + if not active: + try: + topic = await message.bot.create_forum_topic(chat_id=chat_id, name="Чат #1") + thread_id = topic.message_thread_id + db.create_chat(user_id=user_id, thread_id=thread_id, chat_name="Чат #1") + logger.info("start_created_first_topic", user_id=user_id, thread_id=thread_id) + except TelegramBadRequest as e: + logger.warning("start_create_topic_failed", error=str(e)) + await message.answer( + "Не удалось создать топик. Убедись, что в @BotFather включён " + "Threaded Mode для этого бота." + ) + return + + try: + await message.bot.hide_general_forum_topic(chat_id=chat_id) + except TelegramBadRequest: + pass # Not critical + + await message.answer( + "Привет! Это твоё личное пространство с AI-агентом Lambda. " + "Каждый топик — отдельный контекст. Напиши что-нибудь." + ) + else: + await message.answer( + f"Снова привет! У тебя {len(active)} активных чатов. " + "Напиши /new чтобы создать новый." + ) + + +async def _check_and_prune_stale_topics( + message: Message, user_id: int, chat_id: int +) -> None: + """Send typing action to each active topic; archive any that no longer exist.""" + for chat in db.get_active_chats(user_id): + thread_id = chat["thread_id"] + try: + await message.bot.send_chat_action( + chat_id=chat_id, + action="typing", + message_thread_id=thread_id, + ) + except TelegramBadRequest: + db.archive_chat(user_id=user_id, thread_id=thread_id) + logger.info("pruned_stale_topic", user_id=user_id, thread_id=thread_id) diff --git a/adapter/telegram/handlers/topic_events.py b/adapter/telegram/handlers/topic_events.py new file mode 100644 index 0000000..4f899d1 --- /dev/null +++ b/adapter/telegram/handlers/topic_events.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +import structlog +from aiogram import F, Router +from aiogram.types import Message + +from adapter.telegram import db + +logger = structlog.get_logger(__name__) + +router = Router(name="topic_events") + + +@router.message(F.forum_topic_created) +async def on_topic_created(message: Message) -> None: + """User created a topic via Telegram UI — register it as a new chat.""" + user_id = message.from_user.id + thread_id = message.message_thread_id + name = message.forum_topic_created.name + db.create_chat(user_id=user_id, thread_id=thread_id, chat_name=name) + logger.info("topic_created", user_id=user_id, thread_id=thread_id, name=name) + + +@router.message(F.forum_topic_edited) +async def on_topic_edited(message: Message) -> None: + """User renamed a topic via Telegram UI — sync chat_name in DB.""" + user_id = message.from_user.id + thread_id = message.message_thread_id + new_name = message.forum_topic_edited.name + if db.get_chat(user_id=user_id, thread_id=thread_id) is None: + return + db.rename_chat(user_id=user_id, thread_id=thread_id, new_name=new_name) + logger.info("topic_renamed", user_id=user_id, thread_id=thread_id, new_name=new_name) + + +@router.message(F.forum_topic_closed) +async def on_topic_closed(message: Message) -> None: + """User closed a topic via Telegram UI — auto-archive the chat.""" + user_id = message.from_user.id + thread_id = message.message_thread_id + if db.get_chat(user_id=user_id, thread_id=thread_id) is None: + return + db.archive_chat(user_id=user_id, thread_id=thread_id) + logger.info("topic_closed_archived", user_id=user_id, thread_id=thread_id) diff --git a/adapter/telegram/states.py b/adapter/telegram/states.py new file mode 100644 index 0000000..3326127 --- /dev/null +++ b/adapter/telegram/states.py @@ -0,0 +1,8 @@ +from __future__ import annotations + +from aiogram.fsm.state import State, StatesGroup + + +class SettingsState(StatesGroup): + menu = State() + soul_editing = State() diff --git a/tests/adapter/telegram/test_commands.py b/tests/adapter/telegram/test_commands.py new file mode 100644 index 0000000..1d8d1b9 --- /dev/null +++ b/tests/adapter/telegram/test_commands.py @@ -0,0 +1,76 @@ +from __future__ import annotations + +import importlib +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock + +import pytest + + +@pytest.fixture(autouse=True) +def fresh_db(tmp_path, monkeypatch): + monkeypatch.setenv("DB_PATH", str(tmp_path / "test.db")) + import adapter.telegram.db as db_mod + importlib.reload(db_mod) + db_mod.init_db() + return db_mod + + +def make_message(*, user_id=1, thread_id=42, chat_id=100, text="/new"): + m = SimpleNamespace() + m.from_user = SimpleNamespace(id=user_id, full_name="Alice") + m.message_thread_id = thread_id + m.chat = SimpleNamespace(id=chat_id) + m.text = text + m.answer = AsyncMock() + m.reply = AsyncMock() + m.bot = MagicMock() + m.bot.create_forum_topic = AsyncMock( + return_value=SimpleNamespace(message_thread_id=200) + ) + m.bot.close_forum_topic = AsyncMock() + m.bot.edit_forum_topic = AsyncMock() + m.bot.send_message = AsyncMock() + return m + + +async def test_cmd_new_creates_topic(fresh_db, monkeypatch): + import adapter.telegram.handlers.commands as mod + importlib.reload(mod) + fresh_db.create_chat(1, 42, "Чат #1") + msg = make_message(user_id=1, thread_id=42, chat_id=100) + await mod.cmd_new(msg) + msg.bot.create_forum_topic.assert_called_once() + call_kwargs = str(msg.bot.create_forum_topic.call_args) + assert "Чат #2" in call_kwargs + assert fresh_db.get_chat(1, 200) is not None + + +async def test_cmd_archive_closes_and_archives(fresh_db, monkeypatch): + import adapter.telegram.handlers.commands as mod + importlib.reload(mod) + fresh_db.create_chat(1, 42, "Чат #1") + msg = make_message(user_id=1, thread_id=42, chat_id=100) + await mod.cmd_archive(msg) + msg.bot.close_forum_topic.assert_called_once_with(chat_id=100, message_thread_id=42) + assert fresh_db.get_chat(1, 42)["archived_at"] is not None + + +async def test_cmd_archive_unknown_topic_replies_error(fresh_db, monkeypatch): + import adapter.telegram.handlers.commands as mod + importlib.reload(mod) + msg = make_message(user_id=1, thread_id=999, chat_id=100) + await mod.cmd_archive(msg) + msg.answer.assert_called_once() + + +async def test_cmd_rename_updates_db_and_topic(fresh_db, monkeypatch): + import adapter.telegram.handlers.commands as mod + importlib.reload(mod) + fresh_db.create_chat(1, 42, "Чат #1") + msg = make_message(user_id=1, thread_id=42, chat_id=100, text="/rename Работа") + await mod.cmd_rename(msg) + msg.bot.edit_forum_topic.assert_called_once_with( + chat_id=100, message_thread_id=42, name="Работа" + ) + assert fresh_db.get_chat(1, 42)["chat_name"] == "Работа" diff --git a/tests/adapter/telegram/test_topic_events.py b/tests/adapter/telegram/test_topic_events.py new file mode 100644 index 0000000..3de9a78 --- /dev/null +++ b/tests/adapter/telegram/test_topic_events.py @@ -0,0 +1,70 @@ +from __future__ import annotations + +import importlib +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + + +@pytest.fixture(autouse=True) +def fresh_db(tmp_path, monkeypatch): + monkeypatch.setenv("DB_PATH", str(tmp_path / "test.db")) + import adapter.telegram.db as db_mod + importlib.reload(db_mod) + db_mod.init_db() + return db_mod + + +def make_service_message(*, user_id=1, thread_id=42, topic_name="Мой чат"): + m = SimpleNamespace() + m.message_thread_id = thread_id + m.from_user = SimpleNamespace(id=user_id, full_name="Alice") + m.chat = SimpleNamespace(id=user_id) + m.forum_topic_created = SimpleNamespace(name=topic_name) + m.forum_topic_edited = SimpleNamespace(name="Новое имя") + m.forum_topic_closed = SimpleNamespace() + m.answer = AsyncMock() + return m + + +async def test_on_topic_created_registers_chat(fresh_db, monkeypatch): + import adapter.telegram.handlers.topic_events as mod + importlib.reload(mod) + msg = make_service_message(user_id=5, thread_id=99, topic_name="Мой чат") + await mod.on_topic_created(msg) + chat = fresh_db.get_chat(5, 99) + assert chat is not None + assert chat["chat_name"] == "Мой чат" + + +async def test_on_topic_edited_renames_chat(fresh_db, monkeypatch): + import adapter.telegram.handlers.topic_events as mod + importlib.reload(mod) + fresh_db.create_chat(5, 99, "Старое имя") + msg = make_service_message(user_id=5, thread_id=99) + await mod.on_topic_edited(msg) + assert fresh_db.get_chat(5, 99)["chat_name"] == "Новое имя" + + +async def test_on_topic_edited_unknown_chat_is_noop(fresh_db): + import adapter.telegram.handlers.topic_events as mod + importlib.reload(mod) + msg = make_service_message(user_id=5, thread_id=999) + await mod.on_topic_edited(msg) # should not raise + + +async def test_on_topic_closed_archives_chat(fresh_db): + import adapter.telegram.handlers.topic_events as mod + importlib.reload(mod) + fresh_db.create_chat(5, 99, "Чат #1") + msg = make_service_message(user_id=5, thread_id=99) + await mod.on_topic_closed(msg) + assert fresh_db.get_chat(5, 99)["archived_at"] is not None + + +async def test_on_topic_closed_unknown_chat_is_noop(fresh_db): + import adapter.telegram.handlers.topic_events as mod + importlib.reload(mod) + msg = make_service_message(user_id=5, thread_id=999) + await mod.on_topic_closed(msg) # should not raise