surfaces/docs/superpowers/plans/2026-04-01-telegram-forum-redesign.md

39 KiB
Raw Blame History

Telegram Forum Redesign 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: Rewrite the Telegram adapter to use Bot API 9.3 Threaded Mode — private chat becomes a forum, each topic is an isolated agent context, no supergroup required.

Architecture: New branch feat/telegram-forum from main. Cherry-pick keyboards/settings.py and keyboards/confirm.py from feat/telegram-adapter. Everything else is written from scratch using (user_id, thread_id) as the context key, core/store.py for state (no aiogram FSM for topic routing), and sdk/interface.py's stream_message() for streaming responses.

Tech Stack: Python 3.11+, aiogram 3.4+, SQLite (via stdlib sqlite3), pytest + pytest-asyncio (asyncio_mode=auto), sdk.mock.MockPlatformClient as platform stub.

Spec: docs/superpowers/specs/2026-04-01-telegram-forum-redesign.md


File Map

File Action Notes
adapter/telegram/db.py Rewrite New schema: chats(user_id, thread_id PK, ...)
adapter/telegram/converter.py Rewrite context_key = (user_id, thread_id), keep _extract_attachments
adapter/telegram/handlers/start.py New /start — create first topic, health-check existing ones
adapter/telegram/handlers/topic_events.py New forum_topic_created / edited / closed
adapter/telegram/handlers/commands.py New /new, /archive, /rename, /settings
adapter/telegram/handlers/message.py New Incoming messages with streaming
adapter/telegram/handlers/settings.py Cherry-pick + adapt Drop FSM state dependency for topic routing; keep SettingsState for soul modal
adapter/telegram/keyboards/settings.py Cherry-pick No changes needed
adapter/telegram/keyboards/confirm.py Cherry-pick No changes needed
adapter/telegram/states.py Minimal Only SettingsState (soul editing modal), no topic FSM
adapter/telegram/bot.py Rewrite New router list, same middleware pattern
adapter/telegram/__init__.py Keep No changes
tests/adapter/test_forum_db.py Rewrite Tests for new schema
tests/adapter/telegram/test_converter.py New
tests/adapter/telegram/test_topic_events.py New
tests/adapter/telegram/test_commands.py New

Delete from feat/telegram-adapter (do not carry over):

  • adapter/telegram/handlers/forum.py — supergroup onboarding
  • adapter/telegram/handlers/chat.py — chat switching
  • adapter/telegram/handlers/auth.py — auth flow
  • adapter/telegram/handlers/confirm.py — confirm modal
  • adapter/telegram/keyboards/chat.py
  • adapter/telegram/keyboards/forum.py

Task 0: Create Branch and Cherry-Pick Keyboards

Files:

  • Create branch: feat/telegram-forum

  • Cherry-pick: adapter/telegram/keyboards/settings.py

  • Cherry-pick: adapter/telegram/keyboards/confirm.py

  • Step 1: Create new branch from main

git checkout main
git checkout -b feat/telegram-forum
  • Step 2: Copy keyboards from feat/telegram-adapter
mkdir -p adapter/telegram/keyboards
git show feat/telegram-adapter:adapter/telegram/keyboards/__init__.py > adapter/telegram/keyboards/__init__.py
git show feat/telegram-adapter:adapter/telegram/keyboards/settings.py > adapter/telegram/keyboards/settings.py
git show feat/telegram-adapter:adapter/telegram/keyboards/confirm.py > adapter/telegram/keyboards/confirm.py
  • Step 3: Create package stubs
mkdir -p adapter/telegram/handlers
touch adapter/__init__.py
touch adapter/telegram/__init__.py
touch adapter/telegram/handlers/__init__.py
  • Step 4: Verify keyboards import cleanly
python -c "from adapter.telegram.keyboards.settings import settings_main_keyboard; print('ok')"

Expected: ok

  • Step 5: Commit
git add adapter/
git commit -m "chore: init feat/telegram-forum, cherry-pick keyboards"

Task 1: Database Layer

Files:

  • Create: adapter/telegram/db.py

  • Rewrite: tests/adapter/test_forum_db.py

  • Step 1: Write failing tests

Write tests/adapter/test_forum_db.py:

from __future__ import annotations

import importlib
import pytest


@pytest.fixture(autouse=True)
def fresh_db(tmp_path, monkeypatch):
    monkeypatch.setenv("DB_PATH", str(tmp_path / "test.db"))
    import adapter.telegram.db as db_mod
    importlib.reload(db_mod)
    db_mod.init_db()
    return db_mod


def test_create_and_get_chat(fresh_db):
    db = fresh_db
    db.create_chat(user_id=1, thread_id=100, chat_name="Чат #1")
    chat = db.get_chat(user_id=1, thread_id=100)
    assert chat is not None
    assert chat["chat_name"] == "Чат #1"
    assert chat["archived_at"] is None


