1308 lines
39 KiB
Markdown
1308 lines
39 KiB
Markdown
# 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` |
|