surfaces/docs/superpowers/plans/2026-03-31-forum-topics.md

22 KiB
Raw Blame History

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: Создать тест-файл и написать падающие тесты

# tests/adapter/__init__.py
# (пустой файл)
# 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: Запустить тесты — убедиться что падают
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:

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

Добавить в конец файла:

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: Запустить тесты — убедиться что проходят
cd /Users/a/MAI/sem2/lambda/surfaces-bot
PYTHONPATH=.worktrees/telegram pytest tests/adapter/test_forum_db.py -v

Ожидаем: 3 passed.

  • Step 5: Убедиться что все тесты проекта не сломались
cd /Users/a/MAI/sem2/lambda/surfaces-bot
PYTHONPATH=.worktrees/telegram pytest tests/ -v

Ожидаем: все тесты passed.

  • Step 6: Commit
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

# 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: Проверить синтаксис
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
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):

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: Проверить синтаксис
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
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

# 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: Проверить синтаксис
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
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

Заменить блок импортов целиком:

# 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:

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:

@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:

@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: Проверить синтаксис
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: Запустить все тесты
cd /Users/a/MAI/sem2/lambda/surfaces-bot
PYTHONPATH=.worktrees/telegram pytest tests/ -v

Ожидаем: все тесты passed.

  • Step 7: Commit
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

В блоке импортов добавить:

from adapter.telegram.handlers import auth, chat, confirm, forum, settings

В main() после dp.include_router(auth.router):

    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: Проверить синтаксис
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: Финальный прогон всех тестов
cd /Users/a/MAI/sem2/lambda/surfaces-bot
PYTHONPATH=.worktrees/telegram pytest tests/ -v

Ожидаем: все тесты passed.

  • Step 4: Commit
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"