def test_get_chat_missing(fresh_db):
    assert fresh_db.get_chat(user_id=1, thread_id=999) is None


def test_archive_chat(fresh_db):
    db = fresh_db
    db.create_chat(1, 100, "Чат #1")
    db.archive_chat(1, 100)
    chat = db.get_chat(1, 100)
    assert chat["archived_at"] is not None


def test_rename_chat(fresh_db):
    db = fresh_db
    db.create_chat(1, 100, "Чат #1")
    db.rename_chat(1, 100, "Новое имя")
    assert db.get_chat(1, 100)["chat_name"] == "Новое имя"


def test_get_active_chats(fresh_db):
    db = fresh_db
    db.create_chat(1, 100, "Чат #1")
    db.create_chat(1, 200, "Чат #2")
    db.archive_chat(1, 100)
    chats = db.get_active_chats(1)
    assert len(chats) == 1
    assert chats[0]["thread_id"] == 200


def test_display_number(fresh_db):
    db = fresh_db
    db.create_chat(1, 100, "Чат #1")
    db.create_chat(1, 200, "Чат #2")
    db.create_chat(1, 300, "Чат #3")
    assert db.get_display_number(1, 100) == 1
    assert db.get_display_number(1, 200) == 2
    assert db.get_display_number(1, 300) == 3


def test_count_active_chats(fresh_db):
    db = fresh_db
    db.create_chat(1, 100, "Чат #1")
    db.create_chat(1, 200, "Чат #2")
    db.archive_chat(1, 100)
    assert db.count_active_chats(1) == 1


def test_different_users_isolated(fresh_db):
    db = fresh_db
    db.create_chat(1, 100, "Чат #1")
    db.create_chat(2, 100, "Чат #1")  # same thread_id, different user
    assert db.get_chat(1, 100)["chat_name"] == "Чат #1"
    assert db.get_chat(2, 100)["chat_name"] == "Чат #1"
    db.archive_chat(1, 100)
    assert db.get_chat(1, 100)["archived_at"] is not None
    assert db.get_chat(2, 100)["archived_at"] is None
  • Step 2: Run tests — verify they fail
pytest tests/adapter/test_forum_db.py -v

