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

208 lines
19 KiB
Markdown
Raw 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.
## 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
Нет.