diff --git a/docs/superpowers/specs/2026-04-01-telegram-forum-redesign.md b/docs/superpowers/specs/2026-04-01-telegram-forum-redesign.md new file mode 100644 index 0000000..529eed1 --- /dev/null +++ b/docs/superpowers/specs/2026-04-01-telegram-forum-redesign.md @@ -0,0 +1,180 @@ +# Telegram Forum Redesign — Forum-First Architecture + +**Date:** 2026-04-01 +**Replaces:** `2026-03-31-telegram-adapter-design.md` (DM+Forum hybrid) +**Branch strategy:** New branch `feat/telegram-forum` from `main` (approach C: cherry-pick from `feat/telegram-adapter`) + +--- + +## Overview + +Redesign the Telegram adapter to use Bot API 9.3 Threaded Mode as the sole interaction model. The user's private chat with the bot becomes a forum: each topic is an isolated AI agent context. No supergroup, no onboarding flow. + +--- + +## File Structure + +**Carried over from `feat/telegram-adapter` (adapted):** +- `adapter/telegram/keyboards/settings.py` — settings inline keyboards +- `adapter/telegram/converter.py` — base conversion logic, rewritten for new context key + +**Written from scratch:** +``` +adapter/telegram/ + bot.py — entry point, router registration + db.py — SQLite schema and queries + handlers/ + start.py — /start handler + message.py — incoming messages in topics + topic_events.py — forum_topic_created / edited / closed + commands.py — /new, /archive, /rename, /settings + keyboards/ + settings.py — (from feat/telegram-adapter) +``` + +**Deleted entirely:** +- `handlers/forum.py` — old supergroup onboarding +- `handlers/chats.py` — chat switching via command +- All `forum_group_id` references in db.py and elsewhere + +--- + +## Database Schema + +```sql +CREATE TABLE 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) +); +``` + +**Context key:** `(user_id, thread_id)` — the canonical identifier for a chat context everywhere in the adapter. + +**Display number** ("Чат #1", "Чат #2") is not stored. Computed on demand: +```sql +ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY created_at) +``` + +**workspace_id (C1/C2/C3)** is not stored. The adapter passes `thread_id` as `context_id` to the platform; the platform resolves the workspace mapping. + +**State** is managed via `core/store.py` with key `(user_id, thread_id)`. No aiogram FSM. + +--- + +## Event Handling + +### Commands (`handlers/commands.py` + `handlers/start.py`) + +| Command | Behaviour | +|---------|-----------| +| `/start` | If no active topics: `create_forum_topic("Чат #1")` + `hide_general_forum_topic`. If topics exist: greeting only. Then check all non-archived topics for validity (see Error Handling). | +| `/new` | `create_forum_topic("Чат #N")` where N = next display number. Insert row in DB. Send welcome message into new topic. | +| `/archive` | `close_forum_topic(thread_id)`. Set `archived_at = now()` in DB. | +| `/rename ` | `edit_forum_topic(thread_id, name)`. Update `chat_name` in DB. | +| `/settings` | Global settings. Works from any topic. | + +### Incoming Messages (`handlers/message.py`) + +- Message in a topic → `converter.py` → `IncomingMessage(context_id=str(thread_id))` → `EventDispatcher` +- Message in General topic (`message_thread_id is None`) → ignored silently + +### Topic UI Events (`handlers/topic_events.py`) + +| Event | Behaviour | +|-------|-----------| +| `forum_topic_created` | Register new chat in DB (native topic creation via UI) | +| `forum_topic_edited` | Update `chat_name` in DB to match new Telegram topic name | +| `forum_topic_closed` | Set `archived_at = now()` — automatic archive | + +--- + +## Data Flow with Streaming + +``` +User → Telegram → aiogram router + → message.py handler + → converter.py: Message → IncomingMessage(context_id=thread_id) + → send placeholder "..." into topic + → EventDispatcher.dispatch(incoming) + → platform/mock.py (or real SDK) + → returns AsyncIterator[str] (chunks) + → for chunk in stream: edit_text(accumulated) every ~1.5s + → final edit_text with complete response + → StateStore.set((user_id, thread_id), new state) +``` + +**`platform/interface.py` change:** +```python +class PlatformClient(Protocol): + async def send_message( + self, + context_id: str, + text: str, + on_chunk: Callable[[str], Awaitable[None]] | None = None, + ) -> str: ... +``` + +`on_chunk` is optional. If the platform does not support streaming (mock), it is ignored and the full response is returned at once. The adapter shows "..." while waiting. + +--- + +## Error Handling + +**Topic deleted by user** +- Sending to topic raises `BadRequest: message thread not found` +- Response: set `archived_at = now()` in DB, stop writing to that topic +- Prevention: on `/start`, call `send_chat_action("typing")` for all non-archived topics; treat error as deleted → set `archived_at` + +**Platform unavailable** +- Real SDK may raise connection/timeout errors +- Response: edit placeholder → "Сервис временно недоступен, попробуй позже" +- Do not archive the topic, do not change state + +**Threaded Mode not enabled** +- `create_forum_topic` raises `BadRequest` if bot doesn't have Threaded Mode on +- Response: `/start` replies with instruction to enable the mode in @BotFather +- Only case where the bot explains a configuration problem + +**General rule:** errors are caught at the handler level, logged, and surfaced to the user as a message. The placeholder never stays as "...". + +--- + +## Testing + +**Unit — `converter.py`** +- `Message(thread_id=123)` → `IncomingMessage(context_id="123")` +- `Message(thread_id=None)` (General) → `None` (ignored) + +**Unit — `db.py`** +- Topic creation, archiving, renaming +- `ROW_NUMBER()` display number computation +- Existing `tests/adapter/test_forum_db.py` covers this + +**Integration — handlers (mocked bot)** +- `/start` creates topic and hides General (`bot.create_forum_topic` mocked) +- `forum_topic_closed` → `archived_at` set +- `forum_topic_edited` → `chat_name` updated +- Message in General → `EventDispatcher` not called + +**Out of scope for now:** +- Streaming end-to-end with real Telegram +- Stale topic recovery on `/start` (requires live bot) + +--- + +## Decisions Log + +| Question | Decision | Rationale | +|----------|----------|-----------| +| Closed topic via UI | Auto-archive | Closing = intent to finish; keeping state in sync | +| Renamed topic via UI | Sync to DB | Respect user intent; `/rename` is symmetric | +| Commands | `/new`, `/archive`, `/rename`, `/settings` | UI and commands are parallel paths | +| DB context key | `(user_id, thread_id)` | `thread_id` is the real identifier in this model | +| FSM | `core/store.py` only | Avoids duplicating state logic; platform-agnostic | +| workspace mapping | Platform responsibility | Adapter passes `thread_id` as `context_id`; platform resolves | +| Streaming | In design via `on_chunk` | Proven pattern from supervisor's examples; `on_chunk` is optional | +| Branch strategy | Cherry-pick (C) | New branch from `main`; carry over keyboards + converter base only |