Expected: ModuleNotFoundError or AttributeError (db.py doesn't exist yet)

  • Step 3: Implement db.py

Create adapter/telegram/db.py:

from __future__ import annotations

import os
import sqlite3
from contextlib import contextmanager

DB_PATH = os.environ.get("DB_PATH", "lambda_bot.db")


@contextmanager
def _conn():
    con = sqlite3.connect(DB_PATH)
    con.row_factory = sqlite3.Row
    try:
        yield con
        con.commit()
    finally:
        con.close()


def init_db() -> None:
    with _conn() as con:
        con.executescript("""
            CREATE TABLE IF NOT EXISTS chats (
                user_id     INTEGER  NOT NULL,
                thread_id   INTEGER  NOT NULL,
                chat_name   TEXT     NOT NULL DEFAULT 'Чат #1',
                archived_at DATETIME,
                created_at  DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
                PRIMARY KEY (user_id, thread_id)
            );
        """)


def create_chat(user_id: int, thread_id: int, chat_name: str) -> None:
    with _conn() as con:
        con.execute(
            "INSERT OR IGNORE INTO chats (user_id, thread_id, chat_name) VALUES (?, ?, ?)",
            (user_id, thread_id, chat_name),
        )


def get_chat(user_id: int, thread_id: int) -> dict | None:
    with _conn() as con:
        row = con.execute(
            "SELECT * FROM chats WHERE user_id = ? AND thread_id = ?",
            (user_id, thread_id),
        ).fetchone()
        return dict(row) if row else None


def get_active_chats(user_id: int) -> list[dict]:
    with _conn() as con:
        rows = con.execute(
            "SELECT * FROM chats WHERE user_id = ? AND archived_at IS NULL "
            "ORDER BY created_at ASC",
            (user_id,),
        ).fetchall()
        return [dict(r) for r in rows]


def count_active_chats(user_id: int) -> int:
    with _conn() as con:
        row = con.execute(
            "SELECT COUNT(*) FROM chats WHERE user_id = ? AND archived_at IS NULL",
            (user_id,),
        ).fetchone()
        return row[0]


def archive_chat(user_id: int, thread_id: int) -> None:
    with _conn() as con:
        con.execute(
            "UPDATE chats SET archived_at = CURRENT_TIMESTAMP "
            "WHERE user_id = ? AND thread_id = ?",
            (user_id, thread_id),
        )


def rename_chat(user_id: int, thread_id: int, new_name: str) -> None:
    with _conn() as con:
        con.execute(
            "UPDATE chats SET chat_name = ? WHERE user_id = ? AND thread_id = ?",
            (new_name, user_id, thread_id),
        )


def get_display_number(user_id: int, thread_id: int) -> int:
    """Return 1-based display number for a chat (by creation order)."""
    with _conn() as con:
        row = con.execute(
            """
            SELECT rn FROM (
                SELECT thread_id,
                       ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY created_at) AS rn
                FROM chats
                WHERE user_id = ?
            ) WHERE thread_id = ?
            """,
            (user_id, thread_id),
        ).fetchone()
        return row[0] if row else 1
  • Step 4: Run tests — verify they pass
pytest tests/adapter/test_forum_db.py -v

Expected: all 8 tests pass

  • Step 5: Commit
git add adapter/telegram/db.py tests/adapter/test_forum_db.py
git commit -m "feat(tg): new db schema — (user_id, thread_id) PK"

Task 2: Converter

Files:

  • Create: adapter/telegram/converter.py

  • Create: tests/adapter/telegram/test_converter.py

  • Step 1: Write failing tests

Create tests/adapter/telegram/test_converter.py:

from __future__ import annotations

from types import SimpleNamespace

from adapter.telegram.converter import from_message, format_outgoing
from core.protocol import OutgoingMessage, OutgoingUI


def make_message(*, text="hello", thread_id=42, user_id=1):
    m = SimpleNamespace()
    m.text = text
    m.caption = None
    m.photo = None
    m.document = None
    m.voice = None
    m.message_thread_id = thread_id
    m.from_user = SimpleNamespace(id=user_id, full_name="Alice")
    return m


def test_from_message_in_topic():
    msg = make_message(thread_id=42, user_id=7)
    result = from_message(msg)
    assert result is not None
    assert result.user_id == "7"
    assert result.chat_id == "42"
    assert result.text == "hello"
    assert result.platform == "telegram"


def test_from_message_in_general_returns_none():
    msg = make_message(thread_id=None)
    assert from_message(msg) is None


def test_from_message_uses_caption_if_no_text():
    msg = make_message(text=None, thread_id=10)
    msg.caption = "caption text"
    result = from_message(msg)
    assert result.text == "caption text"


def test_format_outgoing_message():
    event = OutgoingMessage(chat_id="42", text="response")
    assert format_outgoing(event) == "response"


def test_format_outgoing_ui():
    event = OutgoingUI(chat_id="42", text="choose")
    assert format_outgoing(event) == "choose"
  • Step 2: Run tests — verify they fail
pytest tests/adapter/telegram/test_converter.py -v

Expected: ModuleNotFoundError

  • Step 3: Implement converter.py

Create adapter/telegram/converter.py:

from __future__ import annotations

from aiogram.types import Message

from core.protocol import Attachment, IncomingMessage, OutgoingEvent, OutgoingMessage, OutgoingUI


def from_message(message: Message) -> IncomingMessage | None:
    """Convert aiogram Message to IncomingMessage. Returns None for General topic."""
    thread_id = message.message_thread_id
    if thread_id is None:
        return None
    return IncomingMessage(
        user_id=str(message.from_user.id),
        chat_id=str(thread_id),
        text=message.text or message.caption or "",
        attachments=_extract_attachments(message),
        platform="telegram",
    )


def _extract_attachments(message: Message) -> list[Attachment]:
    attachments: list[Attachment] = []
    if message.photo:
        file = message.photo[-1]
        attachments.append(Attachment(
            type="image",
            url=f"tg://file/{file.file_id}",
            mime_type="image/jpeg",
        ))
    if message.document:
        attachments.append(Attachment(
            type="document",
            url=f"tg://file/{message.document.file_id}",
            mime_type=message.document.mime_type or "application/octet-stream",
            filename=message.document.file_name,
        ))
    if message.voice:
        attachments.append(Attachment(
            type="audio",
            url=f"tg://file/{message.voice.file_id}",
            mime_type="audio/ogg",
        ))
    return attachments


def format_outgoing(event: OutgoingEvent) -> str:
    """Extract text from an outgoing event for sending to Telegram."""
    if isinstance(event, (OutgoingMessage, OutgoingUI)):
        return event.text
    return str(event)
  • Step 4: Run tests — verify they pass
pytest tests/adapter/telegram/test_converter.py -v

Expected: all 5 tests pass

  • Step 5: Commit
git add adapter/telegram/converter.py tests/adapter/telegram/test_converter.py
git commit -m "feat(tg): converter — context_key=(user_id, thread_id)"

Task 3: Topic Event Handlers

Files:

  • Create: adapter/telegram/handlers/topic_events.py

  • Create: tests/adapter/telegram/test_topic_events.py

  • Step 1: Write failing tests

Create tests/adapter/telegram/test_topic_events.py:

from __future__ import annotations

import importlib
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch

import pytest


@pytest.fixture(autouse=True)
def fresh_db(tmp_path, monkeypatch):
    monkeypatch.setenv("DB_PATH", str(tmp_path / "test.db"))
    import adapter.telegram.db as db_mod
    importlib.reload(db_mod)
    db_mod.init_db()
    return db_mod


def make_service_message(*, user_id=1, thread_id=42, chat_id=1):
    m = SimpleNamespace()
    m.message_thread_id = thread_id
    m.from_user = SimpleNamespace(id=user_id, full_name="Alice")
    m.chat = SimpleNamespace(id=chat_id)
    m.forum_topic_created = SimpleNamespace(name="Мой чат")
    m.forum_topic_edited = SimpleNamespace(name="Новое имя")
    m.forum_topic_closed = SimpleNamespace()
    m.answer = AsyncMock()
    return m


async def test_on_topic_created_registers_chat(fresh_db, monkeypatch):
    from adapter.telegram.handlers.topic_events import on_topic_created
    msg = make_service_message(user_id=5, thread_id=99)
    await on_topic_created(msg)
    chat = fresh_db.get_chat(5, 99)
    assert chat is not None
    assert chat["chat_name"] == "Мой чат"


async def test_on_topic_edited_renames_chat(fresh_db, monkeypatch):
    from adapter.telegram.handlers.topic_events import on_topic_edited
    fresh_db.create_chat(5, 99, "Старое имя")
    msg = make_service_message(user_id=5, thread_id=99)
    await on_topic_edited(msg)
    assert fresh_db.get_chat(5, 99)["chat_name"] == "Новое имя"


async def test_on_topic_edited_unknown_chat_is_noop(fresh_db):
    from adapter.telegram.handlers.topic_events import on_topic_edited
    msg = make_service_message(user_id=5, thread_id=999)
    await on_topic_edited(msg)  # should not raise


async def test_on_topic_closed_archives_chat(fresh_db):
    from adapter.telegram.handlers.topic_events import on_topic_closed
    fresh_db.create_chat(5, 99, "Чат #1")
    msg = make_service_message(user_id=5, thread_id=99)
    await on_topic_closed(msg)
    assert fresh_db.get_chat(5, 99)["archived_at"] is not None


async def test_on_topic_closed_unknown_chat_is_noop(fresh_db):
    from adapter.telegram.handlers.topic_events import on_topic_closed
    msg = make_service_message(user_id=5, thread_id=999)
    await on_topic_closed(msg)  # should not raise
  • Step 2: Run tests — verify they fail
pytest tests/adapter/telegram/test_topic_events.py -v

Expected: ModuleNotFoundError

  • Step 3: Implement topic_events.py

Create adapter/telegram/handlers/topic_events.py:

from __future__ import annotations

import structlog
from aiogram import F, Router
from aiogram.types import Message

from adapter.telegram import db

logger = structlog.get_logger(__name__)

router = Router(name="topic_events")


@router.message(F.forum_topic_created)
async def on_topic_created(message: Message) -> None:
    """User created a topic via Telegram UI — register it as a new chat."""
    user_id = message.from_user.id
    thread_id = message.message_thread_id
    name = message.forum_topic_created.name
    db.create_chat(user_id=user_id, thread_id=thread_id, chat_name=name)
    logger.info("topic_created", user_id=user_id, thread_id=thread_id, name=name)


@router.message(F.forum_topic_edited)
async def on_topic_edited(message: Message) -> None:
    """User renamed a topic via Telegram UI — sync chat_name in DB."""
    user_id = message.from_user.id
    thread_id = message.message_thread_id
    new_name = message.forum_topic_edited.name
    existing = db.get_chat(user_id=user_id, thread_id=thread_id)
    if existing is None:
        return
    db.rename_chat(user_id=user_id, thread_id=thread_id, new_name=new_name)
    logger.info("topic_renamed", user_id=user_id, thread_id=thread_id, new_name=new_name)


@router.message(F.forum_topic_closed)
async def on_topic_closed(message: Message) -> None:
    """User closed a topic via Telegram UI — auto-archive the chat."""
    user_id = message.from_user.id
    thread_id = message.message_thread_id
    existing = db.get_chat(user_id=user_id, thread_id=thread_id)
    if existing is None:
        return
    db.archive_chat(user_id=user_id, thread_id=thread_id)
    logger.info("topic_closed_archived", user_id=user_id, thread_id=thread_id)
  • Step 4: Run tests — verify they pass
pytest tests/adapter/telegram/test_topic_events.py -v

Expected: all 5 tests pass

  • Step 5: Commit
git add adapter/telegram/handlers/topic_events.py tests/adapter/telegram/test_topic_events.py
git commit -m "feat(tg): handle forum_topic_created/edited/closed events"

Task 4: Command Handlers

Files:

  • Create: adapter/telegram/handlers/commands.py

  • Create: tests/adapter/telegram/test_commands.py

  • Step 1: Write failing tests

Create tests/adapter/telegram/test_commands.py:

from __future__ import annotations

import importlib
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock

import pytest


@pytest.fixture(autouse=True)
def fresh_db(tmp_path, monkeypatch):
    monkeypatch.setenv("DB_PATH", str(tmp_path / "test.db"))
    import adapter.telegram.db as db_mod
    importlib.reload(db_mod)
    db_mod.init_db()
    return db_mod


def make_message(*, user_id=1, thread_id=42, chat_id=1, args=None):
    m = SimpleNamespace()
    m.from_user = SimpleNamespace(id=user_id, full_name="Alice")
    m.message_thread_id = thread_id
    m.chat = SimpleNamespace(id=chat_id)
    m.answer = AsyncMock()
    m.reply = AsyncMock()
    m.bot = MagicMock()
    m.bot.create_forum_topic = AsyncMock(
        return_value=SimpleNamespace(message_thread_id=200)
    )
    m.bot.close_forum_topic = AsyncMock()
    m.bot.edit_forum_topic = AsyncMock()
    m.bot.send_message = AsyncMock()
    return m


async def test_cmd_new_creates_topic(fresh_db):
    from adapter.telegram.handlers.commands import cmd_new
    msg = make_message(user_id=1, thread_id=42, chat_id=100)
    fresh_db.create_chat(1, 42, "Чат #1")  # 1 existing chat
    await cmd_new(msg)
    msg.bot.create_forum_topic.assert_called_once()
    call_kwargs = msg.bot.create_forum_topic.call_args
    assert "Чат #2" in str(call_kwargs)
    new_chat = fresh_db.get_chat(1, 200)
    assert new_chat is not None
    assert new_chat["chat_name"] == "Чат #2"


async def test_cmd_archive_closes_and_archives(fresh_db):
    from adapter.telegram.handlers.commands import cmd_archive
    fresh_db.create_chat(1, 42, "Чат #1")
    msg = make_message(user_id=1, thread_id=42, chat_id=100)
    await cmd_archive(msg)
    msg.bot.close_forum_topic.assert_called_once_with(
        chat_id=100, message_thread_id=42
    )
    assert fresh_db.get_chat(1, 42)["archived_at"] is not None


async def test_cmd_archive_unknown_topic_replies_error(fresh_db):
    from adapter.telegram.handlers.commands import cmd_archive
    msg = make_message(user_id=1, thread_id=999, chat_id=100)
    await cmd_archive(msg)
    msg.answer.assert_called_once()
    assert "не найден" in msg.answer.call_args[0][0].lower() or \
           "not found" in msg.answer.call_args[0][0].lower() or \
           len(msg.answer.call_args[0][0]) > 0  # some error message


async def test_cmd_rename_updates_db_and_topic(fresh_db):
    from adapter.telegram.handlers.commands import cmd_rename
    fresh_db.create_chat(1, 42, "Чат #1")
    msg = make_message(user_id=1, thread_id=42, chat_id=100)
    await cmd_rename(msg, new_name="Работа")
    msg.bot.edit_forum_topic.assert_called_once_with(
        chat_id=100, message_thread_id=42, name="Работа"
    )
    assert fresh_db.get_chat(1, 42)["chat_name"] == "Работа"
  • Step 2: Run tests — verify they fail
pytest tests/adapter/telegram/test_commands.py -v

Expected: ModuleNotFoundError

  • Step 3: Implement commands.py

Create adapter/telegram/handlers/commands.py:

from __future__ import annotations

import structlog
from aiogram import Router
from aiogram.filters import Command
from aiogram.types import Message

from adapter.telegram import db
from adapter.telegram.keyboards.settings import settings_main_keyboard

logger = structlog.get_logger(__name__)

router = Router(name="commands")


@router.message(Command("new"))
async def cmd_new(message: Message) -> None:
    """Create a new topic and register it as a new chat."""
    user_id = message.from_user.id
    chat_id = message.chat.id
    n = db.count_active_chats(user_id) + 1
    new_name = f"Чат #{n}"
    topic = await message.bot.create_forum_topic(chat_id=chat_id, name=new_name)
    thread_id = topic.message_thread_id
    db.create_chat(user_id=user_id, thread_id=thread_id, chat_name=new_name)
    await message.bot.send_message(
        chat_id=chat_id,
        message_thread_id=thread_id,
        text=f"Создан {new_name}. Напиши что-нибудь.",
    )
    logger.info("cmd_new", user_id=user_id, thread_id=thread_id, name=new_name)


@router.message(Command("archive"))
async def cmd_archive(message: Message) -> None:
    """Archive the current topic."""
    user_id = message.from_user.id
    thread_id = message.message_thread_id
    chat = db.get_chat(user_id=user_id, thread_id=thread_id)
    if chat is None or chat["archived_at"] is not None:
        await message.answer("Этот чат не найден или уже архивирован.")
        return
    await message.bot.close_forum_topic(
        chat_id=message.chat.id, message_thread_id=thread_id
    )
    db.archive_chat(user_id=user_id, thread_id=thread_id)
    logger.info("cmd_archive", user_id=user_id, thread_id=thread_id)


@router.message(Command("rename"))
async def cmd_rename(message: Message, new_name: str = "") -> None:
    """Rename the current topic. Usage: /rename New Name"""
    user_id = message.from_user.id
    thread_id = message.message_thread_id
    if not new_name:
        # Parse from message text: /rename New Name
        parts = (message.text or "").split(maxsplit=1)
        new_name = parts[1].strip() if len(parts) > 1 else ""
    if not new_name:
        await message.answer("Использование: /rename Новое название")
        return
    chat = db.get_chat(user_id=user_id, thread_id=thread_id)
    if chat is None:
        await message.answer("Этот чат не найден.")
        return
    await message.bot.edit_forum_topic(
        chat_id=message.chat.id,
        message_thread_id=thread_id,
        name=new_name[:128],
    )
    db.rename_chat(user_id=user_id, thread_id=thread_id, new_name=new_name[:128])
    logger.info("cmd_rename", user_id=user_id, thread_id=thread_id, new_name=new_name)


@router.message(Command("settings"))
async def cmd_settings(message: Message) -> None:
    """Open settings menu."""
    await message.answer("⚙️ Настройки", reply_markup=settings_main_keyboard())
  • Step 4: Run tests — verify they pass
pytest tests/adapter/telegram/test_commands.py -v

Expected: all 4 tests pass

  • Step 5: Commit
git add adapter/telegram/handlers/commands.py tests/adapter/telegram/test_commands.py
git commit -m "feat(tg): command handlers — /new /archive /rename /settings"

Task 5: /start Handler

Files:

  • Create: adapter/telegram/handlers/start.py

No separate test file — behaviour is verified via integration in Task 7. Unit testing /start requires heavy bot mocking; the key logic (stale topic detection) is thin enough to verify manually.

  • Step 1: Implement start.py

Create adapter/telegram/handlers/start.py:

from __future__ import annotations

import structlog
from aiogram import Router
from aiogram.exceptions import TelegramBadRequest
from aiogram.filters import Command, CommandStart
from aiogram.types import Message

from adapter.telegram import db

logger = structlog.get_logger(__name__)

router = Router(name="start")


@router.message(CommandStart())
async def cmd_start(message: Message) -> None:
    """
    Bootstrap the user's forum.

    First visit: create Чат #1, hide General topic.
    Returning visit: health-check all active topics, archive stale ones.
    """
    user_id = message.from_user.id
    chat_id = message.chat.id

    # Health-check existing topics — archive any that Telegram no longer knows about
    await _check_and_prune_stale_topics(message, user_id, chat_id)

    active = db.get_active_chats(user_id)

    if not active:
        # First visit or all topics were pruned — create the first one
        try:
            topic = await message.bot.create_forum_topic(
                chat_id=chat_id, name="Чат #1"
            )
            thread_id = topic.message_thread_id
            db.create_chat(user_id=user_id, thread_id=thread_id, chat_name="Чат #1")
            logger.info("start_created_first_topic", user_id=user_id, thread_id=thread_id)
        except TelegramBadRequest as e:
            if "not modified" not in str(e).lower():
                logger.warning("start_create_topic_failed", error=str(e))
                await message.answer(
                    "Не удалось создать топик. Убедись, что в @BotFather включён "
                    "Threaded Mode для этого бота."
                )
                return

        # Hide General topic so it doesn't distract
        try:
            await message.bot.hide_general_forum_topic(chat_id=chat_id)
        except TelegramBadRequest:
            pass  # Not critical — may not be available in all API versions

        await message.answer(
            "Привет! Это твоё личное пространство с AI-агентом Lambda. "
            "Каждый топик — отдельный контекст. Напиши что-нибудь."
        )
    else:
        await message.answer(
            f"Снова привет! У тебя {len(active)} активных чатов. "
            "Напиши /new чтобы создать новый."
        )


async def _check_and_prune_stale_topics(
    message: Message, user_id: int, chat_id: int
) -> None:
    """
    Send typing action to each active topic.
    If Telegram returns an error — the topic was deleted; archive it.
    """
    active = db.get_active_chats(user_id)
    for chat in active:
        thread_id = chat["thread_id"]
        try:
            await message.bot.send_chat_action(
                chat_id=chat_id,
                action="typing",
                message_thread_id=thread_id,
            )
        except TelegramBadRequest:
            db.archive_chat(user_id=user_id, thread_id=thread_id)
            logger.info("pruned_stale_topic", user_id=user_id, thread_id=thread_id)
  • Step 2: Verify it imports cleanly
python -c "from adapter.telegram.handlers.start import router; print('ok')"

Expected: ok

  • Step 3: Commit
git add adapter/telegram/handlers/start.py
git commit -m "feat(tg): /start handler with topic bootstrap and stale-topic pruning"

Task 6: Message Handler with Streaming

Files:

  • Create: adapter/telegram/handlers/message.py

  • Step 1: Implement message.py

Create adapter/telegram/handlers/message.py:

from __future__ import annotations

import asyncio
import time

import structlog
from aiogram import F, Router
from aiogram.exceptions import TelegramBadRequest
from aiogram.types import Message

from adapter.telegram import converter, db
from core.handler import EventDispatcher

logger = structlog.get_logger(__name__)

router = Router(name="message")

STREAM_EDIT_INTERVAL = 1.5   # seconds between edit_text calls
STREAM_MIN_DELTA = 100        # minimum new chars before editing
TELEGRAM_MAX_LEN = 4096


@router.message(F.text & F.message_thread_id)
async def handle_topic_message(message: Message, dispatcher: EventDispatcher) -> None:
    """Route a text message in a topic to the platform and stream the response."""
    user_id = message.from_user.id
    thread_id = message.message_thread_id

    chat = db.get_chat(user_id=user_id, thread_id=thread_id)
    if chat is None or chat["archived_at"] is not None:
        # Unregistered or archived topic — silently ignore
        return

    incoming = converter.from_message(message)
    if incoming is None:
        return

    platform_user = await dispatcher._platform.get_or_create_user(
        external_id=str(user_id),
        platform="telegram",
        display_name=message.from_user.full_name,
    )

    placeholder = await message.reply("...")

    accumulated = ""
    last_edit_time = 0.0
    last_edit_len = 0

    try:
        async for chunk in dispatcher._platform.stream_message(
            user_id=platform_user.user_id,
            chat_id=str(thread_id),
            text=incoming.text,
            attachments=None,
        ):
            accumulated += chunk.delta
            now = time.monotonic()
            delta = len(accumulated) - last_edit_len
            if delta >= STREAM_MIN_DELTA and (now - last_edit_time) >= STREAM_EDIT_INTERVAL:
                await _safe_edit(placeholder, accumulated)
                last_edit_time = now
                last_edit_len = len(accumulated)

        # Final edit with complete response
        await _safe_edit(placeholder, accumulated or "...")

    except TelegramBadRequest as e:
        if "thread not found" in str(e).lower():
            db.archive_chat(user_id=user_id, thread_id=thread_id)
            logger.warning("topic_deleted_during_message", thread_id=thread_id)
        else:
            logger.error("telegram_error", error=str(e))
    except Exception:
        logger.exception("platform_error", user_id=user_id, thread_id=thread_id)
        await _safe_edit(placeholder, "Сервис временно недоступен, попробуй позже")


async def _safe_edit(message: Message, text: str) -> None:
    """Edit message text, truncating to Telegram limit. Swallows 'not modified'."""
    truncated = text[:TELEGRAM_MAX_LEN]
    try:
        await message.edit_text(truncated)
    except TelegramBadRequest as e:
        if "not modified" not in str(e).lower():
            raise
  • Step 2: Verify it imports cleanly
python -c "from adapter.telegram.handlers.message import router; print('ok')"

Expected: ok

  • Step 3: Commit
git add adapter/telegram/handlers/message.py
git commit -m "feat(tg): message handler with streaming via sdk.stream_message"

Task 7: Settings Handler (Cherry-Pick + Adapt)

Files:

  • Create: adapter/telegram/states.py
  • Create: adapter/telegram/handlers/settings.py

The settings handler from feat/telegram-adapter already works well. We adapt it to drop db.get_or_create_tg_user (no longer needed — platform resolves users by str(tg_id)) and remove topic-FSM dependency.

  • Step 1: Create states.py (SettingsState only)

Create adapter/telegram/states.py:

from __future__ import annotations

from aiogram.fsm.state import State, StatesGroup


class SettingsState(StatesGroup):
    menu = State()
    soul_editing = State()
  • Step 2: Cherry-pick settings handler
git show feat/telegram-adapter:adapter/telegram/handlers/settings.py > adapter/telegram/handlers/settings.py
  • Step 3: Patch settings handler — remove get_or_create_tg_user calls

In adapter/telegram/handlers/settings.py, replace all blocks that call db.get_or_create_tg_user with a direct string cast. Find every occurrence of:

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)
platform_user_id = tg_user.get("platform_user_id", str(tg_id))

