From a1b7a14138424f209104fd4c8482e0adfb37da67 Mon Sep 17 00:00:00 2001 From: Mikhail Putilovskij Date: Wed, 1 Apr 2026 01:49:45 +0300 Subject: [PATCH] Improve Telegram forum onboarding and topic safety --- adapter/telegram/bot.py | 5 +- adapter/telegram/converter.py | 26 +- adapter/telegram/db.py | 58 ++- adapter/telegram/handlers/chat.py | 200 +++++++- adapter/telegram/handlers/confirm.py | 46 +- adapter/telegram/handlers/forum.py | 212 +++++++++ adapter/telegram/keyboards/forum.py | 22 + adapter/telegram/states.py | 4 + .../2026-03-31-telegram-adapter-design.md | 446 ++++++------------ docs/telegram-prototype.md | 82 ++-- tests/adapter/__init__.py | 1 + tests/adapter/telegram/__init__.py | 1 + tests/adapter/telegram/test_forum.py | 374 +++++++++++++++ 13 files changed, 1101 insertions(+), 376 deletions(-) create mode 100644 adapter/telegram/handlers/forum.py create mode 100644 adapter/telegram/keyboards/forum.py create mode 100644 tests/adapter/__init__.py create mode 100644 tests/adapter/telegram/__init__.py create mode 100644 tests/adapter/telegram/test_forum.py diff --git a/adapter/telegram/bot.py b/adapter/telegram/bot.py index a5b61eb..fef12e4 100644 --- a/adapter/telegram/bot.py +++ b/adapter/telegram/bot.py @@ -10,7 +10,7 @@ from aiogram.fsm.storage.memory import MemoryStorage from aiogram.types import BotCommand from adapter.telegram import db -from adapter.telegram.handlers import auth, chat, confirm, settings +from adapter.telegram.handlers import auth, chat, confirm, forum, settings from core.auth import AuthManager from core.chat import ChatManager from core.handler import EventDispatcher @@ -54,7 +54,7 @@ def build_event_dispatcher(platform: MockPlatformClient) -> EventDispatcher: ) # Register core handlers - from core.protocol import IncomingCommand, IncomingMessage, IncomingCallback + from core.protocol import IncomingCallback, IncomingCommand, IncomingMessage ed.register(IncomingCommand, "start", handle_start) ed.register(IncomingCommand, "settings", handle_settings) ed.register(IncomingCommand, "settings_skills", handle_settings_skills) @@ -89,6 +89,7 @@ async def main() -> None: # Include routers dp.include_router(auth.router) + dp.include_router(forum.router) dp.include_router(chat.router) dp.include_router(settings.router) dp.include_router(confirm.router) diff --git a/adapter/telegram/converter.py b/adapter/telegram/converter.py index 976d63d..640ba67 100644 --- a/adapter/telegram/converter.py +++ b/adapter/telegram/converter.py @@ -3,6 +3,7 @@ from __future__ import annotations from aiogram.types import Message +from adapter.telegram import db from core.protocol import Attachment, IncomingMessage, OutgoingEvent, OutgoingMessage, OutgoingUI @@ -16,6 +17,21 @@ def from_message(message: Message, chat_id: str) -> IncomingMessage: ) +def is_forum_message(message: Message) -> bool: + return getattr(message, "message_thread_id", None) is not None + + +def resolve_forum_chat_id(message: Message, tg_user_id: int) -> str | None: + thread_id = getattr(message, "message_thread_id", None) + if thread_id is None: + return None + + chat = db.get_chat_by_thread(tg_user_id, thread_id) + if not chat: + return None + return chat["chat_id"] + + def _extract_attachments(message: Message) -> list[Attachment]: attachments: list[Attachment] = [] if message.photo: @@ -41,10 +57,10 @@ def _extract_attachments(message: Message) -> list[Attachment]: return attachments -def format_outgoing(chat_name: str, event: OutgoingEvent) -> str: - prefix = f"[{chat_name}] " +def format_outgoing(chat_name: str, event: OutgoingEvent, *, prefix: bool = True) -> str: + rendered_prefix = f"[{chat_name}] " if prefix else "" if isinstance(event, OutgoingMessage): - return prefix + event.text + return rendered_prefix + event.text if isinstance(event, OutgoingUI): - return prefix + event.text - return prefix + str(event) + return rendered_prefix + event.text + return rendered_prefix + str(event) diff --git a/adapter/telegram/db.py b/adapter/telegram/db.py index 2d39729..697dd8d 100644 --- a/adapter/telegram/db.py +++ b/adapter/telegram/db.py @@ -27,18 +27,29 @@ def init_db() -> None: tg_user_id INTEGER PRIMARY KEY, platform_user_id TEXT NOT NULL, display_name TEXT, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + forum_group_id INTEGER ); CREATE TABLE IF NOT EXISTS chats ( - chat_id TEXT PRIMARY KEY, - tg_user_id INTEGER NOT NULL, - name TEXT NOT NULL, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - archived_at TIMESTAMP, + chat_id TEXT PRIMARY KEY, + tg_user_id INTEGER NOT NULL, + name TEXT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + archived_at TIMESTAMP, + forum_thread_id INTEGER, FOREIGN KEY(tg_user_id) REFERENCES tg_users(tg_user_id) ); """) + # Миграция для существующих БД + try: + con.execute("ALTER TABLE tg_users ADD COLUMN forum_group_id INTEGER") + except Exception: + pass + try: + con.execute("ALTER TABLE chats ADD COLUMN forum_thread_id INTEGER") + except Exception: + pass def get_or_create_tg_user( @@ -119,3 +130,38 @@ def archive_chat(chat_id: str) -> None: "UPDATE chats SET archived_at = CURRENT_TIMESTAMP WHERE chat_id = ?", (chat_id,), ) + + +def set_forum_group(tg_user_id: int, group_id: int) -> None: + with _conn() as con: + con.execute( + "UPDATE tg_users SET forum_group_id = ? WHERE tg_user_id = ?", + (group_id, tg_user_id), + ) + + +def get_forum_group(tg_user_id: int) -> int | None: + with _conn() as con: + row = con.execute( + "SELECT forum_group_id FROM tg_users WHERE tg_user_id = ?", + (tg_user_id,), + ).fetchone() + return row["forum_group_id"] if row else None + + +def set_forum_thread(chat_id: str, thread_id: int) -> None: + with _conn() as con: + con.execute( + "UPDATE chats SET forum_thread_id = ? WHERE chat_id = ?", + (thread_id, chat_id), + ) + + +def get_chat_by_thread(tg_user_id: int, thread_id: int) -> dict | None: + with _conn() as con: + row = con.execute( + "SELECT * FROM chats WHERE tg_user_id = ? AND forum_thread_id = ? " + "AND archived_at IS NULL", + (tg_user_id, thread_id), + ).fetchone() + return dict(row) if row else None diff --git a/adapter/telegram/handlers/chat.py b/adapter/telegram/handlers/chat.py index 4565b4d..2128043 100644 --- a/adapter/telegram/handlers/chat.py +++ b/adapter/telegram/handlers/chat.py @@ -3,31 +3,87 @@ from __future__ import annotations import asyncio +import structlog from aiogram import F, Router -from aiogram.filters import Command, CommandObject +from aiogram.filters import Command from aiogram.fsm.context import FSMContext from aiogram.types import CallbackQuery, Message from adapter.telegram import db -from adapter.telegram.converter import format_outgoing, from_message +from adapter.telegram.converter import ( + format_outgoing, + from_message, + is_forum_message, + resolve_forum_chat_id, +) from adapter.telegram.keyboards.chat import chats_list_keyboard +from adapter.telegram.keyboards.confirm import confirm_keyboard from adapter.telegram.states import ChatState from core.handler import EventDispatcher from core.protocol import OutgoingMessage, OutgoingUI -from adapter.telegram.keyboards.confirm import confirm_keyboard + +logger = structlog.get_logger(__name__) router = Router(name="chat") -async def _send_outgoing(message: Message, chat_name: str, events: list) -> None: +def _thread_id(message: Message) -> int | None: + return getattr(message, "message_thread_id", None) + + +def _callback_thread_id(callback: CallbackQuery) -> int | None: + if callback.message is None: + return None + return getattr(callback.message, "message_thread_id", None) + + +async def _send_reply( + message: Message, + text: str, + *, + reply_markup=None, + thread_id: int | None = None, +) -> None: + if thread_id is None: + await message.answer(text, reply_markup=reply_markup) + return + + await message.bot.send_message( + message.chat.id, + text, + reply_markup=reply_markup, + message_thread_id=thread_id, + ) + + +async def _send_outgoing( + message: Message, + chat_name: str, + events: list, + *, + forum_mode: bool, + thread_id: int | None = None, +) -> None: for event in events: if isinstance(event, OutgoingUI): - from adapter.telegram.keyboards.confirm import confirm_keyboard - action_id = event.buttons[0].payload.get("action_id", "unknown") if event.buttons else "unknown" + action_id = ( + event.buttons[0].payload.get("action_id", "unknown") + if event.buttons + else "unknown" + ) kb = confirm_keyboard(action_id) - await message.answer(format_outgoing(chat_name, event), reply_markup=kb) + await _send_reply( + message, + format_outgoing(chat_name, event, prefix=not forum_mode), + reply_markup=kb, + thread_id=thread_id, + ) elif isinstance(event, OutgoingMessage): - await message.answer(format_outgoing(chat_name, event)) + await _send_reply( + message, + format_outgoing(chat_name, event, prefix=not forum_mode), + thread_id=thread_id, + ) @router.message(ChatState.idle, (F.text | F.photo | F.document | F.voice) & ~F.text.startswith("/")) @@ -37,8 +93,28 @@ async def handle_message( dispatcher: EventDispatcher, ) -> None: data = await state.get_data() - chat_id = data.get("active_chat_id") - chat_name = data.get("active_chat_name", "Чат") + tg_id = message.from_user.id + tg_user = db.get_or_create_tg_user(tg_id, str(tg_id), message.from_user.full_name) + platform_user_id = tg_user.get("platform_user_id", str(tg_id)) + forum_mode = is_forum_message(message) + thread_id = _thread_id(message) if forum_mode else None + + if forum_mode: + chat_id = resolve_forum_chat_id(message, tg_id) + if not chat_id: + await _send_reply( + message, + "Эта форум-тема ещё не зарегистрирована. Выполните /new в этой теме.", + thread_id=thread_id, + ) + return + + chat = db.get_chat_by_id(chat_id) + chat_name = chat["name"] if chat else data.get("active_chat_name", "Чат") + await state.update_data(active_chat_id=chat_id, active_chat_name=chat_name) + else: + chat_id = data.get("active_chat_id") + chat_name = data.get("active_chat_name", "Чат") if not chat_id: await message.answer("Нет активного чата. Введите /start") @@ -46,18 +122,21 @@ async def handle_message( await state.set_state(ChatState.waiting_response) - # Typing indicator loop - async def _typing_loop(): + async def _typing_loop() -> None: while True: - await message.bot.send_chat_action(message.chat.id, "typing") + if thread_id is None: + await message.bot.send_chat_action(message.chat.id, "typing") + else: + await message.bot.send_chat_action( + message.chat.id, + "typing", + message_thread_id=thread_id, + ) await asyncio.sleep(4) task = asyncio.create_task(_typing_loop()) + events: list = [] try: - tg_id = message.from_user.id - tg_user = db.get_or_create_tg_user(tg_id, str(tg_id), message.from_user.full_name) - platform_user_id = tg_user.get("platform_user_id", str(tg_id)) - incoming = from_message(message, chat_id) incoming.user_id = platform_user_id events = await dispatcher.dispatch(incoming) @@ -69,7 +148,13 @@ async def handle_message( pass await state.set_state(ChatState.idle) - await _send_outgoing(message, chat_name, events) + await _send_outgoing( + message, + chat_name, + events, + forum_mode=forum_mode, + thread_id=thread_id, + ) @router.message(Command("new")) @@ -77,18 +162,79 @@ async def cmd_new_chat(message: Message, state: FSMContext) -> None: tg_id = message.from_user.id args = message.text.split(maxsplit=1) name = args[1].strip() if len(args) > 1 else None + thread_id = _thread_id(message) + + if thread_id is not None: + chat = db.get_chat_by_thread(tg_id, thread_id) + if chat: + chat_id = chat["chat_id"] + chat_name = name or chat["name"] + if name and name != chat["name"]: + db.rename_chat(chat_id, name) + await state.update_data(active_chat_id=chat_id, active_chat_name=chat_name) + await state.set_state(ChatState.idle) + await _send_reply( + message, + f"✅ [{chat_name}] уже связан с этой темой.", + thread_id=thread_id, + ) + return + + count = db.count_chats(tg_id) + chat_name = name or f"Чат #{count + 1}" + chat_id = db.create_chat(tg_id, chat_name) + db.set_forum_thread(chat_id, thread_id) + await state.update_data(active_chat_id=chat_id, active_chat_name=chat_name) + await state.set_state(ChatState.idle) + await _send_reply( + message, + f"✅ [{chat_name}] зарегистрирован в этой теме. Пиши!", + thread_id=thread_id, + ) + return count = db.count_chats(tg_id) chat_name = name or f"Чат #{count + 1}" - chat_id = db.create_chat(tg_id, chat_name) + created_thread_id = None + forum_group_id = db.get_forum_group(tg_id) + + if forum_group_id is not None: + try: + topic = await message.bot.create_forum_topic(chat_id=forum_group_id, name=chat_name) + created_thread_id = ( + getattr(topic, "message_thread_id", None) + or getattr(topic, "thread_id", None) + ) + if created_thread_id is not None: + db.set_forum_thread(chat_id, created_thread_id) + except Exception as exc: # pragma: no cover - defensive fallback for Telegram API + logger.warning( + "Failed to create forum topic for new chat", + tg_user_id=tg_id, + chat_name=chat_name, + error=str(exc), + ) + await state.update_data(active_chat_id=chat_id, active_chat_name=chat_name) await state.set_state(ChatState.idle) - await message.answer(f"✅ [{chat_name}] создан. Пиши!") + if created_thread_id is not None: + await message.answer(f"✅ [{chat_name}] создан. Форум-тема тоже создана.") + else: + await message.answer(f"✅ [{chat_name}] создан. Пиши!") @router.message(Command("chats")) async def cmd_list_chats(message: Message, state: FSMContext) -> None: + if is_forum_message(message): + await _send_reply( + message, + "В forum-теме переключение между чатами отключено. " + "Эта тема всегда привязана к одному чату. Используй /chats в личке с ботом.", + thread_id=_thread_id(message), + ) + return + tg_id = message.from_user.id chats = db.get_user_chats(tg_id) if not chats: @@ -103,6 +249,13 @@ async def cmd_list_chats(message: Message, state: FSMContext) -> None: @router.callback_query(F.data.startswith("switch:")) async def switch_chat(callback: CallbackQuery, state: FSMContext) -> None: + if _callback_thread_id(callback) is not None: + await callback.answer( + "Переключение чатов доступно только в личке с ботом.", + show_alert=True, + ) + return + _, chat_id, chat_name = callback.data.split(":", 2) await state.update_data(active_chat_id=chat_id, active_chat_name=chat_name) await state.set_state(ChatState.idle) @@ -112,6 +265,13 @@ async def switch_chat(callback: CallbackQuery, state: FSMContext) -> None: @router.callback_query(F.data == "new_chat") async def cb_new_chat(callback: CallbackQuery, state: FSMContext) -> None: + if _callback_thread_id(callback) is not None: + await callback.answer( + "Создание нового чата из списка доступно только в личке с ботом.", + show_alert=True, + ) + return + tg_id = callback.from_user.id count = db.count_chats(tg_id) chat_name = f"Чат #{count + 1}" diff --git a/adapter/telegram/handlers/confirm.py b/adapter/telegram/handlers/confirm.py index d8a1839..11b5021 100644 --- a/adapter/telegram/handlers/confirm.py +++ b/adapter/telegram/handlers/confirm.py @@ -5,12 +5,35 @@ from aiogram import F, Router from aiogram.fsm.context import FSMContext from aiogram.types import CallbackQuery +from adapter.telegram import db +from adapter.telegram.converter import format_outgoing, is_forum_message, resolve_forum_chat_id from core.handler import EventDispatcher -from core.protocol import IncomingCallback +from core.protocol import IncomingCallback, OutgoingMessage, OutgoingUI router = Router(name="confirm") +async def _send_reply( + callback: CallbackQuery, + text: str, + *, + thread_id: int | None = None, +) -> None: + if callback.message is None: + await callback.answer() + return + + if thread_id is None: + await callback.message.answer(text) + return + + await callback.message.bot.send_message( + callback.message.chat.id, + text, + message_thread_id=thread_id, + ) + + @router.callback_query(F.data.startswith("confirm:")) async def handle_confirm( callback: CallbackQuery, @@ -23,12 +46,21 @@ async def handle_confirm( data = await state.get_data() chat_id = data.get("active_chat_id", "") chat_name = data.get("active_chat_name", "Чат") + thread_id = getattr(callback.message, "message_thread_id", None) + forum_mode = callback.message is not None and is_forum_message(callback.message) - from adapter.telegram import db as tgdb tg_id = callback.from_user.id - tg_user = tgdb.get_or_create_tg_user(tg_id, str(tg_id), callback.from_user.full_name) + tg_user = db.get_or_create_tg_user(tg_id, str(tg_id), callback.from_user.full_name) platform_user_id = tg_user.get("platform_user_id", str(tg_id)) + if forum_mode and callback.message is not None: + resolved_chat_id = resolve_forum_chat_id(callback.message, tg_id) + if resolved_chat_id: + chat_id = resolved_chat_id + chat = db.get_chat_by_id(chat_id) + if chat: + chat_name = chat["name"] + incoming = IncomingCallback( user_id=platform_user_id, platform="telegram", @@ -38,12 +70,12 @@ async def handle_confirm( ) events = await dispatcher.dispatch(incoming) - await callback.message.edit_reply_markup(reply_markup=None) + if callback.message is not None: + await callback.message.edit_reply_markup(reply_markup=None) for event in events: - from core.protocol import OutgoingMessage, OutgoingUI - from adapter.telegram.converter import format_outgoing if isinstance(event, (OutgoingMessage, OutgoingUI)): - await callback.message.answer(format_outgoing(chat_name, event)) + rendered = format_outgoing(chat_name, event, prefix=not forum_mode) + await _send_reply(callback, rendered, thread_id=thread_id if forum_mode else None) await callback.answer() diff --git a/adapter/telegram/handlers/forum.py b/adapter/telegram/handlers/forum.py new file mode 100644 index 0000000..61bff25 --- /dev/null +++ b/adapter/telegram/handlers/forum.py @@ -0,0 +1,212 @@ +from __future__ import annotations + +import structlog +from aiogram import F, Router +from aiogram.filters import Command +from aiogram.fsm.context import FSMContext +from aiogram.types import Chat, Message, ReplyKeyboardRemove + +from adapter.telegram import db +from adapter.telegram.keyboards.forum import forum_group_request_keyboard +from adapter.telegram.states import ChatState, ForumSetupState + +logger = structlog.get_logger(__name__) + +router = Router(name="forum") + + +def _thread_id_from_topic(topic: object) -> int | None: + thread_id = getattr(topic, "message_thread_id", None) + if thread_id is not None: + return thread_id + return getattr(topic, "thread_id", None) + + +def _resolve_forwarded_chat(message: Message) -> Chat | None: + forwarded_chat = getattr(message, "forward_from_chat", None) + if forwarded_chat is not None: + return forwarded_chat + + forward_origin = getattr(message, "forward_origin", None) + if forward_origin is None: + return None + + sender_chat = getattr(forward_origin, "sender_chat", None) + if sender_chat is not None: + return sender_chat + + return getattr(forward_origin, "chat", None) + + +def _forward_debug_payload(message: Message) -> dict[str, object]: + forward_origin = getattr(message, "forward_origin", None) + forwarded_chat = _resolve_forwarded_chat(message) + return { + "has_forward_from_chat": getattr(message, "forward_from_chat", None) is not None, + "has_forward_origin": forward_origin is not None, + "forward_origin_type": getattr(forward_origin, "type", None), + "forwarded_chat_id": getattr(forwarded_chat, "id", None), + "forwarded_chat_type": getattr(forwarded_chat, "type", None), + "forwarded_chat_is_forum": getattr(forwarded_chat, "is_forum", None), + } + + +async def _send_message( + message: Message, + text: str, + *, + reply_markup=None, + thread_id: int | None = None, +) -> None: + if thread_id is None: + await message.answer(text, reply_markup=reply_markup) + return + + await message.bot.send_message( + message.chat.id, + text, + reply_markup=reply_markup, + message_thread_id=thread_id, + ) + + +async def _complete_group_link(message: Message, state: FSMContext, forwarded_chat: Chat) -> None: + bot_user = await message.bot.get_me() + member = await message.bot.get_chat_member(forwarded_chat.id, bot_user.id) + can_manage_topics = getattr(member, "can_manage_topics", False) + is_admin = member.status in ("administrator", "creator") + if not is_admin or (member.status == "administrator" and not can_manage_topics): + logger.warning( + "Forum onboarding failed: bot lacks forum admin rights", + tg_user_id=message.from_user.id, + forum_group_id=forwarded_chat.id, + member_status=member.status, + can_manage_topics=can_manage_topics, + ) + await message.answer( + "Я не вижу прав на управление темами. " + "Добавь меня администратором с правом `can_manage_topics` и попробуй снова.", + reply_markup=ReplyKeyboardRemove(), + ) + return + + tg_user_id = message.from_user.id + db.set_forum_group(tg_user_id, forwarded_chat.id) + logger.info( + "Forum group linked", + tg_user_id=tg_user_id, + forum_group_id=forwarded_chat.id, + forum_group_title=getattr(forwarded_chat, "title", None), + ) + + created_topics = 0 + for chat in db.get_user_chats(tg_user_id): + if chat.get("forum_thread_id") is not None: + continue + + topic = await message.bot.create_forum_topic( + chat_id=forwarded_chat.id, + name=chat["name"], + ) + thread_id = _thread_id_from_topic(topic) + if thread_id is None: + logger.warning("Forum topic created without thread id", chat_id=chat["chat_id"]) + continue + + db.set_forum_thread(chat["chat_id"], thread_id) + created_topics += 1 + logger.info( + "Forum topic linked to chat", + tg_user_id=tg_user_id, + chat_id=chat["chat_id"], + forum_group_id=forwarded_chat.id, + forum_thread_id=thread_id, + ) + + await state.set_state(ChatState.idle) + logger.info( + "Forum onboarding completed", + tg_user_id=tg_user_id, + forum_group_id=forwarded_chat.id, + created_topics=created_topics, + ) + await message.answer( + f"✅ Группа подключена. Создал {created_topics} тем(ы) для существующих чатов.", + reply_markup=ReplyKeyboardRemove(), + ) + + +@router.message(Command("forum")) +async def cmd_forum(message: Message, state: FSMContext) -> None: + await state.set_state(ForumSetupState.waiting_for_group) + logger.info("Forum onboarding started", tg_user_id=message.from_user.id) + await message.answer( + "Выбери forum-группу кнопкой ниже. Бот должен уже быть добавлен туда " + "администратором с правом управления темами.\n\n" + "Если кнопка не сработает, можно переслать сообщение из группы как fallback.", + reply_markup=forum_group_request_keyboard(), + ) + + +@router.message(ForumSetupState.waiting_for_group) +async def handle_group_forward(message: Message, state: FSMContext) -> None: + chat_shared = getattr(message, "chat_shared", None) + if chat_shared is not None: + logger.info( + "Forum onboarding chat selected via request_chat", + tg_user_id=message.from_user.id, + forum_group_id=chat_shared.chat_id, + forum_group_title=getattr(chat_shared, "title", None), + request_id=getattr(chat_shared, "request_id", None), + ) + forwarded_chat = Chat( + id=chat_shared.chat_id, + type="supergroup", + title=getattr(chat_shared, "title", None), + is_forum=True, + ) + await _complete_group_link(message, state, forwarded_chat) + return + + debug_payload = _forward_debug_payload(message) + logger.info( + "Forum onboarding message received", + tg_user_id=message.from_user.id, + **debug_payload, + ) + + forwarded_chat = _resolve_forwarded_chat(message) + if forwarded_chat is None: + logger.warning( + "Forum onboarding failed: missing forwarded chat metadata", + tg_user_id=message.from_user.id, + **debug_payload, + ) + await message.answer( + "Не вижу в сообщении данных о группе. " + "Нажми кнопку `Выбрать forum-группу` или перешли сообщение именно из нужной супергруппы, не копируй текст вручную." + ) + return + + if forwarded_chat.type != "supergroup": + logger.warning( + "Forum onboarding failed: forwarded chat is not supergroup", + tg_user_id=message.from_user.id, + **debug_payload, + ) + await message.answer( + "Пересылка пришла не из супергруппы. Нужна именно supergroup с включёнными Topics." + ) + return + + if getattr(forwarded_chat, "is_forum", None) is False: + logger.warning( + "Forum onboarding failed: supergroup is not forum-enabled", + tg_user_id=message.from_user.id, + **debug_payload, + ) + await message.answer( + "Это супергруппа, но в ней выключены Topics. Включи Topics и попробуй снова." + ) + return + await _complete_group_link(message, state, forwarded_chat) diff --git a/adapter/telegram/keyboards/forum.py b/adapter/telegram/keyboards/forum.py new file mode 100644 index 0000000..6aa1012 --- /dev/null +++ b/adapter/telegram/keyboards/forum.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +from aiogram.types import KeyboardButton, KeyboardButtonRequestChat, ReplyKeyboardMarkup + + +def forum_group_request_keyboard() -> ReplyKeyboardMarkup: + return ReplyKeyboardMarkup( + keyboard=[[ + KeyboardButton( + text="Выбрать forum-группу", + request_chat=KeyboardButtonRequestChat( + request_id=1, + chat_is_channel=False, + chat_is_forum=True, + bot_is_member=True, + request_title=True, + ), + ) + ]], + resize_keyboard=True, + one_time_keyboard=True, + ) diff --git a/adapter/telegram/states.py b/adapter/telegram/states.py index 251c6ef..2b57517 100644 --- a/adapter/telegram/states.py +++ b/adapter/telegram/states.py @@ -11,3 +11,7 @@ class SettingsState(StatesGroup): menu = State() # Главное меню настроек soul_editing = State() # Редактирует имя/инструкции агента confirm_action = State() # Подтверждение деструктивного действия + + +class ForumSetupState(StatesGroup): + waiting_for_group = State() # Ждём пересылку сообщения из супергруппы diff --git a/docs/superpowers/specs/2026-03-31-telegram-adapter-design.md b/docs/superpowers/specs/2026-03-31-telegram-adapter-design.md index 46421a5..ea2346e 100644 --- a/docs/superpowers/specs/2026-03-31-telegram-adapter-design.md +++ b/docs/superpowers/specs/2026-03-31-telegram-adapter-design.md @@ -1,7 +1,7 @@ # Telegram Adapter Design **Date:** 2026-03-31 -**Status:** Approved — ready for implementation +**Status:** Approved — implemented in `feat/telegram-adapter` **Scope:** `adapter/telegram/` --- @@ -10,38 +10,45 @@ Telegram-адаптер — поверхность для взаимодействия пользователя с AI-агентом Lambda через Telegram. Адаптер конвертирует Telegram-события в `IncomingEvent` (core protocol) и отправляет `OutgoingEvent` обратно. -Бизнес-логика — в `core/`, адаптер только переводит форматы и управляет Telegram API. +Бизнес-логика остаётся в `core/`; адаптер отвечает за Telegram API, FSM и локальную SQLite-привязку Telegram-пользователя и чатов. --- -## Чаты: основной режим — Виртуальные чаты в DM +## Чаты: hybrid DM + Forum Topics -**Решение зафиксировано:** основной режим — виртуальные чаты прямо в личке с ботом. -Forum Topics — опциональный advanced режим (не реализуется в этом прототипе). +**Зафиксированное решение:** базовая поверхность — личка с ботом, Forum Topics — опциональный advanced-режим поверх тех же самых чатов. -### Принцип работы - -- `active_chat_id` — куда идут входящие сообщения от пользователя в данный момент -- Ответы от агента всегда приходят в общий DM-поток с тегом: `[Чат #1] Вот ответ...` -- Пользователь может переключиться до получения ответа — ответ всё равно придёт, тегирован +- В DM пользователь всегда может писать сразу после `/start` +- `active_chat_id` хранится в FSM и определяет, в какой чат идут DM-сообщения +- Если подключена Forum-группа, каждый чат может получить `forum_thread_id` +- Один и тот же `chat_id` доступен из двух поверхностей: + - DM: ответы идут с префиксом `[Название чата]` + - Forum-тема: ответы идут прямо в тему без префикса ### UX флоу -``` +```text /start -→ Приветствие + Чат #1 создан автоматически -→ Пользователь сразу пишет +→ пользователь аутентифицирован +→ создаётся или восстанавливается активный DM-чат -/new [название] -→ Новый чат создан, переключаемся на него +/new [название] в DM +→ создаётся новый чат +→ если forum уже подключён, бот создаёт и forum topic -/chats -→ Инлайн-кнопки: 1. Чат #1 2. Чат #2 3. Исследование рынка -→ Нажимает — переключился +/forum +→ бот просит переслать сообщение из супергруппы с Topics +→ проверяет admin rights +→ привязывает группу к пользователю +→ создаёт topics для существующих чатов -Сообщение в активный чат -→ Typing indicator -→ [Чат #1] Ответ агента +Сообщение в DM +→ идёт в active_chat_id +→ ответ приходит в DM как `[Чат #N] ...` + +Сообщение в forum topic +→ по `message_thread_id` определяется chat_id +→ ответ приходит в ту же тему без тега ``` --- @@ -50,339 +57,180 @@ Forum Topics — опциональный advanced режим (не реализ ### Флоу (мок) -1. `/start` → `get_or_create_user(tg_user_id, "telegram", display_name)` -2. `is_new=True` → создать Чат #1, написать приветствие -3. `is_new=False` → восстановить `active_chat_id` из БД, написать "С возвращением" +1. `/start` → `platform.get_or_create_user(external_id=tg_user_id, platform="telegram", display_name=...)` +2. Бот сохраняет `tg_user_id -> platform_user_id` в локальной БД +3. Если локальных чатов ещё нет — создаёт `Чат #1` +4. Если чат уже есть — восстанавливает последний активный чат ### FSM состояния -```python -class AuthState(StatesGroup): - # В моке состояний нет — auth мгновенный - # Зарезервировано для реального SDK (waiting_confirmation и т.п.) - pass -``` - ---- - -## FSM состояния (полная схема) - ```python class ChatState(StatesGroup): - idle = State() # В активном чате, ждём сообщения - waiting_response = State() # Запрос ушёл на платформу, ждём ответа + idle = State() + waiting_response = State() + class SettingsState(StatesGroup): - menu = State() # Главное меню настроек - soul_editing = State() # Редактирует имя/инструкции агента - confirm_action = State() # Подтверждение деструктивного действия + menu = State() + soul_editing = State() + confirm_action = State() + + +class ForumSetupState(StatesGroup): + waiting_for_group = State() ``` -**`active_chat_id` хранится в FSM StateData, не в состоянии.** +`active_chat_id` и `active_chat_name` хранятся в `FSMContext` data. --- ## Структура файлов -``` +```text adapter/telegram/ - bot.py — точка входа: Dispatcher, routers, middleware - states.py — FSM StatesGroup - converter.py — aiogram Message → IncomingEvent и обратно + bot.py — точка входа: Dispatcher, middleware, routers + converter.py — Message -> IncomingMessage, forum helpers, output formatting + db.py — SQLite schema и Telegram-specific persistence + states.py — ChatState, SettingsState, ForumSetupState handlers/ auth.py — /start - chat.py — /new, /chats, /rename, /archive, сообщения в чате - settings.py — /settings и callback_query для настроек - confirm.py — подтверждение действий агента (InlineKeyboard ✅/❌) + chat.py — /new, /chats, switch chat, входящие сообщения + confirm.py — confirm/cancel callbacks + forum.py — /forum onboarding и регистрация forum group + settings.py — /settings и callbacks настроек keyboards/ - chat.py — список чатов, управление чатом - settings.py — меню настроек, скиллы, коннекторы - confirm.py — кнопки подтверждения действия + chat.py — список чатов + confirm.py — confirm keyboard + settings.py — меню настроек ``` --- +## Persistence + +Локальная БД содержит две Telegram-специфичные сущности: + +```sql +CREATE TABLE tg_users ( + tg_user_id INTEGER PRIMARY KEY, + platform_user_id TEXT NOT NULL, + display_name TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + forum_group_id INTEGER +); + +CREATE TABLE chats ( + chat_id TEXT PRIMARY KEY, + tg_user_id INTEGER NOT NULL, + name TEXT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + archived_at TIMESTAMP, + forum_thread_id INTEGER +); +``` + +- `forum_group_id` — привязанная супергруппа пользователя +- `forum_thread_id` — опциональная связь конкретного чата с forum topic + +--- + ## Converter -Конвертация в обе стороны — `adapter/telegram/converter.py`. - -### aiogram → IncomingEvent +### Telegram -> IncomingEvent ```python -def from_message(message: Message) -> IncomingMessage: +def from_message(message: Message, chat_id: str) -> IncomingMessage: return IncomingMessage( user_id=str(message.from_user.id), - chat_id=active_chat_id, # из FSM StateData - text=message.text or "", - attachments=extract_attachments(message), + chat_id=chat_id, + text=message.text or message.caption or "", + attachments=_extract_attachments(message), platform="telegram", - raw=message.model_dump(), ) -def extract_attachments(message: Message) -> list[Attachment]: - attachments = [] - if message.photo: - file = message.photo[-1] # наибольшее разрешение - attachments.append(Attachment( - url=f"tg://file/{file.file_id}", # резолвим через getFile при необходимости - mime_type="image/jpeg", - size=file.file_size, - )) - if message.document: - attachments.append(Attachment( - url=f"tg://file/{message.document.file_id}", - mime_type=message.document.mime_type or "application/octet-stream", - size=message.document.file_size, - filename=message.document.file_name, - )) - if message.voice: - attachments.append(Attachment( - url=f"tg://file/{message.voice.file_id}", - mime_type="audio/ogg", - size=message.voice.file_size, - )) - return attachments + +def is_forum_message(message: Message) -> bool: + return getattr(message, "message_thread_id", None) is not None + + +def resolve_forum_chat_id(message: Message, tg_user_id: int) -> str | None: + thread_id = getattr(message, "message_thread_id", None) + if thread_id is None: + return None + + chat = db.get_chat_by_thread(tg_user_id, thread_id) + return chat["chat_id"] if chat else None ``` -### OutgoingEvent → Telegram +### OutgoingEvent -> Telegram ```python -async def send_outgoing(bot: Bot, user_id: int, chat_name: str, event: OutgoingEvent) -> None: - prefix = f"[{chat_name}] " - - if isinstance(event, OutgoingMessage): - await bot.send_message(user_id, prefix + event.text) - - elif isinstance(event, OutgoingUI): - # Кнопки подтверждения действия - keyboard = build_confirm_keyboard(event) - await bot.send_message(user_id, prefix + event.text, reply_markup=keyboard) +def format_outgoing(chat_name: str, event: OutgoingEvent, *, prefix: bool = True) -> str: + rendered_prefix = f"[{chat_name}] " if prefix else "" + return rendered_prefix + event.text ``` +- DM-ответы используют `prefix=True` +- Forum-ответы используют `prefix=False` +- `OutgoingUI` отправляется с inline-кнопками подтверждения + --- ## Обработчики -### auth.py — `/start` +### `auth.py` -```python -@router.message(CommandStart()) -async def cmd_start(message: Message, state: FSMContext, platform: PlatformClient): - user = await platform.get_or_create_user( - external_id=str(message.from_user.id), - platform="telegram", - display_name=message.from_user.full_name, - ) +- `/start` создаёт или восстанавливает пользователя +- если это первый запуск, создаёт `Чат #1` +- обновляет `active_chat_id` и переводит FSM в `ChatState.idle` - if user.is_new: - chat_id = create_chat(user.user_id, "Чат #1") # в локальной БД - await state.update_data(active_chat_id=chat_id, active_chat_name="Чат #1") - await state.set_state(ChatState.idle) - await message.answer( - f"Привет, {message.from_user.first_name}! 👋\n" - f"Я создал тебе первый чат. Просто пиши.\n\n" - f"Команды: /new — новый чат, /chats — список" - ) - else: - # Восстановить последний активный чат - last_chat = get_last_chat(user.user_id) - await state.update_data(active_chat_id=last_chat.id, active_chat_name=last_chat.name) - await state.set_state(ChatState.idle) - await message.answer(f"С возвращением! Продолжаем [{last_chat.name}]") -``` +### `chat.py` -### chat.py — сообщения +- `/new`: + - в DM создаёт новый чат + - если подключён forum, пытается создать forum topic и сохранить `forum_thread_id` + - в forum-теме может зарегистрировать текущую тему как чат +- `/chats` показывает inline-список чатов +- `switch::` переключает активный DM-чат +- `handle_message`: + - в DM читает `active_chat_id` из FSM + - в forum определяет чат по `message_thread_id` + - отправляет `typing` + - прокидывает `IncomingMessage` в `EventDispatcher` + - возвращает ответ в DM или в тему -```python -@router.message(ChatState.idle, F.text) -async def handle_message(message: Message, state: FSMContext, platform: PlatformClient): - data = await state.get_data() - chat_id = data["active_chat_id"] - chat_name = data["active_chat_name"] +### `forum.py` - await state.set_state(ChatState.waiting_response) - await message.bot.send_chat_action(message.chat.id, "typing") +- `/forum` переводит FSM в `ForumSetupState.waiting_for_group` +- пересланное сообщение из супергруппы: + - валидирует, что это `supergroup` + - проверяет, что бот admin и умеет `can_manage_topics` + - сохраняет `forum_group_id` + - создаёт topics для существующих чатов без `forum_thread_id` - incoming = from_message(message, chat_id) - outgoing_events = await core_handler.handle(incoming, platform) +### `confirm.py` - await state.set_state(ChatState.idle) - - for event in outgoing_events: - await send_outgoing(message.bot, message.from_user.id, chat_name, event) -``` - -### chat.py — управление чатами - -```python -@router.message(Command("new")) -async def cmd_new_chat(message: Message, state: FSMContext): - args = message.text.split(maxsplit=1) - name = args[1] if len(args) > 1 else None - - data = await state.get_data() - user_id = ... # из платформы - count = count_chats(user_id) - chat_name = name or f"Чат #{count + 1}" - - chat_id = create_chat(user_id, chat_name) - await state.update_data(active_chat_id=chat_id, active_chat_name=chat_name) - await message.answer(f"✅ [{chat_name}] создан. Пиши!") - - -@router.message(Command("chats")) -async def cmd_list_chats(message: Message, state: FSMContext): - chats = get_user_chats(user_id) - data = await state.get_data() - active_id = data.get("active_chat_id") - - buttons = [] - for chat in chats: - mark = "● " if chat.id == active_id else "" - buttons.append([InlineKeyboardButton( - text=f"{mark}{chat.name}", - callback_data=f"switch:{chat.id}:{chat.name}" - )]) - buttons.append([InlineKeyboardButton(text="➕ Новый чат", callback_data="new_chat")]) - - await message.answer("Твои чаты:", reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons)) - - -@router.callback_query(F.data.startswith("switch:")) -async def switch_chat(callback: CallbackQuery, state: FSMContext): - _, chat_id, chat_name = callback.data.split(":", 2) - await state.update_data(active_chat_id=chat_id, active_chat_name=chat_name) - await callback.message.edit_text(f"✅ Переключился на [{chat_name}]") - await callback.answer() -``` - -### confirm.py — подтверждение действий агента - -```python -# Агент хочет выполнить действие → OutgoingUI приходит из core handler -# Бот показывает кнопки, ждёт ответа - -@router.callback_query(F.data.startswith("confirm:")) -async def handle_confirm(callback: CallbackQuery, state: FSMContext, platform: PlatformClient): - _, action_id, decision = callback.data.split(":") # "confirm" / "cancel" - data = await state.get_data() - - incoming = IncomingCallback( - user_id=..., - chat_id=data["active_chat_id"], - action="confirm" if decision == "yes" else "cancel", - payload={"action_id": action_id}, - platform="telegram", - ) - outgoing_events = await core_handler.handle(incoming, platform) - await callback.message.edit_reply_markup(reply_markup=None) - for event in outgoing_events: - await send_outgoing(callback.bot, callback.from_user.id, data["active_chat_name"], event) - await callback.answer() -``` +- обрабатывает `confirm:yes:` и `confirm:no:` +- в forum-режиме восстанавливает `chat_id` по thread +- ответ на callback отправляет обратно в тот же канал: + - DM -> в личку + - Forum -> в тот же `message_thread_id` --- -## Настройки +## Текущее покрытие -`/settings` → инлайн-меню. Структура: - -``` -⚙️ Настройки -[🧩 Скиллы] [🔗 Коннекторы] -[🧠 Личность] [🔒 Безопасность] -[💳 Подписка] -``` - -**Скиллы** — список с кнопками-переключателями ✅/❌. Нажатие → `SettingsAction(toggle_skill)`. - -**Личность** — свободные поля (имя агента, инструкции). Без пресетов стилей. -FSM: `SettingsState.soul_editing` → бот задаёт вопросы по одному полю. - -**Коннекторы** — заглушка OAuth ссылки. - -**Безопасность** — переключатели для деструктивных действий. - -**Подписка** — заглушка с токенами. +- unit-тесты на forum routing и forum onboarding: `tests/adapter/telegram/test_forum.py` +- smoke/integration на dispatcher и core handlers: + - `tests/core/test_dispatcher.py` + - `tests/core/test_integration.py` --- -## Хранилище (БД) +## Что не покрывает этот документ -Минимальная схема для прототипа: - -```sql -CREATE TABLE tg_users ( - tg_user_id INTEGER PRIMARY KEY, - platform_user_id TEXT NOT NULL, -- из MockPlatformClient - display_name TEXT, - created_at TIMESTAMP -); - -CREATE TABLE chats ( - chat_id TEXT PRIMARY KEY, -- UUID - tg_user_id INTEGER NOT NULL, - name TEXT NOT NULL, - created_at TIMESTAMP, - archived_at TIMESTAMP, - FOREIGN KEY(tg_user_id) REFERENCES tg_users(tg_user_id) -); -``` - -`StateStore` из `core/store.py` (`SQLiteStore`) — для FSM и общего состояния. - ---- - -## Typing indicator - -Отправлять `send_chat_action("typing")` перед запросом к платформе. -Если запрос > 5 сек — возобновлять каждые 4 сек (action живёт ~5 сек). - -```python -async def with_typing(bot: Bot, chat_id: int, coro): - async def renew(): - while True: - await bot.send_chat_action(chat_id, "typing") - await asyncio.sleep(4) - task = asyncio.create_task(renew()) - try: - return await coro - finally: - task.cancel() -``` - ---- - -## Обработка ответов при смене чата - -Ответ всегда приходит в DM-поток с тегом: -``` -[Чат #1] Вот мой ответ на вопрос про Python... -``` - -Пользователь мог переключить `active_chat_id` пока шёл запрос — это нормально. -`chat_name` берётся из `StateData` в момент отправки запроса (до `set_state(waiting_response)`). - ---- - -## Что НЕ реализуем в прототипе - -- Forum Topics режим (researched, отложено) -- Webhook от платформы (platform ещё не готов — используем sync `send_message`) -- `/rename`, `/archive` для чатов (добавить после основного флоу) -- Экспорт истории - ---- - -## Порядок реализации - -1. `bot.py` — Dispatcher, middleware для platform client -2. `states.py` — FSM классы -3. `converter.py` — from_message, extract_attachments -4. `handlers/auth.py` — /start -5. `handlers/chat.py` — сообщения + /new + /chats -6. `keyboards/chat.py` — список чатов -7. `handlers/settings.py` + `keyboards/settings.py` — меню настроек -8. `handlers/confirm.py` + `keyboards/confirm.py` — подтверждения +- Matrix-адаптер +- Реальный SDK платформы вместо `sdk.mock.MockPlatformClient` +- Автоматическое отслеживание вручную созданных пользователем forum topics без `/new` diff --git a/docs/telegram-prototype.md b/docs/telegram-prototype.md index c58a1e5..b739843 100644 --- a/docs/telegram-prototype.md +++ b/docs/telegram-prototype.md @@ -2,14 +2,17 @@ ## Концепция -Один бот, несколько чатов через Topics в Forum-группе. +Один бот, несколько чатов, две поверхности: -При первом запуске бот создаёт для пользователя персональную Forum-группу -(супергруппу с включёнными темами). Каждый новый чат с агентом — отдельная тема -внутри группы. Пользователь видит это как список чатов в одном месте. +- базовая поверхность — личка с ботом (DM) +- опциональная advanced-поверхность — Topics в пользовательской Forum-группе -Бот управляет группой от имени пользователя через Telegram Bot API: -создаёт темы, переименовывает, архивирует. +При первом запуске пользователь начинает в DM: бот создаёт первый чат и +переключает пользователя в него. Если позже пользователь подключает Forum-группу +через `/forum`, существующие чаты получают соответствующие темы в супергруппе. + +DM и Forum используют один и тот же `chat_id`: пользователь может писать +либо в личке, либо в forum topic, а платформа видит единый разговор. --- @@ -29,44 +32,51 @@ --- -## Чаты через Forum Topics (вариант В) +## Чаты в DM и Forum Topics ### Как это работает -- Бот создаёт супергруппу с Topics для каждого нового пользователя -- Каждый чат = отдельная тема (Topic) в этой группе -- История хранится нативно в Telegram (в самой теме) -- Переключение между чатами = переключение между темами +- После `/start` бот создаёт `Чат #1` в DM +- В DM активный чат хранится как `active_chat_id` +- Ответы в личке приходят в общий поток с тегом `[Чат #N]` +- После `/forum` пользователь привязывает свою супергруппу с Topics +- Каждый чат может получить соответствующую тему (`forum_thread_id`) +- В forum-теме ответы приходят без тега, прямо в тему +- История forum-разговоров хранится нативно в Telegram ### Управление чатами -Внутри каждой темы доступны команды: +В DM доступны команды: | Команда | Действие | |---|---| -| `/new` | Создать новый чат (новую тему) | -| `/rename Название` | Переименовать текущий чат | -| `/archive` | Архивировать текущий чат | +| `/new` | Создать новый чат | | `/chats` | Показать список всех чатов | +| `/forum` | Подключить Forum-группу | + +В forum-темах поддерживается тот же разговорный контекст, а `/new` может +зарегистрировать текущую тему как отдельный чат. ### Создание нового чата -1. Пользователь пишет `/new` или нажимает кнопку -2. Бот спрашивает название (опционально, можно пропустить) -3. Бот создаёт новую тему в группе: «Чат 1», «Чат 2» и т.д. -4. Бот отправляет в новую тему приветствие; при первом сообщении платформа автоматически поднимает контейнер +1. Пользователь пишет `/new [название]` или нажимает кнопку +2. Бот создаёт новый чат в локальной БД: `Чат #N` или указанное название +3. Если Forum уже подключён, бот дополнительно создаёт новую тему в привязанной группе +4. В DM бот переключает `active_chat_id` на новый чат ### В моке -- Группа и темы создаются реально через Bot API -- Сообщения передаются в MockPlatformClient с `chat_id` (C1, C2...) -- История в темах хранится нативно в Telegram, ничего не нужно делать +- DM-чаты работают сразу после `/start` +- Если Forum подключён, темы создаются реально через Bot API +- Сообщения из DM и forum topic передаются в `MockPlatformClient` с одним и тем же `chat_id` --- ## Основной диалог ### Флоу сообщения -1. Пользователь пишет текст в тему +1. Пользователь пишет текст в DM или в forum topic 2. Бот показывает `typing...` 3. Запрос уходит в платформу (сейчас — MockPlatformClient) -4. Бот отвечает текстом агента +4. Бот отвечает: + - в DM: с тегом `[Чат #N]` + - в forum topic: без тега, в ту же тему ### Вложения - Фото, документы, голосовые — передаются в платформу как `attachments` @@ -92,7 +102,7 @@ ## Настройки -Доступны через `/settings` в любой теме или в главном меню бота. +Доступны через `/settings` в личке или в forum topic. Реализованы как цепочка инлайн-кнопок. ### Главное меню настроек @@ -198,16 +208,14 @@ ## FSM состояния -``` -[Start] → AuthPending → AuthConfirmed - ↓ - GroupSetup → Idle - ↓ - ReceivingMessage → WaitingResponse → Idle - ↓ - ConfirmAction → [Confirmed/Cancelled] → Idle - ↓ - Settings → [подменю] → Idle +```text +[Start] -> ChatState.idle + ↓ + ForumSetupState.waiting_for_group + ↓ + ChatState.waiting_response -> ChatState.idle + ↓ + SettingsState.* ``` --- @@ -216,6 +224,6 @@ - Python 3.11+ - aiogram 3.x (Router, FSM, InlineKeyboard, Forum Topics API) -- MockPlatformClient → `platform/interface.py` +- MockPlatformClient → `sdk/mock.py` - structlog для логирования -- SQLite для хранения `tg_user_id → platform_user_id` и состояния скиллов +- SQLite для хранения `tg_user_id → platform_user_id`, чатов и forum bindings diff --git a/tests/adapter/__init__.py b/tests/adapter/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/tests/adapter/__init__.py @@ -0,0 +1 @@ + diff --git a/tests/adapter/telegram/__init__.py b/tests/adapter/telegram/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/tests/adapter/telegram/__init__.py @@ -0,0 +1 @@ + diff --git a/tests/adapter/telegram/test_forum.py b/tests/adapter/telegram/test_forum.py new file mode 100644 index 0000000..d0f7aaa --- /dev/null +++ b/tests/adapter/telegram/test_forum.py @@ -0,0 +1,374 @@ +from __future__ import annotations + +from datetime import datetime +from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock + +from aiogram.fsm.context import FSMContext +from aiogram.types import Chat, MessageOriginChat + +from adapter.telegram.converter import is_forum_message, resolve_forum_chat_id +from adapter.telegram.handlers import chat as chat_handler +from adapter.telegram.handlers import confirm as confirm_handler +from adapter.telegram.handlers import forum as forum_handler +from adapter.telegram.states import ChatState, ForumSetupState +from core.protocol import OutgoingMessage + + +def make_message(*, text: str = "hello", thread_id: int | None = None): + message = SimpleNamespace() + message.text = text + message.caption = None + message.photo = None + message.document = None + message.voice = None + message.message_thread_id = thread_id + message.chat = SimpleNamespace(id=-100123) + message.from_user = SimpleNamespace(id=42, full_name="Alice", first_name="Alice") + message.answer = AsyncMock() + message.edit_text = AsyncMock() + message.edit_reply_markup = AsyncMock() + message.bot = SimpleNamespace( + send_message=AsyncMock(), + send_chat_action=AsyncMock(), + create_forum_topic=AsyncMock(), + get_me=AsyncMock(), + get_chat_member=AsyncMock(), + ) + message.chat_shared = None + return message + + +class FakeTask: + def cancel(self) -> None: + self.cancelled = True + + def __await__(self): + async def _done(): + return None + + return _done().__await__() + + +async def test_forum_helpers_detect_and_resolve(monkeypatch): + message = make_message(thread_id=77) + monkeypatch.setattr( + chat_handler.db, + "get_chat_by_thread", + lambda tg_user_id, thread_id: {"chat_id": "chat-77"} if thread_id == 77 else None, + ) + + assert is_forum_message(message) is True + assert resolve_forum_chat_id(message, 42) == "chat-77" + + +async def test_cmd_forum_enters_setup_state(): + message = make_message(text="/forum") + state = AsyncMock(spec=FSMContext) + + await forum_handler.cmd_forum(message, state) + + state.set_state.assert_awaited_once_with(ForumSetupState.waiting_for_group) + message.answer.assert_awaited_once() + assert message.answer.await_args.kwargs["reply_markup"] is not None + + +async def test_handle_group_forward_registers_group_and_topics(monkeypatch): + message = make_message(text="forwarded") + message.forward_from_chat = SimpleNamespace(id=-100200, type="supergroup", title="Lambda") + message.bot.get_me.return_value = SimpleNamespace(id=999) + message.bot.get_chat_member.return_value = SimpleNamespace( + status="administrator", + can_manage_topics=True, + ) + message.bot.create_forum_topic.side_effect = [ + SimpleNamespace(message_thread_id=11), + SimpleNamespace(message_thread_id=22), + ] + state = AsyncMock(spec=FSMContext) + + monkeypatch.setattr( + forum_handler.db, + "get_user_chats", + lambda tg_user_id: [ + {"chat_id": "chat-1", "name": "One", "forum_thread_id": None}, + {"chat_id": "chat-2", "name": "Two", "forum_thread_id": None}, + ], + ) + set_forum_group = Mock() + set_forum_thread = Mock() + monkeypatch.setattr(forum_handler.db, "set_forum_group", set_forum_group) + monkeypatch.setattr(forum_handler.db, "set_forum_thread", set_forum_thread) + + await forum_handler.handle_group_forward(message, state) + + set_forum_group.assert_called_once_with(42, -100200) + assert message.bot.create_forum_topic.await_count == 2 + set_forum_thread.assert_any_call("chat-1", 11) + set_forum_thread.assert_any_call("chat-2", 22) + state.set_state.assert_awaited_once_with(ChatState.idle) + assert "Группа подключена" in message.answer.await_args.args[0] + + +async def test_handle_group_forward_accepts_forward_origin_chat(monkeypatch): + message = make_message(text="forwarded") + message.forward_from_chat = None + message.forward_origin = MessageOriginChat( + date=datetime.now(), + sender_chat=Chat(id=-100200, type="supergroup", title="Lambda", is_forum=True), + ) + message.bot.get_me.return_value = SimpleNamespace(id=999) + message.bot.get_chat_member.return_value = SimpleNamespace( + status="administrator", + can_manage_topics=True, + ) + state = AsyncMock(spec=FSMContext) + + monkeypatch.setattr(forum_handler.db, "get_user_chats", lambda tg_user_id: []) + set_forum_group = Mock() + monkeypatch.setattr(forum_handler.db, "set_forum_group", set_forum_group) + + await forum_handler.handle_group_forward(message, state) + + set_forum_group.assert_called_once_with(42, -100200) + state.set_state.assert_awaited_once_with(ChatState.idle) + assert "Группа подключена" in message.answer.await_args.args[0] + + +async def test_handle_group_forward_accepts_chat_shared(monkeypatch): + message = make_message(text="selected") + message.chat_shared = SimpleNamespace(request_id=1, chat_id=-100200, title="Lambda") + message.bot.get_me.return_value = SimpleNamespace(id=999) + message.bot.get_chat_member.return_value = SimpleNamespace( + status="administrator", + can_manage_topics=True, + ) + state = AsyncMock(spec=FSMContext) + + monkeypatch.setattr(forum_handler.db, "get_user_chats", lambda tg_user_id: []) + set_forum_group = Mock() + monkeypatch.setattr(forum_handler.db, "set_forum_group", set_forum_group) + + await forum_handler.handle_group_forward(message, state) + + set_forum_group.assert_called_once_with(42, -100200) + state.set_state.assert_awaited_once_with(ChatState.idle) + assert "Группа подключена" in message.answer.await_args.args[0] + + +async def test_handle_group_forward_reports_missing_forward_metadata(): + message = make_message(text="not forwarded") + message.forward_from_chat = None + message.forward_origin = None + state = AsyncMock(spec=FSMContext) + + await forum_handler.handle_group_forward(message, state) + + message.answer.assert_awaited_once() + assert "данных о группе" in message.answer.await_args.args[0] + state.set_state.assert_not_awaited() + + +async def test_handle_group_forward_reports_non_forum_supergroup(): + message = make_message(text="forwarded") + message.forward_from_chat = SimpleNamespace( + id=-100200, + type="supergroup", + title="Lambda", + is_forum=False, + ) + state = AsyncMock(spec=FSMContext) + + await forum_handler.handle_group_forward(message, state) + + message.answer.assert_awaited_once() + assert "выключены Topics" in message.answer.await_args.args[0] + state.set_state.assert_not_awaited() + + +async def test_handle_message_routes_forum_thread(monkeypatch): + message = make_message(thread_id=77) + dispatcher = SimpleNamespace( + dispatch=AsyncMock( + return_value=[OutgoingMessage(chat_id="chat-77", text="ok")] + ) + ) + state = AsyncMock(spec=FSMContext) + state.get_data.return_value = {} + + monkeypatch.setattr( + chat_handler.db, + "get_or_create_tg_user", + lambda tg_user_id, platform_user_id, display_name: { + "platform_user_id": "usr-42", + "display_name": display_name, + }, + ) + monkeypatch.setattr( + chat_handler.db, + "get_chat_by_thread", + lambda tg_user_id, thread_id: {"chat_id": "chat-77", "name": "Forum chat"}, + ) + monkeypatch.setattr( + chat_handler.db, + "get_chat_by_id", + lambda chat_id: {"chat_id": chat_id, "name": "Forum chat"}, + ) + monkeypatch.setattr( + chat_handler.asyncio, + "create_task", + lambda coro: (coro.close(), FakeTask())[1], + ) + + await chat_handler.handle_message(message, state, dispatcher) + + incoming = dispatcher.dispatch.await_args.args[0] + assert incoming.chat_id == "chat-77" + assert incoming.user_id == "usr-42" + assert state.update_data.await_args.kwargs == { + "active_chat_id": "chat-77", + "active_chat_name": "Forum chat", + } + message.bot.send_message.assert_awaited_once() + assert message.bot.send_message.await_args.args[0] == -100123 + assert message.bot.send_message.await_args.kwargs["message_thread_id"] == 77 + assert message.bot.send_message.await_args.args[1] == "ok" + + +async def test_cmd_new_chat_creates_forum_topic_for_dm(monkeypatch): + message = make_message(text="/new Analysis") + state = AsyncMock(spec=FSMContext) + message.bot.create_forum_topic.return_value = SimpleNamespace(message_thread_id=333) + + monkeypatch.setattr(chat_handler.db, "get_forum_group", lambda tg_user_id: -100200) + monkeypatch.setattr(chat_handler.db, "count_chats", lambda tg_user_id: 2) + create_chat = Mock(return_value="chat-3") + set_forum_thread = Mock() + monkeypatch.setattr(chat_handler.db, "create_chat", create_chat) + monkeypatch.setattr(chat_handler.db, "set_forum_thread", set_forum_thread) + + await chat_handler.cmd_new_chat(message, state) + + create_chat.assert_called_once_with(42, "Analysis") + message.bot.create_forum_topic.assert_awaited_once_with(chat_id=-100200, name="Analysis") + set_forum_thread.assert_called_once_with("chat-3", 333) + state.update_data.assert_awaited_once_with(active_chat_id="chat-3", active_chat_name="Analysis") + message.answer.assert_awaited_once() + assert "Форум-тема тоже создана" in message.answer.await_args.args[0] + + +async def test_cmd_new_chat_registers_topic(monkeypatch): + message = make_message(text="/new Research", thread_id=88) + state = AsyncMock(spec=FSMContext) + + monkeypatch.setattr( + chat_handler.db, + "get_chat_by_thread", + lambda tg_user_id, thread_id: None, + ) + monkeypatch.setattr(chat_handler.db, "count_chats", lambda tg_user_id: 4) + create_chat = Mock(return_value="chat-5") + set_forum_thread = Mock() + monkeypatch.setattr(chat_handler.db, "create_chat", create_chat) + monkeypatch.setattr(chat_handler.db, "set_forum_thread", set_forum_thread) + + await chat_handler.cmd_new_chat(message, state) + + create_chat.assert_called_once_with(42, "Research") + set_forum_thread.assert_called_once_with("chat-5", 88) + message.bot.send_message.assert_awaited_once() + assert message.bot.send_message.await_args.kwargs["message_thread_id"] == 88 + state.update_data.assert_awaited_once_with(active_chat_id="chat-5", active_chat_name="Research") + + +async def test_cmd_list_chats_rejected_in_forum_topic(): + message = make_message(text="/chats", thread_id=88) + state = AsyncMock(spec=FSMContext) + + await chat_handler.cmd_list_chats(message, state) + + message.bot.send_message.assert_awaited_once() + assert message.bot.send_message.await_args.kwargs["message_thread_id"] == 88 + assert "отключено" in message.bot.send_message.await_args.args[1] + + +async def test_switch_chat_rejected_in_forum_topic(): + callback = SimpleNamespace( + data="switch:chat-9:Other", + from_user=SimpleNamespace(id=42, full_name="Alice"), + message=make_message(thread_id=88), + answer=AsyncMock(), + ) + state = AsyncMock(spec=FSMContext) + + await chat_handler.switch_chat(callback, state) + + state.update_data.assert_not_awaited() + callback.answer.assert_awaited_once_with( + "Переключение чатов доступно только в личке с ботом.", + show_alert=True, + ) + + +async def test_new_chat_callback_rejected_in_forum_topic(monkeypatch): + callback = SimpleNamespace( + data="new_chat", + from_user=SimpleNamespace(id=42, full_name="Alice"), + message=make_message(thread_id=88), + answer=AsyncMock(), + ) + state = AsyncMock(spec=FSMContext) + create_chat = Mock() + monkeypatch.setattr(chat_handler.db, "create_chat", create_chat) + + await chat_handler.cb_new_chat(callback, state) + + create_chat.assert_not_called() + state.update_data.assert_not_awaited() + callback.answer.assert_awaited_once_with( + "Создание нового чата из списка доступно только в личке с ботом.", + show_alert=True, + ) + + +async def test_confirm_callback_routes_back_to_forum_thread(monkeypatch): + message = make_message(thread_id=77) + callback = SimpleNamespace( + data="confirm:yes:action-1", + from_user=message.from_user, + message=message, + answer=AsyncMock(), + ) + dispatcher = SimpleNamespace( + dispatch=AsyncMock( + return_value=[OutgoingMessage(chat_id="chat-77", text="done")] + ) + ) + state = AsyncMock(spec=FSMContext) + state.get_data.return_value = {} + + monkeypatch.setattr( + confirm_handler.db, + "get_or_create_tg_user", + lambda tg_user_id, platform_user_id, display_name: { + "platform_user_id": "usr-42", + "display_name": display_name, + }, + ) + monkeypatch.setattr( + confirm_handler.db, + "get_chat_by_thread", + lambda tg_user_id, thread_id: {"chat_id": "chat-77"}, + ) + monkeypatch.setattr( + confirm_handler.db, + "get_chat_by_id", + lambda chat_id: {"chat_id": chat_id, "name": "Forum chat"}, + ) + + await confirm_handler.handle_confirm(callback, state, dispatcher) + + assert dispatcher.dispatch.await_args.args[0].chat_id == "chat-77" + assert callback.message.bot.send_message.await_count == 1 + assert callback.message.bot.send_message.await_args.args[1] == "done" + assert callback.message.bot.send_message.await_args.kwargs["message_thread_id"] == 77