Files
transcriber/openspec/changes/archive/2026-08-13-start-without-telegram-token/design.md
T
av b733a84d6a telegram: сервис поднимается без бота и работает одним входом
- Клиент бота собирается один раз и достаётся отправителю и транспорту;
  разрез прошёл по «ответил ли Telegram»: ответ «такого бота нет» роняет
  старт, недоступность даёт подъём без Telegram (ADR-2026-08-13). Ожидание
  при сборке ограничено сроком — иначе молчащий Telegram вешал подъём.
- Недоставленный ответ не роняет шаг: пишется с job_id и считается метрикой,
  уровень по причине — WARN для неподнятого входа, ERROR для неназванного
  адресата. Заведены transcriber_intake_up и transcriber_undelivered_reply_count.
- Закрыта утечка токена в журнал: отказ разбора адреса рождается раньше
  обращения к клиенту, то есть мимо чистки на его границе.
2026-08-13 19:10:08 +03:00

19 KiB
Raw Blame History

Context

Сегодня старт роняет отсутствующий токен бота: сборка отправителя ответов возвращает «токен не задан», и процесс заканчивается раньше, чем встаёт HTTP-сервер. Соседний путь того же старта — сборка транспорта бота — тот же отказ уже терпит и сервис не роняет. Два пути одного старта решают одно и то же по-разному, и побеждает тот, что стоит выше.

Ограничение, из-за которого это дорого: боевым токеном запускаться запрещено, а другого действующего токена у разработчика нет. Значит, живой прогон недоступен никому, и всякая задача проверяется одними тестами.

Отправитель ответов уходит в ядро расшифровки обязательной зависимостью, и ядро зовёт его без проверки. Убрать отправителя, ничего не решив, — значит уронить процесс на первой же задаче из Telegram, лежащей в базе с прошлого запуска.

Goals / Non-Goals

Goals:

  • сервис поднимается без токена бота и работает оставшимся входом;
  • отсутствие бота видно в журнале, а не выводится читателем из тишины;
  • задача из Telegram, которой некому ответить, не роняет процесс и не теряется молча;
  • ошибка в токене остаётся заметной.

Non-Goals:

  • приём из Telegram по существу — кто допущен, как забирается запись — не нормируется; оговорка спеки intake остаётся;
  • запрет запускаться боевым токеном не снимается и не смягчается;
  • второй вход не становится необязательным «вообще»: сервис без обоих входов бессмыслен, но проверять это изменение не берётся;
  • отправка отложенных ответов, когда бот появится позже, не заводится.

Decisions

Решение 1: пустой токен — отказ от входа, негодный непустой — ошибка настройки

Разрез проходит по пустому значению, а не по отказу сборки бота.

Что человек увидит иначе: разработчик стирает токен в своём файле настроек и поднимает сервис; владелец сервиса, опечатавшийся в токене при ротации, получает отказ старта вместо сервиса, молча работающего без бота.

Правка после ревью кода, решение владельца 2026-08-13. Разрез перенесён с «пусто / непусто» на «ответил ли Telegram»: недоступность Telegram на подъём сервиса не влияет. Довод — тот же, что у паспорта: основной вход не Telegram, и класть его целиком из-за чужой аварии нельзя. Ровно этого и требовал прежний разрез: перезапуск в минуту аварии Bot API оставил бы без работы приём по HTTP, панель и конвейер, которому Telegram не нужен вовсе.

Отдельно снят довод, оказавшийся ложным. Дизайн утверждал, что «Telegram не признал бота» и «до Telegram не дошли» различать нечем. Различать есть чем: ответ Bot API приезжает своим типом с кодом, транспортный отказ — нашим после чистки, и одно от другого отделяется проверкой типа. Утверждение держалось на незнании библиотеки, а не на её устройстве.

Из решения следует второе, без которого оно невыполнимо: ожидание при сборке ограничивается сроком. Пока срока не было, недоступность не отличалась от подъёма — молчащий Telegram вешал старт бессрочно, без записи, без порта и без пробы здоровья. Срок стоит только на сборке; длинный опрос им не ограничен, и клиент подменяется сразу после.

