Replaces DM+Forum hybrid design with Bot API 9.3 Threaded Mode as the sole interaction model.
6.8 KiB
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 keyboardsadapter/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 onboardinghandlers/chats.py— chat switching via command- All
forum_group_idreferences in db.py and elsewhere
Database Schema
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:
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:
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, callsend_chat_action("typing")for all non-archived topics; treat error as deleted → setarchived_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_topicraisesBadRequestif bot doesn't have Threaded Mode on- Response:
/startreplies 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.pycovers this
Integration — handlers (mocked bot)
/startcreates topic and hides General (bot.create_forum_topicmocked)forum_topic_closed→archived_atsetforum_topic_edited→chat_nameupdated- Message in General →
EventDispatchernot 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 |