From bcdaea51431d51d4175204aad8069825929b9ce4 Mon Sep 17 00:00:00 2001 From: Mikhail Putilovskij Date: Tue, 31 Mar 2026 23:02:56 +0300 Subject: [PATCH] docs: Forum Topics implementation plan --- .../plans/2026-03-31-forum-topics.md | 704 ++++++++++++++++++ 1 file changed, 704 insertions(+) create mode 100644 docs/superpowers/plans/2026-03-31-forum-topics.md diff --git a/docs/superpowers/plans/2026-03-31-forum-topics.md b/docs/superpowers/plans/2026-03-31-forum-topics.md new file mode 100644 index 0000000..87a92b2 --- /dev/null +++ b/docs/superpowers/plans/2026-03-31-forum-topics.md @@ -0,0 +1,704 @@ +# Forum Topics Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Добавить опциональный Forum Topics режим — пользователь подключает Telegram-супергруппу, его DM-чаты синхронизируются с нативными темами форума. + +**Architecture:** Каждый `chat` в БД получает опциональный `forum_thread_id`. Адаптер маршрутизирует: пришло из DM → отвечает в DM с тегом, пришло из Forum-темы → отвечает в ту же тему без тега. Core не меняется — `chat_id` (UUID) одинаковый для обеих поверхностей. + +**Tech Stack:** aiogram 3.x, SQLite (sqlite3), Python 3.11+ + +**Working directory:** `/Users/a/MAI/sem2/lambda/surfaces-bot/.worktrees/telegram` + +--- + +## File Map + +| Файл | Действие | Что меняется | +|------|----------|--------------| +| `adapter/telegram/db.py` | Modify | Миграция схемы + 4 новых функции | +| `adapter/telegram/states.py` | Modify | Добавить `ForumSetupState` | +| `adapter/telegram/converter.py` | Modify | Добавить `is_forum_message`, `resolve_chat_id` | +| `adapter/telegram/handlers/forum.py` | Create | `/forum` команда + онбординг | +| `adapter/telegram/handlers/chat.py` | Modify | `cmd_new_chat` + `handle_message` с Forum-маршрутизацией | +| `adapter/telegram/bot.py` | Modify | Зарегистрировать `forum.router` | +| `tests/adapter/test_forum_db.py` | Create | Тесты новых функций БД | + +--- + +## Task 1: DB migration + новые функции + +**Files:** +- Modify: `adapter/telegram/db.py` +- Create: `tests/adapter/__init__.py` +- Create: `tests/adapter/test_forum_db.py` + +- [ ] **Step 1: Создать тест-файл и написать падающие тесты** + +```python +# tests/adapter/__init__.py +# (пустой файл) +``` + +```python +# tests/adapter/test_forum_db.py +from __future__ import annotations + +import os +import tempfile +import pytest + +os.environ["DB_PATH"] = ":memory:" + +from adapter.telegram.db import ( + init_db, + get_or_create_tg_user, + create_chat, + set_forum_group, + get_forum_group, + set_forum_thread, + get_chat_by_thread, +) + + +@pytest.fixture(autouse=True) +def fresh_db(tmp_path, monkeypatch): + db_file = str(tmp_path / "test.db") + monkeypatch.setenv("DB_PATH", db_file) + # reload module so DB_PATH is picked up + import importlib + import adapter.telegram.db as db_mod + importlib.reload(db_mod) + db_mod.init_db() + return db_mod + + +def test_set_and_get_forum_group(fresh_db): + db = fresh_db + db.get_or_create_tg_user(111, "usr-111", "Alice") + assert db.get_forum_group(111) is None + db.set_forum_group(111, 999888) + assert db.get_forum_group(111) == 999888 + + +def test_set_forum_thread_and_get_by_thread(fresh_db): + db = fresh_db + db.get_or_create_tg_user(222, "usr-222", "Bob") + chat_id = db.create_chat(222, "Чат #1") + assert db.get_chat_by_thread(222, 42) is None + db.set_forum_thread(chat_id, 42) + chat = db.get_chat_by_thread(222, 42) + assert chat is not None + assert chat["chat_id"] == chat_id + assert chat["forum_thread_id"] == 42 + + +def test_get_chat_by_thread_wrong_user(fresh_db): + db = fresh_db + db.get_or_create_tg_user(333, "usr-333", "Carol") + chat_id = db.create_chat(333, "Чат #1") + db.set_forum_thread(chat_id, 77) + assert db.get_chat_by_thread(999, 77) is None +``` + +- [ ] **Step 2: Запустить тесты — убедиться что падают** + +```bash +cd /Users/a/MAI/sem2/lambda/surfaces-bot +PYTHONPATH=.worktrees/telegram pytest tests/adapter/test_forum_db.py -v +``` + +Ожидаем: `ImportError` — функции ещё не существуют. + +- [ ] **Step 3: Добавить миграцию и новые функции в `db.py`** + +В `init_db()` добавить после `CREATE TABLE IF NOT EXISTS chats`: + +```python +def init_db() -> None: + with _conn() as con: + con.executescript(""" + CREATE TABLE IF NOT EXISTS 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 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, + 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 +``` + +Добавить в конец файла: + +```python +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 +``` + +- [ ] **Step 4: Запустить тесты — убедиться что проходят** + +```bash +cd /Users/a/MAI/sem2/lambda/surfaces-bot +PYTHONPATH=.worktrees/telegram pytest tests/adapter/test_forum_db.py -v +``` + +Ожидаем: `3 passed`. + +- [ ] **Step 5: Убедиться что все тесты проекта не сломались** + +```bash +cd /Users/a/MAI/sem2/lambda/surfaces-bot +PYTHONPATH=.worktrees/telegram pytest tests/ -v +``` + +Ожидаем: все тесты `passed`. + +- [ ] **Step 6: Commit** + +```bash +cd /Users/a/MAI/sem2/lambda/surfaces-bot/.worktrees/telegram +git add adapter/telegram/db.py ../../tests/adapter/ +git commit -m "feat: db migration + forum_group_id/forum_thread_id functions" +``` + +--- + +## Task 2: ForumSetupState в states.py + +**Files:** +- Modify: `adapter/telegram/states.py` + +- [ ] **Step 1: Добавить ForumSetupState** + +```python +# adapter/telegram/states.py +from aiogram.fsm.state import State, StatesGroup + + +class ChatState(StatesGroup): + idle = State() + waiting_response = State() + + +class SettingsState(StatesGroup): + menu = State() + soul_editing = State() + confirm_action = State() + + +class ForumSetupState(StatesGroup): + waiting_for_group = State() # ждём пересылку из группы +``` + +- [ ] **Step 2: Проверить синтаксис** + +```bash +cd /Users/a/MAI/sem2/lambda/surfaces-bot +uv run python -m py_compile .worktrees/telegram/adapter/telegram/states.py && echo OK +``` + +Ожидаем: `OK`. + +- [ ] **Step 3: Commit** + +```bash +cd /Users/a/MAI/sem2/lambda/surfaces-bot/.worktrees/telegram +git add adapter/telegram/states.py +git commit -m "feat: add ForumSetupState" +``` + +--- + +## Task 3: converter.py — is_forum_message и resolve_chat_id + +**Files:** +- Modify: `adapter/telegram/converter.py` + +- [ ] **Step 1: Добавить функции в converter.py** + +Добавить в конец файла (после `format_outgoing`): + +```python +def is_forum_message(message: Message) -> bool: + """Сообщение пришло из Forum-темы (не из General и не из DM).""" + return ( + message.message_thread_id is not None + and message.chat.type in ("supergroup", "group") + ) + + +def resolve_forum_chat_id(message: Message) -> str | None: + """ + Для Forum-сообщения ищет chat_id (UUID) по forum_thread_id в БД. + Возвращает None если тема не зарегистрирована. + """ + from adapter.telegram import db + tg_user_id = message.from_user.id + thread_id = message.message_thread_id + chat = db.get_chat_by_thread(tg_user_id, thread_id) + return chat["chat_id"] if chat else None +``` + +- [ ] **Step 2: Проверить синтаксис** + +```bash +cd /Users/a/MAI/sem2/lambda/surfaces-bot +uv run python -m py_compile .worktrees/telegram/adapter/telegram/converter.py && echo OK +``` + +Ожидаем: `OK`. + +- [ ] **Step 3: Commit** + +```bash +cd /Users/a/MAI/sem2/lambda/surfaces-bot/.worktrees/telegram +git add adapter/telegram/converter.py +git commit -m "feat: add is_forum_message and resolve_forum_chat_id to converter" +``` + +--- + +## Task 4: handlers/forum.py — /forum и онбординг + +**Files:** +- Create: `adapter/telegram/handlers/forum.py` + +- [ ] **Step 1: Создать handlers/forum.py** + +```python +# adapter/telegram/handlers/forum.py +from __future__ import annotations + +from aiogram import Bot, F, Router +from aiogram.filters import Command +from aiogram.fsm.context import FSMContext +from aiogram.types import Message + +from adapter.telegram import db +from adapter.telegram.states import ChatState, ForumSetupState + +router = Router(name="forum") + + +async def _check_forum_admin(bot: Bot, group_id: int) -> bool: + """Проверяет что бот — администратор с правом управления темами.""" + try: + me = await bot.get_me() + member = await bot.get_chat_member(group_id, me.id) + return ( + member.status in ("administrator", "creator") + and getattr(member, "can_manage_topics", False) + ) + except Exception: + return False + + +@router.message(Command("forum")) +async def cmd_forum(message: Message, state: FSMContext) -> None: + await state.set_state(ForumSetupState.waiting_for_group) + await message.answer( + "📋 Подключение Forum-группы\n\n" + "1. Создай супергруппу в Telegram\n" + "2. Включи Topics: настройки группы → Topics\n" + "3. Добавь меня как администратора с правом управления темами\n" + "4. Перешли мне любое сообщение из этой группы\n\n" + "Или /cancel чтобы отменить." + ) + + +@router.message(ForumSetupState.waiting_for_group, Command("cancel")) +async def cmd_cancel_forum(message: Message, state: FSMContext) -> None: + await state.set_state(ChatState.idle) + await message.answer("❌ Настройка форума отменена.") + + +@router.message(ForumSetupState.waiting_for_group, F.forward_from_chat) +async def handle_group_forward( + message: Message, + state: FSMContext, +) -> None: + group = message.forward_from_chat + + if group.type != "supergroup": + await message.answer( + "⚠️ Это не супергруппа. Нужна именно супергруппа с включёнными Topics." + ) + return + + group_id = group.id + + if not await _check_forum_admin(message.bot, group_id): + await message.answer( + "⚠️ Не могу управлять темами в этой группе.\n\n" + "Убедись что:\n" + "• Я добавлен как администратор\n" + "• У меня есть право «Управление темами»" + ) + return + + tg_id = message.from_user.id + db.set_forum_group(tg_id, group_id) + + # Создать Forum-темы для всех существующих активных DM-чатов + chats = db.get_user_chats(tg_id) + created = 0 + for chat in chats: + if chat.get("forum_thread_id"): + continue # уже есть тема + try: + topic = await message.bot.create_forum_topic( + chat_id=group_id, + name=chat["name"], + ) + db.set_forum_thread(chat["chat_id"], topic.message_thread_id) + created += 1 + except Exception: + pass # не страшно — тему можно создать позже через /new + + await state.set_state(ChatState.idle) + await message.answer( + f"✅ Группа «{group.title}» подключена!\n" + f"Создано тем в форуме: {created} из {len(chats)}.\n\n" + "Теперь можешь писать как в DM, так и в темах форума." + ) + + +@router.message(ForumSetupState.waiting_for_group) +async def handle_forward_wrong(message: Message) -> None: + await message.answer( + "Жду пересланное сообщение из группы. " + "Перешли любое сообщение из своей супергруппы." + ) +``` + +- [ ] **Step 2: Проверить синтаксис** + +```bash +cd /Users/a/MAI/sem2/lambda/surfaces-bot +uv run python -m py_compile .worktrees/telegram/adapter/telegram/handlers/forum.py && echo OK +``` + +Ожидаем: `OK`. + +- [ ] **Step 3: Commit** + +```bash +cd /Users/a/MAI/sem2/lambda/surfaces-bot/.worktrees/telegram +git add adapter/telegram/handlers/forum.py +git commit -m "feat: add handlers/forum.py — /forum onboarding flow" +``` + +--- + +## Task 5: handlers/chat.py — Forum-маршрутизация + +**Files:** +- Modify: `adapter/telegram/handlers/chat.py` + +- [ ] **Step 1: Обновить импорты в chat.py** + +Заменить блок импортов целиком: + +```python +# adapter/telegram/handlers/chat.py +from __future__ import annotations + +import asyncio + +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 import db +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 + +router = Router(name="chat") +``` + +- [ ] **Step 2: Обновить `_send_outgoing` — добавить Forum-вариант** + +Заменить функцию `_send_outgoing`: + +```python +async def _send_outgoing( + message: Message, + chat_name: str, + events: list, + forum_group_id: int | None = None, + forum_thread_id: int | None = None, +) -> None: + for event in events: + if forum_group_id and forum_thread_id: + # Ответ в Forum-тему (без тега) + text = event.text if isinstance(event, (OutgoingMessage, OutgoingUI)) else str(event) + if isinstance(event, OutgoingUI) and event.buttons: + action_id = event.buttons[0].payload.get("action_id", "unknown") + kb = confirm_keyboard(action_id) + await message.bot.send_message( + forum_group_id, text, + message_thread_id=forum_thread_id, + reply_markup=kb, + ) + else: + await message.bot.send_message( + forum_group_id, text, + message_thread_id=forum_thread_id, + ) + else: + # Ответ в DM с тегом + if isinstance(event, OutgoingUI) and event.buttons: + action_id = event.buttons[0].payload.get("action_id", "unknown") + kb = confirm_keyboard(action_id) + await message.answer(format_outgoing(chat_name, event), reply_markup=kb) + elif isinstance(event, (OutgoingMessage, OutgoingUI)): + await message.answer(format_outgoing(chat_name, event)) +``` + +- [ ] **Step 3: Обновить `handle_message` — Forum-маршрутизация** + +Заменить функцию `handle_message`: + +```python +@router.message(ChatState.idle, (F.text | F.photo | F.document | F.voice) & ~F.text.startswith("/")) +async def handle_message( + message: Message, + state: FSMContext, + dispatcher: EventDispatcher, +) -> None: + tg_id = message.from_user.id + + # Определяем chat_id и канал ответа + if is_forum_message(message): + chat_id = resolve_forum_chat_id(message) + if not chat_id: + await message.reply( + "Эта тема не зарегистрирована как чат. " + "Введи /new в этой теме чтобы создать чат." + ) + return + chat = db.get_chat_by_thread(tg_id, message.message_thread_id) + chat_name = chat["name"] + forum_group_id = message.chat.id + forum_thread_id = message.message_thread_id + else: + data = await state.get_data() + chat_id = data.get("active_chat_id") + chat_name = data.get("active_chat_name", "Чат") + forum_group_id = None + forum_thread_id = None + + if not chat_id: + await message.answer("Нет активного чата. Введите /start") + return + + await state.set_state(ChatState.waiting_response) + + async def _typing_loop(): + while True: + await message.bot.send_chat_action(message.chat.id, "typing") + await asyncio.sleep(4) + + task = asyncio.create_task(_typing_loop()) + try: + 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) + finally: + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + + await state.set_state(ChatState.idle) + await _send_outgoing(message, chat_name, events, forum_group_id, forum_thread_id) +``` + +- [ ] **Step 4: Обновить `cmd_new_chat` — ветвление DM vs Forum** + +Заменить функцию `cmd_new_chat`: + +```python +@router.message(Command("new")) +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 + + if is_forum_message(message): + # /new в Forum-теме — регистрируем эту тему как чат + thread_id = message.message_thread_id + existing = db.get_chat_by_thread(tg_id, thread_id) + if existing: + await message.reply(f"Эта тема уже зарегистрирована как [{existing['name']}].") + 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 message.reply(f"✅ [{chat_name}] зарегистрирован. Пиши здесь!") + else: + # /new в DM + count = db.count_chats(tg_id) + chat_name = name or f"Чат #{count + 1}" + chat_id = db.create_chat(tg_id, chat_name) + + # Если есть форум-группа — создать тему и там + group_id = db.get_forum_group(tg_id) + if group_id: + try: + topic = await message.bot.create_forum_topic( + chat_id=group_id, + name=chat_name, + ) + db.set_forum_thread(chat_id, topic.message_thread_id) + except Exception: + pass # не блокирует создание DM-чата + + 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}] создан. Пиши!") +``` + +- [ ] **Step 5: Проверить синтаксис** + +```bash +cd /Users/a/MAI/sem2/lambda/surfaces-bot +uv run python -m py_compile .worktrees/telegram/adapter/telegram/handlers/chat.py && echo OK +``` + +Ожидаем: `OK`. + +- [ ] **Step 6: Запустить все тесты** + +```bash +cd /Users/a/MAI/sem2/lambda/surfaces-bot +PYTHONPATH=.worktrees/telegram pytest tests/ -v +``` + +Ожидаем: все тесты `passed`. + +- [ ] **Step 7: Commit** + +```bash +cd /Users/a/MAI/sem2/lambda/surfaces-bot/.worktrees/telegram +git add adapter/telegram/handlers/chat.py +git commit -m "feat: forum routing in handle_message and cmd_new_chat" +``` + +--- + +## Task 6: bot.py — регистрация forum.router + +**Files:** +- Modify: `adapter/telegram/bot.py` + +- [ ] **Step 1: Добавить импорт и регистрацию router** + +В блоке импортов добавить: + +```python +from adapter.telegram.handlers import auth, chat, confirm, forum, settings +``` + +В `main()` после `dp.include_router(auth.router)`: + +```python + 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) +``` + +- [ ] **Step 2: Проверить синтаксис** + +```bash +cd /Users/a/MAI/sem2/lambda/surfaces-bot +uv run python -m py_compile .worktrees/telegram/adapter/telegram/bot.py && echo OK +``` + +Ожидаем: `OK`. + +- [ ] **Step 3: Финальный прогон всех тестов** + +```bash +cd /Users/a/MAI/sem2/lambda/surfaces-bot +PYTHONPATH=.worktrees/telegram pytest tests/ -v +``` + +Ожидаем: все тесты `passed`. + +- [ ] **Step 4: Commit** + +```bash +cd /Users/a/MAI/sem2/lambda/surfaces-bot/.worktrees/telegram +git add adapter/telegram/bot.py +git commit -m "feat: register forum router in bot.py" +```