8.4 KiB
8.4 KiB
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-тема: ответы идут прямо в тему без префикса
- DM: ответы идут с префиксом
UX флоу
/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
→ ответ приходит в ту же тему без тега
Аутентификация
Флоу (мок)
/start→platform.get_or_create_user(external_id=tg_user_id, platform="telegram", display_name=...)- Бот сохраняет
tg_user_id -> platform_user_idв локальной БД - Если локальных чатов ещё нет — создаёт
Чат #1 - Если чат уже есть — восстанавливает последний активный чат
FSM состояния
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.
Структура файлов
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-специфичные сущности:
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
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
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:<chat_id>:<name>переключает активный DM-чатhandle_message:- в DM читает
active_chat_idиз FSM - в forum определяет чат по
message_thread_id - отправляет
typing - прокидывает
IncomingMessageвEventDispatcher - возвращает ответ в DM или в тему
- в 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:<action_id>иconfirm:no:<action_id> - в 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.pytests/core/test_integration.py
Что не покрывает этот документ
- Matrix-адаптер
- Реальный SDK платформы вместо
sdk.mock.MockPlatformClient - Автоматическое отслеживание вручную созданных пользователем forum topics без
/new