docs: telegram adapter design spec + remove soul style presets from mock
This commit is contained in:
parent
67499daa61
commit
a3449fc864
3 changed files with 390 additions and 2 deletions
388
docs/superpowers/specs/2026-03-31-telegram-adapter-design.md
Normal file
388
docs/superpowers/specs/2026-03-31-telegram-adapter-design.md
Normal file
|
|
@ -0,0 +1,388 @@
|
|||
# Telegram Adapter Design
|
||||
|
||||
**Date:** 2026-03-31
|
||||
**Status:** Approved — ready for implementation
|
||||
**Scope:** `adapter/telegram/`
|
||||
|
||||
---
|
||||
|
||||
## Контекст
|
||||
|
||||
Telegram-адаптер — поверхность для взаимодействия пользователя с AI-агентом Lambda через Telegram.
|
||||
Адаптер конвертирует Telegram-события в `IncomingEvent` (core protocol) и отправляет `OutgoingEvent` обратно.
|
||||
Бизнес-логика — в `core/`, адаптер только переводит форматы и управляет Telegram API.
|
||||
|
||||
---
|
||||
|
||||
## Чаты: основной режим — Виртуальные чаты в DM
|
||||
|
||||
**Решение зафиксировано:** основной режим — виртуальные чаты прямо в личке с ботом.
|
||||
Forum Topics — опциональный advanced режим (не реализуется в этом прототипе).
|
||||
|
||||
### Принцип работы
|
||||
|
||||
- `active_chat_id` — куда идут входящие сообщения от пользователя в данный момент
|
||||
- Ответы от агента всегда приходят в общий DM-поток с тегом: `[Чат #1] Вот ответ...`
|
||||
- Пользователь может переключиться до получения ответа — ответ всё равно придёт, тегирован
|
||||
|
||||
### UX флоу
|
||||
|
||||
```
|
||||
/start
|
||||
→ Приветствие + Чат #1 создан автоматически
|
||||
→ Пользователь сразу пишет
|
||||
|
||||
/new [название]
|
||||
→ Новый чат создан, переключаемся на него
|
||||
|
||||
/chats
|
||||
→ Инлайн-кнопки: 1. Чат #1 2. Чат #2 3. Исследование рынка
|
||||
→ Нажимает — переключился
|
||||
|
||||
Сообщение в активный чат
|
||||
→ Typing indicator
|
||||
→ [Чат #1] Ответ агента
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Аутентификация
|
||||
|
||||
### Флоу (мок)
|
||||
|
||||
1. `/start` → `get_or_create_user(tg_user_id, "telegram", display_name)`
|
||||
2. `is_new=True` → создать Чат #1, написать приветствие
|
||||
3. `is_new=False` → восстановить `active_chat_id` из БД, написать "С возвращением"
|
||||
|
||||
### FSM состояния
|
||||
|
||||
```python
|
||||
class AuthState(StatesGroup):
|
||||
# В моке состояний нет — auth мгновенный
|
||||
# Зарезервировано для реального SDK (waiting_confirmation и т.п.)
|
||||
pass
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## FSM состояния (полная схема)
|
||||
|
||||
```python
|
||||
class ChatState(StatesGroup):
|
||||
idle = State() # В активном чате, ждём сообщения
|
||||
waiting_response = State() # Запрос ушёл на платформу, ждём ответа
|
||||
|
||||
class SettingsState(StatesGroup):
|
||||
menu = State() # Главное меню настроек
|
||||
soul_editing = State() # Редактирует имя/инструкции агента
|
||||
confirm_action = State() # Подтверждение деструктивного действия
|
||||
```
|
||||
|
||||
**`active_chat_id` хранится в FSM StateData, не в состоянии.**
|
||||
|
||||
---
|
||||
|
||||
## Структура файлов
|
||||
|
||||
```
|
||||
adapter/telegram/
|
||||
bot.py — точка входа: Dispatcher, routers, middleware
|
||||
states.py — FSM StatesGroup
|
||||
converter.py — aiogram Message → IncomingEvent и обратно
|
||||
|
||||
handlers/
|
||||
auth.py — /start
|
||||
chat.py — /new, /chats, /rename, /archive, сообщения в чате
|
||||
settings.py — /settings и callback_query для настроек
|
||||
confirm.py — подтверждение действий агента (InlineKeyboard ✅/❌)
|
||||
|
||||
keyboards/
|
||||
chat.py — список чатов, управление чатом
|
||||
settings.py — меню настроек, скиллы, коннекторы
|
||||
confirm.py — кнопки подтверждения действия
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Converter
|
||||
|
||||
Конвертация в обе стороны — `adapter/telegram/converter.py`.
|
||||
|
||||
### aiogram → IncomingEvent
|
||||
|
||||
```python
|
||||
def from_message(message: Message) -> IncomingMessage:
|
||||
return IncomingMessage(
|
||||
user_id=str(message.from_user.id),
|
||||
chat_id=active_chat_id, # из FSM StateData
|
||||
text=message.text or "",
|
||||
attachments=extract_attachments(message),
|
||||
platform="telegram",
|
||||
raw=message.model_dump(),
|
||||
)
|
||||
|
||||
def extract_attachments(message: Message) -> list[Attachment]:
|
||||
attachments = []
|
||||
if message.photo:
|
||||
file = message.photo[-1] # наибольшее разрешение
|
||||
attachments.append(Attachment(
|
||||
url=f"tg://file/{file.file_id}", # резолвим через getFile при необходимости
|
||||
mime_type="image/jpeg",
|
||||
size=file.file_size,
|
||||
))
|
||||
if message.document:
|
||||
attachments.append(Attachment(
|
||||
url=f"tg://file/{message.document.file_id}",
|
||||
mime_type=message.document.mime_type or "application/octet-stream",
|
||||
size=message.document.file_size,
|
||||
filename=message.document.file_name,
|
||||
))
|
||||
if message.voice:
|
||||
attachments.append(Attachment(
|
||||
url=f"tg://file/{message.voice.file_id}",
|
||||
mime_type="audio/ogg",
|
||||
size=message.voice.file_size,
|
||||
))
|
||||
return attachments
|
||||
```
|
||||
|
||||
### OutgoingEvent → Telegram
|
||||
|
||||
```python
|
||||
async def send_outgoing(bot: Bot, user_id: int, chat_name: str, event: OutgoingEvent) -> None:
|
||||
prefix = f"[{chat_name}] "
|
||||
|
||||
if isinstance(event, OutgoingMessage):
|
||||
await bot.send_message(user_id, prefix + event.text)
|
||||
|
||||
elif isinstance(event, OutgoingUI):
|
||||
# Кнопки подтверждения действия
|
||||
keyboard = build_confirm_keyboard(event)
|
||||
await bot.send_message(user_id, prefix + event.text, reply_markup=keyboard)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Обработчики
|
||||
|
||||
### auth.py — `/start`
|
||||
|
||||
```python
|
||||
@router.message(CommandStart())
|
||||
async def cmd_start(message: Message, state: FSMContext, platform: PlatformClient):
|
||||
user = await platform.get_or_create_user(
|
||||
external_id=str(message.from_user.id),
|
||||
platform="telegram",
|
||||
display_name=message.from_user.full_name,
|
||||
)
|
||||
|
||||
if user.is_new:
|
||||
chat_id = create_chat(user.user_id, "Чат #1") # в локальной БД
|
||||
await state.update_data(active_chat_id=chat_id, active_chat_name="Чат #1")
|
||||
await state.set_state(ChatState.idle)
|
||||
await message.answer(
|
||||
f"Привет, {message.from_user.first_name}! 👋\n"
|
||||
f"Я создал тебе первый чат. Просто пиши.\n\n"
|
||||
f"Команды: /new — новый чат, /chats — список"
|
||||
)
|
||||
else:
|
||||
# Восстановить последний активный чат
|
||||
last_chat = get_last_chat(user.user_id)
|
||||
await state.update_data(active_chat_id=last_chat.id, active_chat_name=last_chat.name)
|
||||
await state.set_state(ChatState.idle)
|
||||
await message.answer(f"С возвращением! Продолжаем [{last_chat.name}]")
|
||||
```
|
||||
|
||||
### chat.py — сообщения
|
||||
|
||||
```python
|
||||
@router.message(ChatState.idle, F.text)
|
||||
async def handle_message(message: Message, state: FSMContext, platform: PlatformClient):
|
||||
data = await state.get_data()
|
||||
chat_id = data["active_chat_id"]
|
||||
chat_name = data["active_chat_name"]
|
||||
|
||||
await state.set_state(ChatState.waiting_response)
|
||||
await message.bot.send_chat_action(message.chat.id, "typing")
|
||||
|
||||
incoming = from_message(message, chat_id)
|
||||
outgoing_events = await core_handler.handle(incoming, platform)
|
||||
|
||||
await state.set_state(ChatState.idle)
|
||||
|
||||
for event in outgoing_events:
|
||||
await send_outgoing(message.bot, message.from_user.id, chat_name, event)
|
||||
```
|
||||
|
||||
### chat.py — управление чатами
|
||||
|
||||
```python
|
||||
@router.message(Command("new"))
|
||||
async def cmd_new_chat(message: Message, state: FSMContext):
|
||||
args = message.text.split(maxsplit=1)
|
||||
name = args[1] if len(args) > 1 else None
|
||||
|
||||
data = await state.get_data()
|
||||
user_id = ... # из платформы
|
||||
count = count_chats(user_id)
|
||||
chat_name = name or f"Чат #{count + 1}"
|
||||
|
||||
chat_id = create_chat(user_id, chat_name)
|
||||
await state.update_data(active_chat_id=chat_id, active_chat_name=chat_name)
|
||||
await message.answer(f"✅ [{chat_name}] создан. Пиши!")
|
||||
|
||||
|
||||
@router.message(Command("chats"))
|
||||
async def cmd_list_chats(message: Message, state: FSMContext):
|
||||
chats = get_user_chats(user_id)
|
||||
data = await state.get_data()
|
||||
active_id = data.get("active_chat_id")
|
||||
|
||||
buttons = []
|
||||
for chat in chats:
|
||||
mark = "● " if chat.id == active_id else ""
|
||||
buttons.append([InlineKeyboardButton(
|
||||
text=f"{mark}{chat.name}",
|
||||
callback_data=f"switch:{chat.id}:{chat.name}"
|
||||
)])
|
||||
buttons.append([InlineKeyboardButton(text="➕ Новый чат", callback_data="new_chat")])
|
||||
|
||||
await message.answer("Твои чаты:", reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons))
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("switch:"))
|
||||
async def switch_chat(callback: CallbackQuery, state: FSMContext):
|
||||
_, chat_id, chat_name = callback.data.split(":", 2)
|
||||
await state.update_data(active_chat_id=chat_id, active_chat_name=chat_name)
|
||||
await callback.message.edit_text(f"✅ Переключился на [{chat_name}]")
|
||||
await callback.answer()
|
||||
```
|
||||
|
||||
### confirm.py — подтверждение действий агента
|
||||
|
||||
```python
|
||||
# Агент хочет выполнить действие → OutgoingUI приходит из core handler
|
||||
# Бот показывает кнопки, ждёт ответа
|
||||
|
||||
@router.callback_query(F.data.startswith("confirm:"))
|
||||
async def handle_confirm(callback: CallbackQuery, state: FSMContext, platform: PlatformClient):
|
||||
_, action_id, decision = callback.data.split(":") # "confirm" / "cancel"
|
||||
data = await state.get_data()
|
||||
|
||||
incoming = IncomingCallback(
|
||||
user_id=...,
|
||||
chat_id=data["active_chat_id"],
|
||||
action="confirm" if decision == "yes" else "cancel",
|
||||
payload={"action_id": action_id},
|
||||
platform="telegram",
|
||||
)
|
||||
outgoing_events = await core_handler.handle(incoming, platform)
|
||||
await callback.message.edit_reply_markup(reply_markup=None)
|
||||
for event in outgoing_events:
|
||||
await send_outgoing(callback.bot, callback.from_user.id, data["active_chat_name"], event)
|
||||
await callback.answer()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Настройки
|
||||
|
||||
`/settings` → инлайн-меню. Структура:
|
||||
|
||||
```
|
||||
⚙️ Настройки
|
||||
[🧩 Скиллы] [🔗 Коннекторы]
|
||||
[🧠 Личность] [🔒 Безопасность]
|
||||
[💳 Подписка]
|
||||
```
|
||||
|
||||
**Скиллы** — список с кнопками-переключателями ✅/❌. Нажатие → `SettingsAction(toggle_skill)`.
|
||||
|
||||
**Личность** — свободные поля (имя агента, инструкции). Без пресетов стилей.
|
||||
FSM: `SettingsState.soul_editing` → бот задаёт вопросы по одному полю.
|
||||
|
||||
**Коннекторы** — заглушка OAuth ссылки.
|
||||
|
||||
**Безопасность** — переключатели для деструктивных действий.
|
||||
|
||||
**Подписка** — заглушка с токенами.
|
||||
|
||||
---
|
||||
|
||||
## Хранилище (БД)
|
||||
|
||||
Минимальная схема для прототипа:
|
||||
|
||||
```sql
|
||||
CREATE TABLE tg_users (
|
||||
tg_user_id INTEGER PRIMARY KEY,
|
||||
platform_user_id TEXT NOT NULL, -- из MockPlatformClient
|
||||
display_name TEXT,
|
||||
created_at TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE chats (
|
||||
chat_id TEXT PRIMARY KEY, -- UUID
|
||||
tg_user_id INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
created_at TIMESTAMP,
|
||||
archived_at TIMESTAMP,
|
||||
FOREIGN KEY(tg_user_id) REFERENCES tg_users(tg_user_id)
|
||||
);
|
||||
```
|
||||
|
||||
`StateStore` из `core/store.py` (`SQLiteStore`) — для FSM и общего состояния.
|
||||
|
||||
---
|
||||
|
||||
## Typing indicator
|
||||
|
||||
Отправлять `send_chat_action("typing")` перед запросом к платформе.
|
||||
Если запрос > 5 сек — возобновлять каждые 4 сек (action живёт ~5 сек).
|
||||
|
||||
```python
|
||||
async def with_typing(bot: Bot, chat_id: int, coro):
|
||||
async def renew():
|
||||
while True:
|
||||
await bot.send_chat_action(chat_id, "typing")
|
||||
await asyncio.sleep(4)
|
||||
task = asyncio.create_task(renew())
|
||||
try:
|
||||
return await coro
|
||||
finally:
|
||||
task.cancel()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Обработка ответов при смене чата
|
||||
|
||||
Ответ всегда приходит в DM-поток с тегом:
|
||||
```
|
||||
[Чат #1] Вот мой ответ на вопрос про Python...
|
||||
```
|
||||
|
||||
Пользователь мог переключить `active_chat_id` пока шёл запрос — это нормально.
|
||||
`chat_name` берётся из `StateData` в момент отправки запроса (до `set_state(waiting_response)`).
|
||||
|
||||
---
|
||||
|
||||
## Что НЕ реализуем в прототипе
|
||||
|
||||
- Forum Topics режим (researched, отложено)
|
||||
- Webhook от платформы (platform ещё не готов — используем sync `send_message`)
|
||||
- `/rename`, `/archive` для чатов (добавить после основного флоу)
|
||||
- Экспорт истории
|
||||
|
||||
---
|
||||
|
||||
## Порядок реализации
|
||||
|
||||
1. `bot.py` — Dispatcher, middleware для platform client
|
||||
2. `states.py` — FSM классы
|
||||
3. `converter.py` — from_message, extract_attachments
|
||||
4. `handlers/auth.py` — /start
|
||||
5. `handlers/chat.py` — сообщения + /new + /chats
|
||||
6. `keyboards/chat.py` — список чатов
|
||||
7. `handlers/settings.py` + `keyboards/settings.py` — меню настроек
|
||||
8. `handlers/confirm.py` + `keyboards/confirm.py` — подтверждения
|
||||
Loading…
Add table
Add a link
Reference in a new issue