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

1308 lines
39 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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