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

22 KiB
Raw Permalink Blame History

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

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-агента должна учитывать это:

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.

"""
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 (автоочистка неактивных контекстов), атомарные операции (безопасность при конкурентных сообщениях) и персистентность. Паттерн хранения:

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:

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 — для сравнения

Эквивалентный вызов создания топика:

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