Replace with:

platform_user_id = str(callback.from_user.id)

And for message handlers (soul editing), replace the analogous block with:

platform_user_id = str(message.from_user.id)

Also remove the import of ChatState from adapter.telegram.states — it no longer exists: Find: from adapter.telegram.states import ChatState, SettingsState Replace: from adapter.telegram.states import SettingsState

  • Step 4: Verify settings handler imports cleanly
python -c "from adapter.telegram.handlers.settings import router; print('ok')"

Expected: ok

  • Step 5: Commit
git add adapter/telegram/states.py adapter/telegram/handlers/settings.py
git commit -m "feat(tg): cherry-pick settings handler, drop get_or_create_tg_user"

Task 8: Wire Everything in bot.py

Files:

  • Create: adapter/telegram/bot.py

  • Step 1: Implement bot.py

Create adapter/telegram/bot.py:

from __future__ import annotations

import asyncio
import os

import structlog
from aiogram import Bot, Dispatcher
from aiogram.fsm.storage.memory import MemoryStorage
from aiogram.types import BotCommand

from adapter.telegram import db
from adapter.telegram.handlers import commands, message, settings, start, topic_events
from core.auth import AuthManager
from core.chat import ChatManager
from core.handler import EventDispatcher
from core.settings import SettingsManager
from core.store import InMemoryStore
from sdk.mock import MockPlatformClient

