22 KiB
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"