docs: add forum-first redesign spec for Telegram adapter
Replaces DM+Forum hybrid design with Bot API 9.3 Threaded Mode as the sole interaction model.
This commit is contained in:
parent
c9072d51ea
commit
bb690a3c38
1 changed files with 180 additions and 0 deletions
180
docs/superpowers/specs/2026-04-01-telegram-forum-redesign.md
Normal file
180
docs/superpowers/specs/2026-04-01-telegram-forum-redesign.md
Normal file
|
|
@ -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 <name>` | `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 |
|
||||||
Loading…
Add table
Add a link
Reference in a new issue