surfaces/forum_topics_research.md
Mikhail Putilovskij 6ced154124 feat(matrix): land QA follow-ups and refresh docs
- 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
2026-04-05 19:08:58 +03:00

363 lines
No EOL
22 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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` (1128 символов), `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 # название (1128 символов)
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 не уведомляет об удалении топиков, и получить список существующих топиков нельзя — только запоминать при создании.