Рассмотрено и отвергнуто:

  • терпеть любой отказ сборки бота — отвергнуто: опечатка в боевом токене дала бы работающий сервис без бота, и отправители перестали бы получать ответы. Ответ «такого бота нет» опознаётся точно, ждать по нему нечего, и он остаётся единственным отказом старта;
  • ронять старт на любом отказе — отвергнуто владельцем: авария третьей стороны не должна класть основной вход;
  • отдельный ключ настройки «работать без Telegram» — явное объявление намерения. Отвергнуто: имя ключа конфига объявлено необратимым, а пустое значение уже несёт ровно этот смысл. Второй способ сказать одно и то же разъезжается — останется решить, что делать с пустым токеном при выключенном ключе.

Разрез стоит в одном месте, потому что клиент бота собирается один раз. Сегодня его собирают дважды — под отправителя ответов и под транспорт бота, — и именно поэтому два пути разошлись. Вместо того чтобы согласовывать их вручную, изменение сводит сборку к одной: клиент заводится в сборке при старте и отдаётся обоим. Транспорт уже принимает готового клиента, так что менять надо только отправителя — он перестаёт принимать токен и начинает принимать клиента.

Что это даёт сверх опрятности: разрез «пусто / непусто» существует ровно один, запись о неподнятом боте по построению одна, обращение к Telegram при старте одно вместо двух, и подмена журнала библиотеки тоже одна. Проверять «согласованы ли два пути» больше не надо — второго пути нет.

Цена решения: сборка бота перестаёт терпеть негодный токен и начинает ронять старт. Наблюдаемо это почти ничего не меняет: сборка отправителя роняет старт на том же токене и сегодня, а стоит она раньше — до терпимости транспорта очередь попросту не доходит.

Решение 2: заглушка отвечает «канала нет», а запись делает шаг

Когда токена нет, ядро получает отправителя-заглушку. Она ничего не отправляет и на всякий ответ возвращает особое значение отказа — «канал доставки не поднят». Шаг конвейера узнаёт это значение, пишет недоставку в журнал с идентификатором задачи и завершается без отказа.

Что человек увидит иначе: владелец сервиса находит в журнале строку «ответ не доставлен» с идентификатором задачи и забирает расшифровку там же, где лежат остальные.

Почему запись делает шаг, а не сама заглушка: идентификатора задачи у заглушки нет. Контракт отправки несёт текст, чат и сообщение для ответа — задачу он не называет, и знать о ней отправителю незачем. Заглушка, пишущая chat_id вместо задачи, дала бы владельцу строку, по которой задачу не найти, а расширение контракта ради журнала потянуло бы правку и настоящего отправителя, и всех его вызовов.

Почему это не заводит в ядре ветки «а есть ли бот»: ядро ветвится не на устройстве сборки, а на исходе доставки — ровно так же, как оно уже ветвится на «работы нет» и «захват потерян». Особое значение отказа живёт там же, где эти два, и узнаётся тем же способом. Знания о том, как собран сервис, у ядра не появляется.

Источник задачи ядро при этом уже различает: ответ отправителю начинается с проверки источника и на задаче, пришедшей по HTTP, кончается раньше обращения к отправителю. Заглушка задач основного входа не увидит, и ложных строк о недоставке в журнале не будет.

Рассмотрено и отвергнуто:

  • ронять задачу в failed — отвергнуто: расшифровка к этому моменту уже получена и сохранена, а «не удалось» сообщить всё равно некому. Пометка отказа на удавшейся работе врёт и панели, и метрике;
  • оставлять задачу пригодной к повтору — отвергнуто: смысл повтора в том, чтобы работа однажды удалась, а недоставка сама не пройдёт — бот не появится оттого, что задачу подождали;
  • немая заглушка, возвращающая успех — отвергнуто находкой ревью дизайна: недоставку тогда некому записать, и норма «принятая запись не теряется молча» оказывается нарушена именно тем решением, которое её и обслуживало;
  • отпустить отказ заглушки наверх, не разбирая — отвергнуто: шаг объявил бы отказ там, где работа сделана. Задачу это в повтор не отправит — воркеры опрашивают только незавершённые состояния, — но воркеру засчитается сбой, которого не было, и владельцу уедет запись отказа. Соврала бы и метрика, и журнал.

