39 KiB
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 onboardingadapter/telegram/handlers/chat.py— chat switchingadapter/telegram/handlers/auth.py— auth flowadapter/telegram/handlers/confirm.py— confirm modaladapter/telegram/keyboards/chat.pyadapter/telegram/keyboards/forum.py
Task 0: Create Branch and Cherry-Pick Keyboards
Files:
-
Create branch:
feat/telegram-forum -
Cherry-pick:
adapter/telegram/keyboards/settings.py -
Cherry-pick:
adapter/telegram/keyboards/confirm.py -
Step 1: Create new branch from main
git checkout main
git checkout -b feat/telegram-forum
- Step 2: Copy keyboards from feat/telegram-adapter
mkdir -p adapter/telegram/keyboards
git show feat/telegram-adapter:adapter/telegram/keyboards/__init__.py > adapter/telegram/keyboards/__init__.py
git show feat/telegram-adapter:adapter/telegram/keyboards/settings.py > adapter/telegram/keyboards/settings.py
git show feat/telegram-adapter:adapter/telegram/keyboards/confirm.py > adapter/telegram/keyboards/confirm.py
- Step 3: Create package stubs
mkdir -p adapter/telegram/handlers
touch adapter/__init__.py
touch adapter/telegram/__init__.py
touch adapter/telegram/handlers/__init__.py
- Step 4: Verify keyboards import cleanly
python -c "from adapter.telegram.keyboards.settings import settings_main_keyboard; print('ok')"
Expected: ok
- Step 5: Commit
git add adapter/
git commit -m "chore: init feat/telegram-forum, cherry-pick keyboards"
Task 1: Database Layer
Files:
-
Create:
adapter/telegram/db.py -
Rewrite:
tests/adapter/test_forum_db.py -
Step 1: Write failing tests
Write tests/adapter/test_forum_db.py:
from __future__ import annotations
import importlib
import pytest
@pytest.fixture(autouse=True)
def fresh_db(tmp_path, monkeypatch):
monkeypatch.setenv("DB_PATH", str(tmp_path / "test.db"))
import adapter.telegram.db as db_mod
importlib.reload(db_mod)
db_mod.init_db()
return db_mod
def test_create_and_get_chat(fresh_db):
db = fresh_db
db.create_chat(user_id=1, thread_id=100, chat_name="Чат #1")
chat = db.get_chat(user_id=1, thread_id=100)
assert chat is not None
assert chat["chat_name"] == "Чат #1"
assert chat["archived_at"] is None
def test_get_chat_missing(fresh_db):
assert fresh_db.get_chat(user_id=1, thread_id=999) is None
def test_archive_chat(fresh_db):
db = fresh_db
db.create_chat(1, 100, "Чат #1")
db.archive_chat(1, 100)
chat = db.get_chat(1, 100)
assert chat["archived_at"] is not None
def test_rename_chat(fresh_db):
db = fresh_db
db.create_chat(1, 100, "Чат #1")
db.rename_chat(1, 100, "Новое имя")
assert db.get_chat(1, 100)["chat_name"] == "Новое имя"
def test_get_active_chats(fresh_db):
db = fresh_db
db.create_chat(1, 100, "Чат #1")
db.create_chat(1, 200, "Чат #2")
db.archive_chat(1, 100)
chats = db.get_active_chats(1)
assert len(chats) == 1
assert chats[0]["thread_id"] == 200
def test_display_number(fresh_db):
db = fresh_db
db.create_chat(1, 100, "Чат #1")
db.create_chat(1, 200, "Чат #2")
db.create_chat(1, 300, "Чат #3")
assert db.get_display_number(1, 100) == 1
assert db.get_display_number(1, 200) == 2
assert db.get_display_number(1, 300) == 3
def test_count_active_chats(fresh_db):
db = fresh_db
db.create_chat(1, 100, "Чат #1")
db.create_chat(1, 200, "Чат #2")
db.archive_chat(1, 100)
assert db.count_active_chats(1) == 1
def test_different_users_isolated(fresh_db):
db = fresh_db
db.create_chat(1, 100, "Чат #1")
db.create_chat(2, 100, "Чат #1") # same thread_id, different user
assert db.get_chat(1, 100)["chat_name"] == "Чат #1"
assert db.get_chat(2, 100)["chat_name"] == "Чат #1"
db.archive_chat(1, 100)
assert db.get_chat(1, 100)["archived_at"] is not None
assert db.get_chat(2, 100)["archived_at"] is None
- Step 2: Run tests — verify they fail
pytest tests/adapter/test_forum_db.py -v
Expected: ModuleNotFoundError or AttributeError (db.py doesn't exist yet)
- Step 3: Implement db.py
Create adapter/telegram/db.py:
from __future__ import annotations
import os
import sqlite3
from contextlib import contextmanager
DB_PATH = os.environ.get("DB_PATH", "lambda_bot.db")
@contextmanager
def _conn():
con = sqlite3.connect(DB_PATH)
con.row_factory = sqlite3.Row
try:
yield con
con.commit()
finally:
con.close()
def init_db() -> None:
with _conn() as con:
con.executescript("""
CREATE TABLE IF NOT EXISTS chats (
user_id INTEGER NOT NULL,
thread_id INTEGER NOT NULL,
chat_name TEXT NOT NULL DEFAULT 'Чат #1',
archived_at DATETIME,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (user_id, thread_id)
);
""")
def create_chat(user_id: int, thread_id: int, chat_name: str) -> None:
with _conn() as con:
con.execute(
"INSERT OR IGNORE INTO chats (user_id, thread_id, chat_name) VALUES (?, ?, ?)",
(user_id, thread_id, chat_name),
)
def get_chat(user_id: int, thread_id: int) -> dict | None:
with _conn() as con:
row = con.execute(
"SELECT * FROM chats WHERE user_id = ? AND thread_id = ?",
(user_id, thread_id),
).fetchone()
return dict(row) if row else None
def get_active_chats(user_id: int) -> list[dict]:
with _conn() as con:
rows = con.execute(
"SELECT * FROM chats WHERE user_id = ? AND archived_at IS NULL "
"ORDER BY created_at ASC",
(user_id,),
).fetchall()
return [dict(r) for r in rows]
def count_active_chats(user_id: int) -> int:
with _conn() as con:
row = con.execute(
"SELECT COUNT(*) FROM chats WHERE user_id = ? AND archived_at IS NULL",
(user_id,),
).fetchone()
return row[0]
def archive_chat(user_id: int, thread_id: int) -> None:
with _conn() as con:
con.execute(
"UPDATE chats SET archived_at = CURRENT_TIMESTAMP "
"WHERE user_id = ? AND thread_id = ?",
(user_id, thread_id),
)
def rename_chat(user_id: int, thread_id: int, new_name: str) -> None:
with _conn() as con:
con.execute(
"UPDATE chats SET chat_name = ? WHERE user_id = ? AND thread_id = ?",
(new_name, user_id, thread_id),
)
def get_display_number(user_id: int, thread_id: int) -> int:
"""Return 1-based display number for a chat (by creation order)."""
with _conn() as con:
row = con.execute(
"""
SELECT rn FROM (
SELECT thread_id,
ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY created_at) AS rn
FROM chats
WHERE user_id = ?
) WHERE thread_id = ?
""",
(user_id, thread_id),
).fetchone()
return row[0] if row else 1
- Step 4: Run tests — verify they pass
pytest tests/adapter/test_forum_db.py -v
Expected: all 8 tests pass
- Step 5: Commit
git add adapter/telegram/db.py tests/adapter/test_forum_db.py
git commit -m "feat(tg): new db schema — (user_id, thread_id) PK"
Task 2: Converter
Files:
-
Create:
adapter/telegram/converter.py -
Create:
tests/adapter/telegram/test_converter.py -
Step 1: Write failing tests
Create tests/adapter/telegram/test_converter.py:
from __future__ import annotations
from types import SimpleNamespace
from adapter.telegram.converter import from_message, format_outgoing
from core.protocol import OutgoingMessage, OutgoingUI
def make_message(*, text="hello", thread_id=42, user_id=1):
m = SimpleNamespace()
m.text = text
m.caption = None
m.photo = None
m.document = None
m.voice = None
m.message_thread_id = thread_id
m.from_user = SimpleNamespace(id=user_id, full_name="Alice")
return m
def test_from_message_in_topic():
msg = make_message(thread_id=42, user_id=7)
result = from_message(msg)
assert result is not None
assert result.user_id == "7"
assert result.chat_id == "42"
assert result.text == "hello"
assert result.platform == "telegram"
def test_from_message_in_general_returns_none():
msg = make_message(thread_id=None)
assert from_message(msg) is None
def test_from_message_uses_caption_if_no_text():
msg = make_message(text=None, thread_id=10)
msg.caption = "caption text"
result = from_message(msg)
assert result.text == "caption text"
def test_format_outgoing_message():
event = OutgoingMessage(chat_id="42", text="response")
assert format_outgoing(event) == "response"
def test_format_outgoing_ui():
event = OutgoingUI(chat_id="42", text="choose")
assert format_outgoing(event) == "choose"
- Step 2: Run tests — verify they fail
pytest tests/adapter/telegram/test_converter.py -v
Expected: ModuleNotFoundError
- Step 3: Implement converter.py
Create adapter/telegram/converter.py:
from __future__ import annotations
from aiogram.types import Message
from core.protocol import Attachment, IncomingMessage, OutgoingEvent, OutgoingMessage, OutgoingUI
def from_message(message: Message) -> IncomingMessage | None:
"""Convert aiogram Message to IncomingMessage. Returns None for General topic."""
thread_id = message.message_thread_id
if thread_id is None:
return None
return IncomingMessage(
user_id=str(message.from_user.id),
chat_id=str(thread_id),
text=message.text or message.caption or "",
attachments=_extract_attachments(message),
platform="telegram",
)
def _extract_attachments(message: Message) -> list[Attachment]:
attachments: list[Attachment] = []
if message.photo:
file = message.photo[-1]
attachments.append(Attachment(
type="image",
url=f"tg://file/{file.file_id}",
mime_type="image/jpeg",
))
if message.document:
attachments.append(Attachment(
type="document",
url=f"tg://file/{message.document.file_id}",
mime_type=message.document.mime_type or "application/octet-stream",
filename=message.document.file_name,
))
if message.voice:
attachments.append(Attachment(
type="audio",
url=f"tg://file/{message.voice.file_id}",
mime_type="audio/ogg",
))
return attachments
def format_outgoing(event: OutgoingEvent) -> str:
"""Extract text from an outgoing event for sending to Telegram."""
if isinstance(event, (OutgoingMessage, OutgoingUI)):
return event.text
return str(event)
- Step 4: Run tests — verify they pass
pytest tests/adapter/telegram/test_converter.py -v
Expected: all 5 tests pass
- Step 5: Commit
git add adapter/telegram/converter.py tests/adapter/telegram/test_converter.py
git commit -m "feat(tg): converter — context_key=(user_id, thread_id)"
Task 3: Topic Event Handlers
Files:
-
Create:
adapter/telegram/handlers/topic_events.py -
Create:
tests/adapter/telegram/test_topic_events.py -
Step 1: Write failing tests
Create tests/adapter/telegram/test_topic_events.py:
from __future__ import annotations
import importlib
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
import pytest
@pytest.fixture(autouse=True)
def fresh_db(tmp_path, monkeypatch):
monkeypatch.setenv("DB_PATH", str(tmp_path / "test.db"))
import adapter.telegram.db as db_mod
importlib.reload(db_mod)
db_mod.init_db()
return db_mod
def make_service_message(*, user_id=1, thread_id=42, chat_id=1):
m = SimpleNamespace()
m.message_thread_id = thread_id
m.from_user = SimpleNamespace(id=user_id, full_name="Alice")
m.chat = SimpleNamespace(id=chat_id)
m.forum_topic_created = SimpleNamespace(name="Мой чат")
m.forum_topic_edited = SimpleNamespace(name="Новое имя")
m.forum_topic_closed = SimpleNamespace()
m.answer = AsyncMock()
return m
async def test_on_topic_created_registers_chat(fresh_db, monkeypatch):
from adapter.telegram.handlers.topic_events import on_topic_created
msg = make_service_message(user_id=5, thread_id=99)
await on_topic_created(msg)
chat = fresh_db.get_chat(5, 99)
assert chat is not None
assert chat["chat_name"] == "Мой чат"
async def test_on_topic_edited_renames_chat(fresh_db, monkeypatch):
from adapter.telegram.handlers.topic_events import on_topic_edited
fresh_db.create_chat(5, 99, "Старое имя")
msg = make_service_message(user_id=5, thread_id=99)
await on_topic_edited(msg)
assert fresh_db.get_chat(5, 99)["chat_name"] == "Новое имя"
async def test_on_topic_edited_unknown_chat_is_noop(fresh_db):
from adapter.telegram.handlers.topic_events import on_topic_edited
msg = make_service_message(user_id=5, thread_id=999)
await on_topic_edited(msg) # should not raise
async def test_on_topic_closed_archives_chat(fresh_db):
from adapter.telegram.handlers.topic_events import on_topic_closed
fresh_db.create_chat(5, 99, "Чат #1")
msg = make_service_message(user_id=5, thread_id=99)
await on_topic_closed(msg)
assert fresh_db.get_chat(5, 99)["archived_at"] is not None
async def test_on_topic_closed_unknown_chat_is_noop(fresh_db):
from adapter.telegram.handlers.topic_events import on_topic_closed
msg = make_service_message(user_id=5, thread_id=999)
await on_topic_closed(msg) # should not raise
- Step 2: Run tests — verify they fail
pytest tests/adapter/telegram/test_topic_events.py -v
Expected: ModuleNotFoundError
- Step 3: Implement topic_events.py
Create adapter/telegram/handlers/topic_events.py:
from __future__ import annotations
import structlog
from aiogram import F, Router
from aiogram.types import Message
from adapter.telegram import db
logger = structlog.get_logger(__name__)
router = Router(name="topic_events")
@router.message(F.forum_topic_created)
async def on_topic_created(message: Message) -> None:
"""User created a topic via Telegram UI — register it as a new chat."""
user_id = message.from_user.id
thread_id = message.message_thread_id
name = message.forum_topic_created.name
db.create_chat(user_id=user_id, thread_id=thread_id, chat_name=name)
logger.info("topic_created", user_id=user_id, thread_id=thread_id, name=name)
@router.message(F.forum_topic_edited)
async def on_topic_edited(message: Message) -> None:
"""User renamed a topic via Telegram UI — sync chat_name in DB."""
user_id = message.from_user.id
thread_id = message.message_thread_id
new_name = message.forum_topic_edited.name
existing = db.get_chat(user_id=user_id, thread_id=thread_id)
if existing is None:
return
db.rename_chat(user_id=user_id, thread_id=thread_id, new_name=new_name)
logger.info("topic_renamed", user_id=user_id, thread_id=thread_id, new_name=new_name)
@router.message(F.forum_topic_closed)
async def on_topic_closed(message: Message) -> None:
"""User closed a topic via Telegram UI — auto-archive the chat."""
user_id = message.from_user.id
thread_id = message.message_thread_id
existing = db.get_chat(user_id=user_id, thread_id=thread_id)
if existing is None:
return
db.archive_chat(user_id=user_id, thread_id=thread_id)
logger.info("topic_closed_archived", user_id=user_id, thread_id=thread_id)
- Step 4: Run tests — verify they pass
pytest tests/adapter/telegram/test_topic_events.py -v
Expected: all 5 tests pass
- Step 5: Commit
git add adapter/telegram/handlers/topic_events.py tests/adapter/telegram/test_topic_events.py
git commit -m "feat(tg): handle forum_topic_created/edited/closed events"
Task 4: Command Handlers
Files:
-
Create:
adapter/telegram/handlers/commands.py -
Create:
tests/adapter/telegram/test_commands.py -
Step 1: Write failing tests
Create tests/adapter/telegram/test_commands.py:
from __future__ import annotations
import importlib
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock
import pytest
@pytest.fixture(autouse=True)
def fresh_db(tmp_path, monkeypatch):
monkeypatch.setenv("DB_PATH", str(tmp_path / "test.db"))
import adapter.telegram.db as db_mod
importlib.reload(db_mod)
db_mod.init_db()
return db_mod
def make_message(*, user_id=1, thread_id=42, chat_id=1, args=None):
m = SimpleNamespace()
m.from_user = SimpleNamespace(id=user_id, full_name="Alice")
m.message_thread_id = thread_id
m.chat = SimpleNamespace(id=chat_id)
m.answer = AsyncMock()
m.reply = AsyncMock()
m.bot = MagicMock()
m.bot.create_forum_topic = AsyncMock(
return_value=SimpleNamespace(message_thread_id=200)
)
m.bot.close_forum_topic = AsyncMock()
m.bot.edit_forum_topic = AsyncMock()
m.bot.send_message = AsyncMock()
return m
async def test_cmd_new_creates_topic(fresh_db):
from adapter.telegram.handlers.commands import cmd_new
msg = make_message(user_id=1, thread_id=42, chat_id=100)
fresh_db.create_chat(1, 42, "Чат #1") # 1 existing chat
await cmd_new(msg)
msg.bot.create_forum_topic.assert_called_once()
call_kwargs = msg.bot.create_forum_topic.call_args
assert "Чат #2" in str(call_kwargs)
new_chat = fresh_db.get_chat(1, 200)
assert new_chat is not None
assert new_chat["chat_name"] == "Чат #2"
async def test_cmd_archive_closes_and_archives(fresh_db):
from adapter.telegram.handlers.commands import cmd_archive
fresh_db.create_chat(1, 42, "Чат #1")
msg = make_message(user_id=1, thread_id=42, chat_id=100)
await cmd_archive(msg)
msg.bot.close_forum_topic.assert_called_once_with(
chat_id=100, message_thread_id=42
)
assert fresh_db.get_chat(1, 42)["archived_at"] is not None
async def test_cmd_archive_unknown_topic_replies_error(fresh_db):
from adapter.telegram.handlers.commands import cmd_archive
msg = make_message(user_id=1, thread_id=999, chat_id=100)
await cmd_archive(msg)
msg.answer.assert_called_once()
assert "не найден" in msg.answer.call_args[0][0].lower() or \
"not found" in msg.answer.call_args[0][0].lower() or \
len(msg.answer.call_args[0][0]) > 0 # some error message
async def test_cmd_rename_updates_db_and_topic(fresh_db):
from adapter.telegram.handlers.commands import cmd_rename
fresh_db.create_chat(1, 42, "Чат #1")
msg = make_message(user_id=1, thread_id=42, chat_id=100)
await cmd_rename(msg, new_name="Работа")
msg.bot.edit_forum_topic.assert_called_once_with(
chat_id=100, message_thread_id=42, name="Работа"
)
assert fresh_db.get_chat(1, 42)["chat_name"] == "Работа"
- Step 2: Run tests — verify they fail
pytest tests/adapter/telegram/test_commands.py -v
Expected: ModuleNotFoundError
- Step 3: Implement commands.py
Create adapter/telegram/handlers/commands.py:
from __future__ import annotations
import structlog
from aiogram import Router
from aiogram.filters import Command
from aiogram.types import Message
from adapter.telegram import db
from adapter.telegram.keyboards.settings import settings_main_keyboard
logger = structlog.get_logger(__name__)
router = Router(name="commands")
@router.message(Command("new"))
async def cmd_new(message: Message) -> None:
"""Create a new topic and register it as a new chat."""
user_id = message.from_user.id
chat_id = message.chat.id
n = db.count_active_chats(user_id) + 1
new_name = f"Чат #{n}"
topic = await message.bot.create_forum_topic(chat_id=chat_id, name=new_name)
thread_id = topic.message_thread_id
db.create_chat(user_id=user_id, thread_id=thread_id, chat_name=new_name)
await message.bot.send_message(
chat_id=chat_id,
message_thread_id=thread_id,
text=f"Создан {new_name}. Напиши что-нибудь.",
)
logger.info("cmd_new", user_id=user_id, thread_id=thread_id, name=new_name)
@router.message(Command("archive"))
async def cmd_archive(message: Message) -> None:
"""Archive the current topic."""
user_id = message.from_user.id
thread_id = message.message_thread_id
chat = db.get_chat(user_id=user_id, thread_id=thread_id)
if chat is None or chat["archived_at"] is not None:
await message.answer("Этот чат не найден или уже архивирован.")
return
await message.bot.close_forum_topic(
chat_id=message.chat.id, message_thread_id=thread_id
)
db.archive_chat(user_id=user_id, thread_id=thread_id)
logger.info("cmd_archive", user_id=user_id, thread_id=thread_id)
@router.message(Command("rename"))
async def cmd_rename(message: Message, new_name: str = "") -> None:
"""Rename the current topic. Usage: /rename New Name"""
user_id = message.from_user.id
thread_id = message.message_thread_id
if not new_name:
# Parse from message text: /rename New Name
parts = (message.text or "").split(maxsplit=1)
new_name = parts[1].strip() if len(parts) > 1 else ""
if not new_name:
await message.answer("Использование: /rename Новое название")
return
chat = db.get_chat(user_id=user_id, thread_id=thread_id)
if chat is None:
await message.answer("Этот чат не найден.")
return
await message.bot.edit_forum_topic(
chat_id=message.chat.id,
message_thread_id=thread_id,
name=new_name[:128],
)
db.rename_chat(user_id=user_id, thread_id=thread_id, new_name=new_name[:128])
logger.info("cmd_rename", user_id=user_id, thread_id=thread_id, new_name=new_name)
@router.message(Command("settings"))
async def cmd_settings(message: Message) -> None:
"""Open settings menu."""
await message.answer("⚙️ Настройки", reply_markup=settings_main_keyboard())
- Step 4: Run tests — verify they pass
pytest tests/adapter/telegram/test_commands.py -v
Expected: all 4 tests pass
- Step 5: Commit
git add adapter/telegram/handlers/commands.py tests/adapter/telegram/test_commands.py
git commit -m "feat(tg): command handlers — /new /archive /rename /settings"
Task 5: /start Handler
Files:
- Create:
adapter/telegram/handlers/start.py
No separate test file — behaviour is verified via integration in Task 7. Unit testing /start requires heavy bot mocking; the key logic (stale topic detection) is thin enough to verify manually.
- Step 1: Implement start.py
Create adapter/telegram/handlers/start.py:
from __future__ import annotations
import structlog
from aiogram import Router
from aiogram.exceptions import TelegramBadRequest
from aiogram.filters import Command, CommandStart
from aiogram.types import Message
from adapter.telegram import db
logger = structlog.get_logger(__name__)
router = Router(name="start")
@router.message(CommandStart())
async def cmd_start(message: Message) -> None:
"""
Bootstrap the user's forum.
First visit: create Чат #1, hide General topic.
Returning visit: health-check all active topics, archive stale ones.
"""
user_id = message.from_user.id
chat_id = message.chat.id
# Health-check existing topics — archive any that Telegram no longer knows about
await _check_and_prune_stale_topics(message, user_id, chat_id)
active = db.get_active_chats(user_id)
if not active:
# First visit or all topics were pruned — create the first one
try:
topic = await message.bot.create_forum_topic(
chat_id=chat_id, name="Чат #1"
)
thread_id = topic.message_thread_id
db.create_chat(user_id=user_id, thread_id=thread_id, chat_name="Чат #1")
logger.info("start_created_first_topic", user_id=user_id, thread_id=thread_id)
except TelegramBadRequest as e:
if "not modified" not in str(e).lower():
logger.warning("start_create_topic_failed", error=str(e))
await message.answer(
"Не удалось создать топик. Убедись, что в @BotFather включён "
"Threaded Mode для этого бота."
)
return
# Hide General topic so it doesn't distract
try:
await message.bot.hide_general_forum_topic(chat_id=chat_id)
except TelegramBadRequest:
pass # Not critical — may not be available in all API versions
await message.answer(
"Привет! Это твоё личное пространство с AI-агентом Lambda. "
"Каждый топик — отдельный контекст. Напиши что-нибудь."
)
else:
await message.answer(
f"Снова привет! У тебя {len(active)} активных чатов. "
"Напиши /new чтобы создать новый."
)
async def _check_and_prune_stale_topics(
message: Message, user_id: int, chat_id: int
) -> None:
"""
Send typing action to each active topic.
If Telegram returns an error — the topic was deleted; archive it.
"""
active = db.get_active_chats(user_id)
for chat in active:
thread_id = chat["thread_id"]
try:
await message.bot.send_chat_action(
chat_id=chat_id,
action="typing",
message_thread_id=thread_id,
)
except TelegramBadRequest:
db.archive_chat(user_id=user_id, thread_id=thread_id)
logger.info("pruned_stale_topic", user_id=user_id, thread_id=thread_id)
- Step 2: Verify it imports cleanly
python -c "from adapter.telegram.handlers.start import router; print('ok')"
Expected: ok
- Step 3: Commit
git add adapter/telegram/handlers/start.py
git commit -m "feat(tg): /start handler with topic bootstrap and stale-topic pruning"
Task 6: Message Handler with Streaming
Files:
-
Create:
adapter/telegram/handlers/message.py -
Step 1: Implement message.py
Create adapter/telegram/handlers/message.py:
from __future__ import annotations
import asyncio
import time
import structlog
from aiogram import F, Router
from aiogram.exceptions import TelegramBadRequest
from aiogram.types import Message
from adapter.telegram import converter, db
from core.handler import EventDispatcher
logger = structlog.get_logger(__name__)
router = Router(name="message")
STREAM_EDIT_INTERVAL = 1.5 # seconds between edit_text calls
STREAM_MIN_DELTA = 100 # minimum new chars before editing
TELEGRAM_MAX_LEN = 4096
@router.message(F.text & F.message_thread_id)
async def handle_topic_message(message: Message, dispatcher: EventDispatcher) -> None:
"""Route a text message in a topic to the platform and stream the response."""
user_id = message.from_user.id
thread_id = message.message_thread_id
chat = db.get_chat(user_id=user_id, thread_id=thread_id)
if chat is None or chat["archived_at"] is not None:
# Unregistered or archived topic — silently ignore
return
incoming = converter.from_message(message)
if incoming is None:
return
platform_user = await dispatcher._platform.get_or_create_user(
external_id=str(user_id),
platform="telegram",
display_name=message.from_user.full_name,
)
placeholder = await message.reply("...")
accumulated = ""
last_edit_time = 0.0
last_edit_len = 0
try:
async for chunk in dispatcher._platform.stream_message(
user_id=platform_user.user_id,
chat_id=str(thread_id),
text=incoming.text,
attachments=None,
):
accumulated += chunk.delta
now = time.monotonic()
delta = len(accumulated) - last_edit_len
if delta >= STREAM_MIN_DELTA and (now - last_edit_time) >= STREAM_EDIT_INTERVAL:
await _safe_edit(placeholder, accumulated)
last_edit_time = now
last_edit_len = len(accumulated)
# Final edit with complete response
await _safe_edit(placeholder, accumulated or "...")
except TelegramBadRequest as e:
if "thread not found" in str(e).lower():
db.archive_chat(user_id=user_id, thread_id=thread_id)
logger.warning("topic_deleted_during_message", thread_id=thread_id)
else:
logger.error("telegram_error", error=str(e))
except Exception:
logger.exception("platform_error", user_id=user_id, thread_id=thread_id)
await _safe_edit(placeholder, "Сервис временно недоступен, попробуй позже")
async def _safe_edit(message: Message, text: str) -> None:
"""Edit message text, truncating to Telegram limit. Swallows 'not modified'."""
truncated = text[:TELEGRAM_MAX_LEN]
try:
await message.edit_text(truncated)
except TelegramBadRequest as e:
if "not modified" not in str(e).lower():
raise
- Step 2: Verify it imports cleanly
python -c "from adapter.telegram.handlers.message import router; print('ok')"
Expected: ok
- Step 3: Commit
git add adapter/telegram/handlers/message.py
git commit -m "feat(tg): message handler with streaming via sdk.stream_message"
Task 7: Settings Handler (Cherry-Pick + Adapt)
Files:
- Create:
adapter/telegram/states.py - Create:
adapter/telegram/handlers/settings.py
The settings handler from feat/telegram-adapter already works well. We adapt it to drop db.get_or_create_tg_user (no longer needed — platform resolves users by str(tg_id)) and remove topic-FSM dependency.
- Step 1: Create states.py (SettingsState only)
Create adapter/telegram/states.py:
from __future__ import annotations
from aiogram.fsm.state import State, StatesGroup
class SettingsState(StatesGroup):
menu = State()
soul_editing = State()
- Step 2: Cherry-pick settings handler
git show feat/telegram-adapter:adapter/telegram/handlers/settings.py > adapter/telegram/handlers/settings.py
- Step 3: Patch settings handler — remove get_or_create_tg_user calls
In adapter/telegram/handlers/settings.py, replace all blocks that call db.get_or_create_tg_user with a direct string cast. Find every occurrence of:
from adapter.telegram import db as tgdb
tg_id = callback.from_user.id
tg_user = tgdb.get_or_create_tg_user(tg_id, str(tg_id), callback.from_user.full_name)
platform_user_id = tg_user.get("platform_user_id", str(tg_id))
Replace with:
platform_user_id = str(callback.from_user.id)
And for message handlers (soul editing), replace the analogous block with:
platform_user_id = str(message.from_user.id)
Also remove the import of ChatState from adapter.telegram.states — it no longer exists:
Find: from adapter.telegram.states import ChatState, SettingsState
Replace: from adapter.telegram.states import SettingsState
- Step 4: Verify settings handler imports cleanly
python -c "from adapter.telegram.handlers.settings import router; print('ok')"
Expected: ok
- Step 5: Commit
git add adapter/telegram/states.py adapter/telegram/handlers/settings.py
git commit -m "feat(tg): cherry-pick settings handler, drop get_or_create_tg_user"
Task 8: Wire Everything in bot.py
Files:
-
Create:
adapter/telegram/bot.py -
Step 1: Implement bot.py
Create adapter/telegram/bot.py:
from __future__ import annotations
import asyncio
import os
import structlog
from aiogram import Bot, Dispatcher
from aiogram.fsm.storage.memory import MemoryStorage
from aiogram.types import BotCommand
from adapter.telegram import db
from adapter.telegram.handlers import commands, message, settings, start, topic_events
from core.auth import AuthManager
from core.chat import ChatManager
from core.handler import EventDispatcher
from core.settings import SettingsManager
from core.store import InMemoryStore
from sdk.mock import MockPlatformClient
logger = structlog.get_logger(__name__)
class PlatformMiddleware:
"""Injects EventDispatcher (with platform inside) into every handler."""
def __init__(self, dispatcher: EventDispatcher) -> None:
self._dispatcher = dispatcher
async def __call__(self, handler, event, data):
data["dispatcher"] = self._dispatcher
return await handler(event, data)
def build_event_dispatcher() -> EventDispatcher:
platform = MockPlatformClient()
store = InMemoryStore()
chat_mgr = ChatManager(platform, store)
auth_mgr = AuthManager(platform, store)
settings_mgr = SettingsManager(platform, store)
return EventDispatcher(
platform=platform,
chat_mgr=chat_mgr,
auth_mgr=auth_mgr,
settings_mgr=settings_mgr,
)
async def main() -> None:
token = os.environ.get("BOT_TOKEN")
if not token:
raise RuntimeError("BOT_TOKEN env variable is not set")
db.init_db()
bot = Bot(token=token)
storage = MemoryStorage()
dp = Dispatcher(storage=storage)
event_dispatcher = build_event_dispatcher()
dp.message.middleware(PlatformMiddleware(event_dispatcher))
dp.callback_query.middleware(PlatformMiddleware(event_dispatcher))
# Register routers — order matters (most specific first)
dp.include_router(topic_events.router) # service messages
dp.include_router(start.router) # /start
dp.include_router(commands.router) # /new /archive /rename /settings
dp.include_router(settings.router) # settings callbacks + soul FSM
dp.include_router(message.router) # text messages in topics (last)
await bot.set_my_commands([
BotCommand(command="start", description="Начать / восстановить сессию"),
BotCommand(command="new", description="Создать новый чат"),
BotCommand(command="archive", description="Архивировать текущий чат"),
BotCommand(command="rename", description="Переименовать текущий чат"),
BotCommand(command="settings", description="Настройки"),
])
logger.info("bot_starting")
await dp.start_polling(
bot,
allowed_updates=[
"message",
"callback_query",
],
)
if __name__ == "__main__":
asyncio.run(main())
- Step 2: Verify full import chain
python -c "from adapter.telegram.bot import main; print('ok')"
Expected: ok
- Step 3: Run all tests
pytest tests/adapter/ -v
Expected: all tests pass, no import errors
- Step 4: Commit
git add adapter/telegram/bot.py
git commit -m "feat(tg): wire forum-first adapter in bot.py"
Task 9: Final Cleanup and Module Entry Point
Files:
-
Verify:
adapter/telegram/__init__.py -
Step 1: Ensure
python -m adapter.telegram.botworks
python -m adapter.telegram.bot --help 2>&1 | head -5 || echo "needs BOT_TOKEN"
Expected: either needs BOT_TOKEN or a clean import error (not ModuleNotFoundError)
- Step 2: Run full test suite
pytest tests/ -v --tb=short
Expected: all tests pass (including core/ and matrix/ tests from main)
- Step 3: Final commit
git add -A
git status # verify no unintended files
git commit -m "feat(tg): forum-first adapter complete — threaded mode, (user_id, thread_id) context"
Self-Review Checklist
Spec requirements vs tasks:
| Spec requirement | Task |
|---|---|
(user_id, thread_id) PK |
Task 1 |
forum_topic_created → register |
Task 3 |
forum_topic_edited → sync name |
Task 3 |
forum_topic_closed → auto-archive |
Task 3 |
/new creates topic |
Task 4 |
/archive closes + archives |
Task 4 |
/rename edits topic + DB |
Task 4 |
/settings global keyboard |
Task 4 + Task 7 |
/start bootstrap + health-check |
Task 5 |
| Hide General topic | Task 5 |
| Threaded Mode not enabled → explain | Task 5 |
Streaming via stream_message |
Task 6 |
| General topic messages ignored | Task 6 (thread_id None guard in converter) |
| Stale topic auto-archive on send | Task 6 |
core/store.py for state, no FSM |
All tasks (no FSMContext in message/topic handlers) |
| platform resolves workspace | Implicit — adapter passes str(thread_id) as chat_id |