logger = structlog.get_logger(__name__)


class PlatformMiddleware:
    """Injects EventDispatcher (with platform inside) into every handler."""

    def __init__(self, dispatcher: EventDispatcher) -> None:
        self._dispatcher = dispatcher

    async def __call__(self, handler, event, data):
        data["dispatcher"] = self._dispatcher
        return await handler(event, data)


def build_event_dispatcher() -> EventDispatcher:
    platform = MockPlatformClient()
    store = InMemoryStore()
    chat_mgr = ChatManager(platform, store)
    auth_mgr = AuthManager(platform, store)
    settings_mgr = SettingsManager(platform, store)
    return EventDispatcher(
        platform=platform,
        chat_mgr=chat_mgr,
        auth_mgr=auth_mgr,
        settings_mgr=settings_mgr,
    )


async def main() -> None:
    token = os.environ.get("BOT_TOKEN")
    if not token:
        raise RuntimeError("BOT_TOKEN env variable is not set")

    db.init_db()

    bot = Bot(token=token)
    storage = MemoryStorage()
    dp = Dispatcher(storage=storage)

    event_dispatcher = build_event_dispatcher()

    dp.message.middleware(PlatformMiddleware(event_dispatcher))
    dp.callback_query.middleware(PlatformMiddleware(event_dispatcher))

    # Register routers — order matters (most specific first)
    dp.include_router(topic_events.router)   # service messages
    dp.include_router(start.router)          # /start
    dp.include_router(commands.router)       # /new /archive /rename /settings
    dp.include_router(settings.router)       # settings callbacks + soul FSM
    dp.include_router(message.router)        # text messages in topics (last)

    await bot.set_my_commands([
        BotCommand(command="start",   description="Начать / восстановить сессию"),
        BotCommand(command="new",     description="Создать новый чат"),
        BotCommand(command="archive", description="Архивировать текущий чат"),
        BotCommand(command="rename",  description="Переименовать текущий чат"),
        BotCommand(command="settings", description="Настройки"),
    ])

    logger.info("bot_starting")
    await dp.start_polling(
        bot,
        allowed_updates=[
            "message",
            "callback_query",
        ],
    )


