docs: Forum Topics implementation plan

This commit is contained in:
Mikhail Putilovskij 2026-03-31 23:02:56 +03:00
parent a8885aeaa1
commit bcdaea5143

View file

@ -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"
```