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