236 lines
8.4 KiB
Markdown
236 lines
8.4 KiB
Markdown
# 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:<chat_id>:<name>` переключает активный 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:<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.py`
|
||
- `tests/core/test_integration.py`
|
||
|
||
---
|
||
|
||
## Что не покрывает этот документ
|
||
|
||
- Matrix-адаптер
|
||
- Реальный SDK платформы вместо `sdk.mock.MockPlatformClient`
|
||
- Автоматическое отслеживание вручную созданных пользователем forum topics без `/new`
|