- harden Matrix onboarding/chat lifecycle after manual QA - refresh README and Matrix docs to match current behavior - add local ignores for runtime artifacts and include current planning/report docs Closes #7 Closes #9 Closes #14
363 lines
No EOL
22 KiB
Markdown
363 lines
No EOL
22 KiB
Markdown
# Telegram-бот как форум для AI-агента: полный технический разбор
|
||
|
||
С выходом **Bot API 9.3 (31 декабря 2025) и 9.4 (9 февраля 2026)** Telegram действительно позволяет боту «стать форумом» без отдельной supergroup — через режим **Threaded Mode**, включаемый в @BotFather. Личный чат пользователя с ботом получает полноценные forum topics, каждый из которых выступает изолированным контекстом разговора. Параллельно сохраняется классическая архитектура «бот-админ в supergroup с включёнными Topics», обкатанная с Bot API 6.3 (ноябрь 2022). Оба подхода дают `message_thread_id` для маршрутизации сообщений к нужному контексту AI-агента, но отличаются по сценариям применения, ограничениям и настройке.
|
||
|
||
---
|
||
|
||
## Threaded Mode — бот сам становится форумом
|
||
|
||
Начиная с Bot API 9.3, в @BotFather появилась настройка **Threaded Mode** (Bot Settings → Threaded Mode). После её включения личный чат пользователя с ботом превращается в форум: сообщения несут `message_thread_id` и `is_topic_message`, точно как в supergroup-форумах.
|
||
|
||
Ключевые поля и возможности нового режима:
|
||
|
||
- **`User.has_topics_enabled`** (bool) — показывает, включён ли Threaded Mode у бота для данного пользователя.
|
||
- **`User.allows_users_to_create_topics`** (bool, API 9.4) — может ли пользователь сам создавать топики, или это право только у бота. Управляется через настройку @BotFather Mini App.
|
||
- Бот вызывает **`createForumTopic(chat_id=user_id, name="...")`** прямо в личном чате — без supergroup, без админ-прав (API 9.4).
|
||
- Работают **`editForumTopic`**, **`deleteForumTopic`**, **`unpinAllForumTopicMessages`** — подтверждено для private chats с API 9.3.
|
||
- Все методы отправки (`sendMessage`, `sendPhoto`, `sendDocument` и т.д.) принимают `message_thread_id` в личных чатах.
|
||
|
||
Это и есть ответ на вопрос «бот становится форумом» — **никакой отдельной группы не нужно**. Пользователь открывает чат с ботом и видит структуру топиков. Каждый топик — отдельный «разговор» с AI-агентом.
|
||
|
||
Классическая архитектура «supergroup + бот-админ» по-прежнему актуальна для многопользовательских сценариев, где несколько людей работают с агентом в одном пространстве. Но для **персонального AI-ассистента** Threaded Mode — технически чистое решение.
|
||
|
||
---
|
||
|
||
## Полный справочник Forum Topics API
|
||
|
||
### Основные методы
|
||
|
||
| Метод | Параметры | Возврат | Права |
|
||
|-------|-----------|---------|-------|
|
||
| `createForumTopic` | `chat_id`, `name` (1–128 символов), `icon_color`?, `icon_custom_emoji_id`? | `ForumTopic` | `can_manage_topics` (supergroup) / не нужны (private) |
|
||
| `editForumTopic` | `chat_id`, `message_thread_id`, `name`?, `icon_custom_emoji_id`? | `True` | `can_manage_topics` или создатель топика |
|
||
| `closeForumTopic` | `chat_id`, `message_thread_id` | `True` | `can_manage_topics` или создатель |
|
||
| `reopenForumTopic` | `chat_id`, `message_thread_id` | `True` | `can_manage_topics` или создатель |
|
||
| `deleteForumTopic` | `chat_id`, `message_thread_id` | `True` | **`can_delete_messages`** (не `can_manage_topics`!) |
|
||
| `unpinAllForumTopicMessages` | `chat_id`, `message_thread_id` | `True` | `can_pin_messages` |
|
||
| `getForumTopicIconStickers` | — | `Array of Sticker` | не нужны |
|
||
|
||
### Методы General-топика (только supergroup)
|
||
|
||
| Метод | Описание |
|
||
|-------|----------|
|
||
| `editGeneralForumTopic(chat_id, name)` | Переименовать General-топик |
|
||
| `closeGeneralForumTopic(chat_id)` | Закрыть General |
|
||
| `reopenGeneralForumTopic(chat_id)` | Открыть General |
|
||
| `hideGeneralForumTopic(chat_id)` | Скрыть General (автоматически закрывает) |
|
||
| `unhideGeneralForumTopic(chat_id)` | Показать General |
|
||
| `unpinAllGeneralForumTopicMessages(chat_id)` | Открепить все сообщения в General |
|
||
|
||
Все требуют `can_manage_topics`, кроме `unpinAll...` — там нужен `can_pin_messages`.
|
||
|
||
### Объект ForumTopic
|
||
|
||
```python
|
||
class ForumTopic:
|
||
message_thread_id: int # уникальный ID топика
|
||
name: str # название (1–128 символов)
|
||
icon_color: int # RGB-цвет иконки
|
||
icon_custom_emoji_id: str # кастомный эмодзи (опционально)
|
||
is_name_implicit: bool # имя назначено автоматически (API 9.3+)
|
||
```
|
||
|
||
**Допустимые значения `icon_color`**: `0x6FB9F0` (голубой), `0xFFD67E` (жёлтый), `0xCB86DB` (фиолетовый), `0x8EEE98` (зелёный), `0xFF93B2` (розовый), `0xFB6F5F` (красный) — ровно 6 цветов, других API не принимает.
|
||
|
||
### Как работает message_thread_id
|
||
|
||
При отправке через `sendMessage` (и все остальные send-методы) параметр `message_thread_id` направляет сообщение в конкретный топик. Входящие сообщения из топиков содержат два поля: **`message_thread_id`** (int) и **`is_topic_message`** (bool = True). Для General-топика `is_topic_message` **не устанавливается** — это ключевое отличие.
|
||
|
||
---
|
||
|
||
## General-топик: коварная деталь
|
||
|
||
General-топик имеет фиксированный **`id = 1`** на уровне MTProto API. Однако в Bot API его поведение отличается от кастомных топиков: сообщения в General **не несут `is_topic_message = true`**, а `message_thread_id` может быть `None` или отсутствовать. При этом отправка с `message_thread_id=1` часто возвращает **`400 Bad Request: message thread not found`**. Корректный подход — **просто опустить `message_thread_id`** при отправке в General.
|
||
|
||
Логика маршрутизации для AI-агента должна учитывать это:
|
||
|
||
```python
|
||
if message.is_topic_message and message.message_thread_id:
|
||
# Кастомный топик → изолированный контекст
|
||
context_key = (chat_id, message.message_thread_id)
|
||
elif getattr(message.chat, 'is_forum', False):
|
||
# Форум, но не is_topic_message → General-топик
|
||
context_key = (chat_id, "general")
|
||
else:
|
||
# Обычный чат / личное сообщение
|
||
context_key = (chat_id, None)
|
||
```
|
||
|
||
General-топик **нельзя удалить**, но можно скрыть через `hideGeneralForumTopic`. Для AI-бота рекомендуется скрыть General и направлять все взаимодействия через кастомные топики — это устраняет edge case с маршрутизацией.
|
||
|
||
---
|
||
|
||
## Рабочий бот на aiogram 3.x с полной изоляцией контекстов
|
||
|
||
Ниже — **полный минимальный бот**, который создаёт топики по команде `/new`, ведёт изолированную историю для каждого топика и интегрируется с LLM. Код проверен по документации aiogram 3.26.
|
||
|
||
```python
|
||
"""
|
||
AI-агент с forum topics — aiogram 3.x
|
||
pip install aiogram>=3.20 openai aiosqlite
|
||
"""
|
||
|
||
import asyncio
|
||
import logging
|
||
import os
|
||
from collections import defaultdict
|
||
|
||
from aiogram import Bot, Dispatcher, F, Router
|
||
from aiogram.filters import Command, CommandStart
|
||
from aiogram.types import Message, ForumTopic
|
||
from aiogram.client.default import DefaultBotProperties
|
||
from aiogram.enums import ParseMode
|
||
from aiogram.fsm.storage.memory import MemoryStorage
|
||
from aiogram.fsm.strategy import FSMStrategy
|
||
|
||
# ── Конфигурация ──────────────────────────────────────────────
|
||
TOKEN = os.getenv("BOT_TOKEN")
|
||
GROUP_ID = int(os.getenv("GROUP_ID", "0")) # ID supergroup-форума
|
||
|
||
router = Router()
|
||
|
||
# ── Хранилище контекстов: {(chat_id, topic_id): [messages]} ──
|
||
contexts: dict[tuple[int, int | None], list[dict]] = defaultdict(list)
|
||
|
||
|
||
# ── /start — приветствие в любом топике ───────────────────────
|
||
@router.message(CommandStart())
|
||
async def cmd_start(message: Message):
|
||
topic = message.message_thread_id
|
||
await message.answer(
|
||
f"👋 AI-агент активен.\n"
|
||
f"Топик: {topic or 'General'}\n\n"
|
||
f"/new <имя> — новый разговор\n"
|
||
f"/clear — очистить контекст\n"
|
||
f"/close — закрыть топик"
|
||
)
|
||
|
||
|
||
# ── /new <имя> — создание нового топика-контекста ─────────────
|
||
@router.message(Command("new"))
|
||
async def cmd_new(message: Message, bot: Bot):
|
||
args = message.text.split(maxsplit=1)
|
||
name = args[1] if len(args) > 1 else f"Чат #{message.message_id}"
|
||
|
||
try:
|
||
topic: ForumTopic = await bot.create_forum_topic(
|
||
chat_id=message.chat.id,
|
||
name=name,
|
||
icon_color=0x6FB9F0,
|
||
)
|
||
# Приветственное сообщение внутри нового топика
|
||
await bot.send_message(
|
||
chat_id=message.chat.id,
|
||
text=f"✅ Контекст «{name}» создан. Пишите сюда — "
|
||
f"я помню только этот разговор.",
|
||
message_thread_id=topic.message_thread_id,
|
||
)
|
||
except Exception as e:
|
||
await message.answer(f"❌ Ошибка: {e}")
|
||
|
||
|
||
# ── /clear — сброс контекста текущего топика ──────────────────
|
||
@router.message(Command("clear"))
|
||
async def cmd_clear(message: Message):
|
||
key = (message.chat.id, message.message_thread_id)
|
||
contexts[key].clear()
|
||
await message.answer("🗑 Контекст очищен.")
|
||
|
||
|
||
# ── /close — закрытие текущего топика ─────────────────────────
|
||
@router.message(Command("close"), F.message_thread_id)
|
||
async def cmd_close(message: Message, bot: Bot):
|
||
try:
|
||
await bot.close_forum_topic(
|
||
chat_id=message.chat.id,
|
||
message_thread_id=message.message_thread_id,
|
||
)
|
||
# Чистим контекст закрытого топика
|
||
key = (message.chat.id, message.message_thread_id)
|
||
contexts.pop(key, None)
|
||
except Exception as e:
|
||
await message.answer(f"❌ {e}")
|
||
|
||
|
||
# ── Обработка текстовых сообщений — маршрутизация по топику ───
|
||
@router.message(F.text, ~F.text.startswith("/"))
|
||
async def handle_user_message(message: Message):
|
||
key = (message.chat.id, message.message_thread_id)
|
||
history = contexts[key]
|
||
|
||
# Сохраняем сообщение пользователя
|
||
history.append({"role": "user", "content": message.text})
|
||
|
||
# ── Вызов LLM (заглушка — заменить на реальный вызов) ──
|
||
reply = await call_llm(history)
|
||
|
||
# Сохраняем ответ ассистента
|
||
history.append({"role": "assistant", "content": reply})
|
||
|
||
# Ограничиваем историю (скользящее окно)
|
||
if len(history) > 100:
|
||
contexts[key] = history[-100:]
|
||
|
||
# message.answer() автоматически сохраняет message_thread_id
|
||
await message.answer(reply)
|
||
|
||
|
||
# ── Заглушка LLM (заменить на OpenAI / Anthropic / etc.) ─────
|
||
async def call_llm(history: list[dict]) -> str:
|
||
"""
|
||
Реальная интеграция:
|
||
|
||
from openai import AsyncOpenAI
|
||
client = AsyncOpenAI()
|
||
|
||
messages = [{"role": "system", "content": "Ты полезный ассистент."}]
|
||
messages += [{"role": m["role"], "content": m["content"]}
|
||
for m in history[-20:]]
|
||
|
||
resp = await client.chat.completions.create(
|
||
model="gpt-4o", messages=messages
|
||
)
|
||
return resp.choices[0].message.content
|
||
"""
|
||
return f"[Echo] {history[-1]['content']} (сообщений в контексте: {len(history)})"
|
||
|
||
|
||
# ── Точка входа ───────────────────────────────────────────────
|
||
async def main():
|
||
logging.basicConfig(level=logging.INFO)
|
||
bot = Bot(token=TOKEN, default=DefaultBotProperties(parse_mode=ParseMode.HTML))
|
||
|
||
dp = Dispatcher(
|
||
storage=MemoryStorage(),
|
||
fsm_strategy=FSMStrategy.CHAT_TOPIC, # изоляция FSM по топикам
|
||
)
|
||
dp.include_router(router)
|
||
await dp.start_polling(bot)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
asyncio.run(main())
|
||
```
|
||
|
||
### Критически важная деталь: FSMStrategy.CHAT_TOPIC
|
||
|
||
Встроенная в aiogram стратегия `FSMStrategy.CHAT_TOPIC` хранит состояния FSM с ключом `(chat_id, chat_id, thread_id)` — каждый топик получает **собственное** изолированное состояние. Это появилось в aiogram 3.4.0 и специально предназначено для форумных ботов. Без этой стратегии FSM-состояния будут общими для всех топиков в одном чате.
|
||
|
||
---
|
||
|
||
## Хранение контекстов: от прототипа к продакшену
|
||
|
||
### In-memory dict — для разработки
|
||
|
||
Простой `defaultdict(list)` из примера выше теряет данные при перезапуске, но позволяет мгновенно начать работу. Ключ — кортеж `(chat_id, topic_id)`.
|
||
|
||
### Redis — для продакшена
|
||
|
||
Redis даёт **нативный TTL** (автоочистка неактивных контекстов), **атомарные операции** (безопасность при конкурентных сообщениях) и **персистентность**. Паттерн хранения:
|
||
|
||
```python
|
||
import json
|
||
import redis.asyncio as redis
|
||
|
||
r = redis.from_url("redis://localhost:6379")
|
||
|
||
async def get_history(chat_id: int, topic_id: int | None) -> list[dict]:
|
||
key = f"ctx:{chat_id}:{topic_id or 'general'}"
|
||
raw = await r.get(key)
|
||
return json.loads(raw) if raw else []
|
||
|
||
async def append_and_trim(chat_id: int, topic_id: int | None, msg: dict):
|
||
key = f"ctx:{chat_id}:{topic_id or 'general'}"
|
||
history = await get_history(chat_id, topic_id)
|
||
history.append(msg)
|
||
history = history[-50:] # скользящее окно
|
||
await r.set(key, json.dumps(history), ex=86400 * 7) # TTL 7 дней
|
||
```
|
||
|
||
### SQLite — компромисс
|
||
|
||
Для однопроцессных развёртываний без инфраструктуры Redis:
|
||
|
||
```python
|
||
import aiosqlite
|
||
|
||
async def init_db():
|
||
async with aiosqlite.connect("contexts.db") as db:
|
||
await db.execute("""
|
||
CREATE TABLE IF NOT EXISTS messages (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
chat_id INTEGER NOT NULL,
|
||
topic_id INTEGER,
|
||
role TEXT NOT NULL,
|
||
content TEXT NOT NULL,
|
||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||
)
|
||
""")
|
||
await db.execute(
|
||
"CREATE INDEX IF NOT EXISTS idx_ctx ON messages(chat_id, topic_id)"
|
||
)
|
||
await db.commit()
|
||
```
|
||
|
||
---
|
||
|
||
## Настройка supergroup с forum mode
|
||
|
||
Включить режим форума **через Bot API невозможно** — нет соответствующего метода. Два способа активации:
|
||
|
||
Для **Threaded Mode в личных чатах**: @BotFather → выбрать бота → Bot Settings → Threaded Mode → включить. Всё. Никаких supergroup не нужно.
|
||
|
||
Для **supergroup-форума** — шаги через Telegram-клиент:
|
||
|
||
1. Создать группу (или использовать существующую).
|
||
2. Открыть настройки группы → Edit → включить **Topics**. Telegram автоматически конвертирует группу в supergroup (ID чата изменится).
|
||
3. Добавить бота в группу.
|
||
4. Назначить бота администратором с правами: **`can_manage_topics`** (создание/редактирование/закрытие топиков), **`can_delete_messages`** (удаление топиков), **`can_pin_messages`** (работа с закреплёнными сообщениями).
|
||
|
||
Минимально необходимое право — `can_manage_topics`. Без него бот не сможет вызвать `createForumTopic`.
|
||
|
||
MTProto API имеет `channels.toggleForum(enabled=true)`, но это доступно только пользовательским аккаунтам с правами владельца, а не ботам.
|
||
|
||
---
|
||
|
||
## Лимиты, edge cases и важные ограничения
|
||
|
||
**До 1 000 000 топиков** в одной supergroup — практически неограниченный потолок. **5 закреплённых топиков** максимум. Общие rate limits Bot API (~30 запросов/сек) распространяются и на создание топиков.
|
||
|
||
**При удалении топика** все сообщения внутри него **удаляются безвозвратно**, `message_thread_id` становится невалидным. Критическая проблема: **Bot API не доставляет webhook-событие об удалении топика**. Нет поля `forum_topic_deleted` в объекте Message. Для очистки контекстов в хранилище используйте одну из стратегий: TTL-based expiry в Redis, ошибку при попытке отправки в несуществующий thread (error-based cleanup), или ручную очистку, если удаление инициирует сам бот.
|
||
|
||
**Bot API не предоставляет метод для получения списка существующих топиков.** Нет `getForumTopics`. Бот должен запоминать ID топиков при создании через `createForumTopic` или через service messages `ForumTopicCreated`.
|
||
|
||
### python-telegram-bot v21 — для сравнения
|
||
|
||
Эквивалентный вызов создания топика:
|
||
|
||
```python
|
||
from telegram import Update, ForumTopic
|
||
from telegram.ext import Application, CommandHandler
|
||
|
||
async def new_topic(update: Update, context):
|
||
topic: ForumTopic = await context.bot.create_forum_topic(
|
||
chat_id=update.effective_chat.id,
|
||
name="Новый разговор",
|
||
icon_color=0x6FB9F0,
|
||
)
|
||
await context.bot.send_message(
|
||
chat_id=update.effective_chat.id,
|
||
text="Топик создан!",
|
||
message_thread_id=topic.message_thread_id,
|
||
)
|
||
```
|
||
|
||
Ключевое отличие: python-telegram-bot **не имеет встроенных FSM-стратегий** для топиков. Изоляцию состояний по `message_thread_id` нужно реализовывать вручную. Фильтры service-сообщений: `filters.StatusUpdate.FORUM_TOPIC_CREATED`, `.FORUM_TOPIC_CLOSED`, `.FORUM_TOPIC_REOPENED`.
|
||
|
||
---
|
||
|
||
## Заключение
|
||
|
||
**Threaded Mode — прорывная возможность** для AI-ботов, появившаяся буквально в конце 2025-го. До этого «бот как форум» требовал обязательной supergroup-обёртки. Теперь личный чат с ботом является полноценным форумом, где каждый топик — изолированный контекст разговора с агентом.
|
||
|
||
Архитектурная формула проста: `context_key = (chat_id, message_thread_id)` + `FSMStrategy.CHAT_TOPIC` в aiogram дают полную изоляцию из коробки. Для продакшена — Redis с TTL, для прототипа — `defaultdict(list)`. Три граблей, которые нужно знать заранее: General-топик не принимает `message_thread_id=1` при отправке, Bot API не уведомляет об удалении топиков, и получить список существующих топиков нельзя — только запоминать при создании. |