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

```bash
git checkout main
git checkout -b feat/telegram-forum
```

- [ ] **Step 2: Copy keyboards from feat/telegram-adapter**

```bash
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**

```bash
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**

```bash
python -c "from adapter.telegram.keyboards.settings import settings_main_keyboard; print('ok')"
```

Expected: `ok`

- [ ] **Step 5: Commit**

```bash
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`:

```python
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**

```bash
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`:

```python
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**

```bash
pytest tests/adapter/test_forum_db.py -v
```

Expected: all 8 tests pass

- [ ] **Step 5: Commit**

```bash
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`:

```python
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**

```bash
pytest tests/adapter/telegram/test_converter.py -v
```

Expected: `ModuleNotFoundError`

- [ ] **Step 3: Implement converter.py**

Create `adapter/telegram/converter.py`:

```python
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**

```bash
pytest tests/adapter/telegram/test_converter.py -v
```

Expected: all 5 tests pass

- [ ] **Step 5: Commit**

```bash
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`:

```python
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**

```bash
pytest tests/adapter/telegram/test_topic_events.py -v
```

Expected: `ModuleNotFoundError`

- [ ] **Step 3: Implement topic_events.py**

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

```python
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**

```bash
pytest tests/adapter/telegram/test_topic_events.py -v
```

Expected: all 5 tests pass

- [ ] **Step 5: Commit**

```bash
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`:

```python
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**

```bash
pytest tests/adapter/telegram/test_commands.py -v
```

Expected: `ModuleNotFoundError`

- [ ] **Step 3: Implement commands.py**

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

```python
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**

```bash
pytest tests/adapter/telegram/test_commands.py -v
```

Expected: all 4 tests pass

- [ ] **Step 5: Commit**

```bash
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`:

```python
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**

```bash
python -c "from adapter.telegram.handlers.start import router; print('ok')"
```

Expected: `ok`

- [ ] **Step 3: Commit**

```bash
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`:

```python
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**

```bash
python -c "from adapter.telegram.handlers.message import router; print('ok')"
```

Expected: `ok`

- [ ] **Step 3: Commit**

```bash
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`:

```python
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**

```bash
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:

```python
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:

```python
platform_user_id = str(callback.from_user.id)
```

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

```python
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**

```bash
python -c "from adapter.telegram.handlers.settings import router; print('ok')"
```

Expected: `ok`

- [ ] **Step 5: Commit**

```bash
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`:

```python
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**

```bash
python -c "from adapter.telegram.bot import main; print('ok')"
```

Expected: `ok`

- [ ] **Step 3: Run all tests**

```bash
pytest tests/adapter/ -v
```

Expected: all tests pass, no import errors

- [ ] **Step 4: Commit**

```bash
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**

```bash
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**

```bash
pytest tests/ -v --tb=short
```

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

- [ ] **Step 3: Final commit**

```bash
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` |