Решение 3: заглушка живёт рядом с настоящим отправителем

Место — тот же пакет, что и отправитель Telegram: заглушка знает ровно то же, что и он, и подставляется в сборке при старте, как и все прочие адаптеры. Направление зависимостей это не нарушает, и тесты-сканеры остаются зелёными. Само значение отказа живёт среди контрактов — там же, где «работы нет» и «захват потерян»: узнаёт его ядро, а порождает адаптер, и ни один из них не зависит от другого.

Форма значения — сентинел, а не тип с полями. Соседи по ряду несут поле (состояние, идентификатор задачи) и потому объявлены типами; этому нести нечего — заглушка не знает ни задачи, ни чата. Прецедент сентинела в проекте есть: им же объявлено «токен не задан».

Заодно убирается третье представление того же факта. Кроме пустой строки в настройках и значения «токен не задан» в пакете отправителя, в транспорте бота объявлен ещё один тип с тем же смыслом, не употребляемый нигде. Он снимается этой же задачей: объяснять четвёртое представление дороже, чем удалить мёртвое.

Решение 4: живой прогон требует заполнить ещё две секции, и это говорится вслух

Пустого токена мало. Настройки входа проверяются на старте и роняют процесс, называя незаполненные ключи; конструкторы Yandex так же роняют его на пустых регионе, ключах Object Storage, ключе SpeechKit и папке. Ни один из них при старте наружу не ходит, поэтому выдуманных непустых значений достаточно — живой прогон получается, а денег не стоит.

Этой задачей разрез «пустое значение — отказ от возможности» на другие секции не переносится: распознавание без Yandex не работает по существу, и отказ от него — отдельное решение с отдельной ценой. Здесь только называется, что заполнить, чтобы сервис поднялся.

Отсюда же граница правки документов: строка «живой прогон недоступен» не снимается, а сужается с остатком — стал доступен подъём и осмотр, а прогон с по-настоящему пустыми ключами Yandex по-прежнему невозможен.

Risks / Trade-offs

  • Сервис молча работает без бота, потому что токен забыли стереть или забыли вписать → строка журнала при старте называет это прямо, а не оставляет читателю вывод из тишины. Дальше — дело того, кто выкладывает;
  • Отказ старта на негодном токене останавливает выкладку, которая прежде проходила → это и есть цель решения 1; чинится правкой настройки, и отказ называет, какой ключ виноват, не называя значения;
  • Сборка бота ходит в Telegram, и без сети старт с непустым токеном упадёт → поведение не новое и не ухудшается. Сегодня сборка отправителя зовётся первой и роняет старт на любом отказе, так что терпимость соседнего пути на негодном токене всё равно не срабатывает: до неё не доходит очередь. Изменение делает два пути согласованными и снимает сеть с законного пути — пустой токен не ходит наружу вовсе. Срока ожидания у обращения к Telegram при этом нет, и задача его не заводит: таймауты у трёх внешних собеседников — известный недостаток проекта и предмет отдельной работы;
  • Недоставленный ответ пропадает навсегда → отложенной доставки нет и не заводится: расшифровка лежит в хранилище и достаётся через панель и HTTP API;
  • Остановка идёт по пути, которым прежде не ходили → останов зеркален сборке: чего не собрали, того не закрывают и не ждут. Проверяется прогоном сигнала остановки на конфиге с пустым токеном.

Migration Plan

Схемы хранилища изменение не трогает, миграции нет, откат — обычный откат образа. Выкладка с заполненным токеном ведёт себя ровно как прежде.

Open Questions

Нет.