surfaces/docs/superpowers/specs/2026-04-01-telegram-forum-redesign.md
Mikhail Putilovskij bb690a3c38 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.
2026-04-02 00:27:29 +03:00

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 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

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.pyIncomingMessage(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, 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_closedarchived_at set
  • forum_topic_editedchat_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