# Telegram Adapter Design **Date:** 2026-03-31 **Status:** Approved — implemented in `feat/telegram-adapter` **Scope:** `adapter/telegram/` --- ## Контекст Telegram-адаптер — поверхность для взаимодействия пользователя с AI-агентом Lambda через Telegram. Адаптер конвертирует Telegram-события в `IncomingEvent` (core protocol) и отправляет `OutgoingEvent` обратно. Бизнес-логика остаётся в `core/`; адаптер отвечает за Telegram API, FSM и локальную SQLite-привязку Telegram-пользователя и чатов. --- ## Чаты: hybrid DM + Forum Topics **Зафиксированное решение:** базовая поверхность — личка с ботом, Forum Topics — опциональный advanced-режим поверх тех же самых чатов. - В DM пользователь всегда может писать сразу после `/start` - `active_chat_id` хранится в FSM и определяет, в какой чат идут DM-сообщения - Если подключена Forum-группа, каждый чат может получить `forum_thread_id` - Один и тот же `chat_id` доступен из двух поверхностей: - DM: ответы идут с префиксом `[Название чата]` - Forum-тема: ответы идут прямо в тему без префикса ### UX флоу ```text /start → пользователь аутентифицирован → создаётся или восстанавливается активный DM-чат /new [название] в DM → создаётся новый чат → если forum уже подключён, бот создаёт и forum topic /forum → бот просит переслать сообщение из супергруппы с Topics → проверяет admin rights → привязывает группу к пользователю → создаёт topics для существующих чатов Сообщение в DM → идёт в active_chat_id → ответ приходит в DM как `[Чат #N] ...` Сообщение в forum topic → по `message_thread_id` определяется chat_id → ответ приходит в ту же тему без тега ``` --- ## Аутентификация ### Флоу (мок) 1. `/start` → `platform.get_or_create_user(external_id=tg_user_id, platform="telegram", display_name=...)` 2. Бот сохраняет `tg_user_id -> platform_user_id` в локальной БД 3. Если локальных чатов ещё нет — создаёт `Чат #1` 4. Если чат уже есть — восстанавливает последний активный чат ### FSM состояния ```python class ChatState(StatesGroup): idle = State() waiting_response = State() class SettingsState(StatesGroup): menu = State() soul_editing = State() confirm_action = State() class ForumSetupState(StatesGroup): waiting_for_group = State() ``` `active_chat_id` и `active_chat_name` хранятся в `FSMContext` data. --- ## Структура файлов ```text adapter/telegram/ bot.py — точка входа: Dispatcher, middleware, routers converter.py — Message -> IncomingMessage, forum helpers, output formatting db.py — SQLite schema и Telegram-specific persistence states.py — ChatState, SettingsState, ForumSetupState handlers/ auth.py — /start chat.py — /new, /chats, switch chat, входящие сообщения confirm.py — confirm/cancel callbacks forum.py — /forum onboarding и регистрация forum group settings.py — /settings и callbacks настроек keyboards/ chat.py — список чатов confirm.py — confirm keyboard settings.py — меню настроек ``` --- ## Persistence Локальная БД содержит две Telegram-специфичные сущности: ```sql CREATE TABLE tg_users ( tg_user_id INTEGER PRIMARY KEY, platform_user_id TEXT NOT NULL, display_name TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, forum_group_id INTEGER ); CREATE TABLE chats ( chat_id TEXT PRIMARY KEY, tg_user_id INTEGER NOT NULL, name TEXT NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, archived_at TIMESTAMP, forum_thread_id INTEGER ); ``` - `forum_group_id` — привязанная супергруппа пользователя - `forum_thread_id` — опциональная связь конкретного чата с forum topic --- ## Converter ### Telegram -> IncomingEvent ```python def from_message(message: Message, chat_id: str) -> IncomingMessage: return IncomingMessage( user_id=str(message.from_user.id), chat_id=chat_id, text=message.text or message.caption or "", attachments=_extract_attachments(message), platform="telegram", ) def is_forum_message(message: Message) -> bool: return getattr(message, "message_thread_id", None) is not None def resolve_forum_chat_id(message: Message, tg_user_id: int) -> str | None: thread_id = getattr(message, "message_thread_id", None) if thread_id is None: return None chat = db.get_chat_by_thread(tg_user_id, thread_id) return chat["chat_id"] if chat else None ``` ### OutgoingEvent -> Telegram ```python def format_outgoing(chat_name: str, event: OutgoingEvent, *, prefix: bool = True) -> str: rendered_prefix = f"[{chat_name}] " if prefix else "" return rendered_prefix + event.text ``` - DM-ответы используют `prefix=True` - Forum-ответы используют `prefix=False` - `OutgoingUI` отправляется с inline-кнопками подтверждения --- ## Обработчики ### `auth.py` - `/start` создаёт или восстанавливает пользователя - если это первый запуск, создаёт `Чат #1` - обновляет `active_chat_id` и переводит FSM в `ChatState.idle` ### `chat.py` - `/new`: - в DM создаёт новый чат - если подключён forum, пытается создать forum topic и сохранить `forum_thread_id` - в forum-теме может зарегистрировать текущую тему как чат - `/chats` показывает inline-список чатов - `switch::` переключает активный DM-чат - `handle_message`: - в DM читает `active_chat_id` из FSM - в forum определяет чат по `message_thread_id` - отправляет `typing` - прокидывает `IncomingMessage` в `EventDispatcher` - возвращает ответ в DM или в тему ### `forum.py` - `/forum` переводит FSM в `ForumSetupState.waiting_for_group` - пересланное сообщение из супергруппы: - валидирует, что это `supergroup` - проверяет, что бот admin и умеет `can_manage_topics` - сохраняет `forum_group_id` - создаёт topics для существующих чатов без `forum_thread_id` ### `confirm.py` - обрабатывает `confirm:yes:` и `confirm:no:` - в forum-режиме восстанавливает `chat_id` по thread - ответ на callback отправляет обратно в тот же канал: - DM -> в личку - Forum -> в тот же `message_thread_id` --- ## Текущее покрытие - unit-тесты на forum routing и forum onboarding: `tests/adapter/telegram/test_forum.py` - smoke/integration на dispatcher и core handlers: - `tests/core/test_dispatcher.py` - `tests/core/test_integration.py` --- ## Что не покрывает этот документ - Matrix-адаптер - Реальный SDK платформы вместо `sdk.mock.MockPlatformClient` - Автоматическое отслеживание вручную созданных пользователем forum topics без `/new`