if __name__ == "__main__":
    asyncio.run(main())
  • Step 2: Verify full import chain
python -c "from adapter.telegram.bot import main; print('ok')"

Expected: ok

  • Step 3: Run all tests
pytest tests/adapter/ -v

Expected: all tests pass, no import errors

  • Step 4: Commit
git add adapter/telegram/bot.py
git commit -m "feat(tg): wire forum-first adapter in bot.py"

Task 9: Final Cleanup and Module Entry Point

Files:

  • Verify: adapter/telegram/__init__.py

  • Step 1: Ensure python -m adapter.telegram.bot works

python -m adapter.telegram.bot --help 2>&1 | head -5 || echo "needs BOT_TOKEN"

Expected: either needs BOT_TOKEN or a clean import error (not ModuleNotFoundError)

  • Step 2: Run full test suite
pytest tests/ -v --tb=short

Expected: all tests pass (including core/ and matrix/ tests from main)

  • Step 3: Final commit
git add -A
git status  # verify no unintended files
git commit -m "feat(tg): forum-first adapter complete — threaded mode, (user_id, thread_id) context"

Self-Review Checklist

Spec requirements vs tasks:

Spec requirement Task
(user_id, thread_id) PK Task 1
forum_topic_created → register Task 3
forum_topic_edited → sync name Task 3
forum_topic_closed → auto-archive Task 3
/new creates topic Task 4
/archive closes + archives Task 4
/rename edits topic + DB Task 4
/settings global keyboard Task 4 + Task 7
/start bootstrap + health-check Task 5
Hide General topic Task 5
Threaded Mode not enabled → explain Task 5
Streaming via stream_message Task 6
General topic messages ignored Task 6 (thread_id None guard in converter)
Stale topic auto-archive on send Task 6
core/store.py for state, no FSM All tasks (no FSMContext in message/topic handlers)
platform resolves workspace Implicit — adapter passes str(thread_id) as chat_id