diff --git a/docs/superpowers/plans/2026-04-01-telegram-forum-redesign.md b/docs/superpowers/plans/2026-04-01-telegram-forum-redesign.md new file mode 100644 index 0000000..3592485 --- /dev/null +++ b/docs/superpowers/plans/2026-04-01-telegram-forum-redesign.md @@ -0,0 +1,1308 @@ +# 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` |