Тема в Telegram есть, а ответа в ней нет
Персональный агент у меня живёт на своём сервере, а интерфейс к нему — Telegram. Не веб-морда, не терминал, а обычная личка с ботом, в которой включены темы. Это оказалось самой удобной связкой: каждое сообщение в новой теме становится отдельной сессией со своим контекстом, историей и инструментами. Тред равен задаче.
Схему я пробовал на двух клиентах — Hermes и OpenClaw. Подключение к Telegram у них устроено одинаково, и одинаково же ломалось одно и то же место. Разбирательство заняло почти день и закончилось плагином на сервере, комментариями в upstream и пониманием, что виноват не агент.
Что именно ломалось
Иногда я пишу боту в ленте «Все сообщения» — Telegram создаёт под это сообщение новую тему, — но ответ приходит не в неё, а в общий чат. Тема остаётся пустой, вопрос висит отдельно. Иногда тот же сценарий отрабатывает правильно, и именно эта непостоянность мешала искать причину.
В журнале Hermes за утро нашлись два таких случая: в 08:33 создалась тема 79143, но вопрос пришёл в сессию без номера треда; в 10:47 — тема 79151 и снова без треда. Сообщение, с которого начался разбор, пришло уже с номером 79155, и с ним всё работало.
Первое, что я исключил, — настройки: темы у бота включены1, getMe возвращает has_topics_enabled: true и allows_users_to_create_topics: true, отдельный режим /topic в Hermes не активирован (в базе нет ни его состояния, ни привязок). То есть дело не в том, что я что-то не включил.
Первый заход: deepseek-v4.1-flash
Начинал я с deepseek/deepseek-v4.1-flash через OpenRouter. Разбор шёл по кругу: проверить настройки бота, потом настройки клиента, потом версию Hermes, потом снова настройки. Ответы были складные и правдоподобные, но каждый следующий шаг не приближал к причине, а проверка очередной гипотезы её опровергала. Признака, по которому можно было бы отличить «Telegram не прислал» от «агент потерял», так и не появилось.
Это не претензия к модели: задача плохо поставлена. Пока не видно, что именно приходит боту, любая гипотеза выглядит одинаково разумной.
Второй заход: gpt-6.1-sol
Следующую попытку я делал на gpt-6.1-sol. Здесь разбор пошёл от кода, а не от настроек, и быстро выяснилось важное: адаптер Telegram в Hermes принимает тему в приватном чате только при двух условиях сразу — есть message_thread_id и выставлен is_topic_message. Если флага нет, номер темы отбрасывается, и сообщение обрабатывается как сообщение общего чата. Проверка на копии функций маршрутизации это подтвердила: 79155 с флагом остаётся темой, 79155 без флага превращается в None.
Дальше нашлась карточка в трекере Telegram2: при отправке из «Все сообщения» клиент создаёт тему, но само сообщение с текстом доставляет боту без номера темы и без признака принадлежности к ней. В комментариях к карточке упомянуты оба клиента — и OpenClaw, и Hermes, — то есть это не особенность конкретного агента.
В репозитории Hermes к этому моменту уже висело issue #1365673 с описанием ровно нашего сценария, ссылками на PR и подробными выкладками от нескольких независимых инсталляций. На этом этапе причина была понятна, но исправления на сервере не было.
Новый DeepSeek Flash
Тем временем вышла новая версия DeepSeek Flash, и я перевёл Hermes с deepseek/deepseek-v4.1-flash через OpenRouter на канонический deepseek-flash у провайдера напрямую. Следующий заход делался уже на нём — и на этот раз разговор начался не с гипотез, а с вопроса «что именно приходит боту».
Ответ нашёлся в сервисных сообщениях. Когда Telegram создаёт тему, бот получает событие forum_topic_created, и в нём есть номер новой темы. А следом приходит текст сообщения, и в нём нет ни message_thread_id, ни is_topic_message, ни reply_to_message:
forum_topic_created → message_thread_id = 79143 (номер темы известен)
message (текст) → message_thread_id = отсутствует, is_topic_message = отсутствует
Ядро Hermes лежит в контейнере только для чтения, поэтому исправление сделали пользовательским плагином рядом с данными, а не патчем в образе. Логика в нём короткая:
- Плагин подписывается на
forum_topic_createdв приватных чатах и запоминает пару «чат → номер новой темы» на 180 секунд. Темы, созданные самим ботом, игнорируются. - Он же оборачивает обработчик входящих сообщений адаптера.
- Первое сообщение без номера темы, пришедшее в это окно, копируется в новую тему через
copyMessage4 и перенаправляется туда: у события проставляетсяthread_id, а якорь ответа указывает на копию. Дальше ответ, прогресс инструментов и продолжение диалога идут уже в тему, и каждая такая тема — отдельная сессия. - Всё падает в исходное поведение, если сервисного сообщения не было, окно истекло, копия не удалась или вылетело исключение.
Проверяли по шагам: двенадцать офлайн-тестов на маршрутизацию и установку патча, затем /restart и строки в журнале о том, что обработчик подменён и наблюдатель зарегистрирован. Первая живая проверка оказалась даже не нужна: тестовая тема 79175 создалась, и сообщение пришло с номером темы само.
Это, кстати, ещё один факт из карточки бага: поведение зависит от клиента. Telegram Desktop номер темы присылает, мобильный клиент из «Все сообщения» — нет. Поэтому баг воспроизводится не каждый раз, а «иногда».
Комментарии в upstream
К этому моменту в репозитории Hermes уже висели несколько PR с почти идентичным решением5, один из них (#136596) был закрыт без мержа. Все они восстанавливали номер темы у «пустого» первого сообщения — и в обсуждении оставался открытый вопрос: что делать с якорем ответа, если само сообщение осталось в общей ленте.
Мы этот вопрос не стали обсуждать теоретически, а измерили двумя пробами Bot API на живом боте (сообщения отправили в тему и удалили):
| Якорь ответа | Результат |
|---|---|
| сообщение из общей ленты | ok: true, сообщение легло в тему, reply_to_message отсутствует — цитата молча выброшена |
| сообщение внутри темы | ok: true, reply_to_message.message_id на месте |
Вывод: Bot API не отвергает кросс-топиковый якорь и не возвращает ошибку, поэтому до ветки повторной отправки без якоря дело не доходит. Значит исправления, которые восстанавливают только номер темы, по стороне отправки полны, а дополнительный ретрай в более широком #135959 для этого сценария не нужен.
Комментарий с этими измерениями оставили в issue и в #136581. Автор PR ответил через десять минут: с замерами согласился, сказал, что именно этой части не хватало, а расхождение объяснил тем, что жёсткий отказ Bot API — другая форма отказа, которую его тест и закрепляет.
Что осталось нерешённым
Тема всё равно не самодостаточна: Telegram оставляет исходное сообщение в общей ленте, поэтому без копирования в теме будет только ответ, а вопрос — снаружи. Локально это решает copyMessage, но в upstream это отдельное улучшение со своими издержками — дубль сообщения в двух местах и лишний вызов API на каждый такой ход. Автор PR согласился с такой оценкой и оставил свой патч узким.
Ни один из PR пока не смержен, баг на стороне Telegram и зависит от клиента. Плагин на сервере живёт до тех пор, пока исправление не появится в релизе Hermes, и его придётся перепроверять после обновлений: он держится за внутренние точки, которых в чужом коде никто не обещал сохранять.
Что из этого следует
Три вывода, которые мне кажутся полезнее самого исправления.
Первый: интерфейс в мессенджере — это не «просто бот». Темы, лобби, сервисные сообщения, разные клиенты и разные формы одного и того же события — состояний больше, чем кажется, и половина из них не описана в документации.
Второй: без сырых данных расследование вырождается в перебор гипотез. Пока мы смотрели на уже разобранное агентом событие, «Telegram не прислал» и «агент потерял» были неразличимы. Как только появились обе формы сообщения рядом, вопрос закрылся за один шаг.
Третий, менее приятный: сравнивать модели по этому эпизоду не совсем честно. К третьему заходу баг уже был найден и обсуждён в issue, так что «нашёл и сразу починил» — не чистая победа новой версии, а эффект накопленного контекста. Что действительно отличается, так это привычка доводить до проверяемого факта вместо правдоподобного объяснения, и вот она у моделей разная.
Примечание. Статья написана deepseek-flash по результатам работы на сервере.
BotFather Threads Settings , Bot API: темы в приватном чате включаются на стороне бота. ↩︎
Темы в приватных чатах: первое сообщение новой темы приходит без номера темы , трекер ошибок Telegram. В комментариях упомянуты OpenClaw и Hermes. ↩︎
NousResearch/hermes-agent#136567 , «Telegram DM topics: opening message of a newly created topic is delivered thread-less». ↩︎
copyMessage , Bot API. Копия отправляется от бота, поэтому в теме видно, что вопрос продублирован, а не написан заново. ↩︎
NousResearch/hermes-agent#136581 и #137348 — два независимых PR с одним и тем же исправлением; более широкий вариант — #135959 . ↩︎