telegram: сервис поднимается без бота и работает одним входом

- Клиент бота собирается один раз и достаётся отправителю и транспорту;
  разрез прошёл по «ответил ли Telegram»: ответ «такого бота нет» роняет
  старт, недоступность даёт подъём без Telegram (ADR-2026-08-13). Ожидание
  при сборке ограничено сроком — иначе молчащий Telegram вешал подъём.
- Недоставленный ответ не роняет шаг: пишется с job_id и считается метрикой,
  уровень по причине — WARN для неподнятого входа, ERROR для неназванного
  адресата. Заведены transcriber_intake_up и transcriber_undelivered_reply_count.
- Закрыта утечка токена в журнал: отказ разбора адреса рождается раньше
  обращения к клиенту, то есть мимо чистки на его границе.
This commit is contained in:
av
2026-08-13 19:10:08 +03:00
parent 863ba3b42e
commit b733a84d6a
33 changed files with 1579 additions and 91 deletions
+5 -1
View File
@@ -186,7 +186,11 @@ task gate # весь набор проверок разом
(`data/storage/<коллекция>/<запись>/`). Локальный каталог данных — свой, его (`data/storage/<коллекция>/<запись>/`). Локальный каталог данных — свой, его
ронять и пересоздавать можно свободно. ронять и пересоздавать можно свободно.
- **Боевым токеном бота не запускаться.** Второй процесс с тем же токеном - **Боевым токеном бота не запускаться.** Второй процесс с тем же токеном
перехватывает обновления у работающего, и пользователь теряет ответы. перехватывает обновления у работающего, и пользователь теряет ответы. Запускай
с **пустым** `telegram.bot_token`: сервис поднимается без Telegram и работает
одним входом, по HTTP. Пустого токена для подъёма мало — секции `[auth]` и
`[yandex]` проверяются на старте, но наружу при этом не ходят, так что годятся
выдуманные непустые значения; подробности строками в `config.dist.toml`.
- **Yandex Cloud за деньги.** Распознавание и хранение в Object Storage - **Yandex Cloud за деньги.** Распознавание и хранение в Object Storage
оплачиваются по факту. Прогон на реальных ключах ради проверки кода запрещён — оплачиваются по факту. Прогон на реальных ключах ради проверки кода запрещён —
подставляй `internal/adapter/recognizer/memory.go`. подставляй `internal/adapter/recognizer/memory.go`.
+19 -2
View File
@@ -61,8 +61,25 @@ secure_cookie = true
# Telegram Bot Configuration # Telegram Bot Configuration
[telegram] [telegram]
# Токен Telegram бота (получить у @BotFather в Telegram) # Токен Telegram бота (получить у @BotFather в Telegram).
bot_token = "your_telegram_bot_token_here" #
# Пустое значение — объявленный режим: сервис поднимается без Telegram и
# работает одним входом, по HTTP. Бот при этом не заводится, записи из Telegram
# не принимаются, а ответы на задачи, принятые оттуда прежде, не уходят —
# недоставка видна записью журнала, расшифровка достаётся из панели и по HTTP.
# Старт роняет только один случай — Telegram ответил, что бота по такому токену
# нет: ждать тут нечего, это опечатка в настройке. Недоступность Telegram (сеть,
# DNS, авария Bot API) подъёму не мешает: сервис встаёт без бота и говорит об
# этом записью журнала, потому что основной вход у него другой.
#
# Локальный прогон идёт именно так — боевым токеном запускаться запрещено:
# второй процесс с тем же токеном перехватывает обновления у работающего.
# Пустого токена для подъёма мало: секции [auth] и [yandex] проверяются на
# старте и роняют процесс на пустых ключах. Наружу при старте не ходит ни одна
# из них, поэтому для локального прогона годятся выдуманные непустые значения —
# адреса [auth] должны лишь разбираться как ссылки. Расшифровка при выдуманных
# ключах не работает: её подменяют в коде.
bot_token = ""
# Таймаут обновлений Telegram бота (в секундах) # Таймаут обновлений Telegram бота (в секундах)
update_timeout = 10 update_timeout = 10
@@ -0,0 +1,58 @@
# Недоступность Telegram подъёму сервиса не мешает
- **Дата:** 2026-08-13
- **Источник:** openspec/changes/archive/2026-08-13-start-without-telegram-token/design.md
## Решение
Старт роняет только один исход сборки клиента бота — ответ Telegram «такого бота
нет». Всё прочее, включая недоступность Telegram и истёкший срок ожидания, даёт
подъём без Telegram: сервис работает по HTTP и говорит о неподнятом входе
записью журнала и метрикой.
## Почему
Очевидный подход был обратный, и он же стоял в первой редакции дизайна: любой
отказ сборки бота роняет старт, потому что «сервис, молча потерявший бота после
опечатки в токене, перестаёт отвечать своим отправителям, и узнать об этом было
бы неоткуда».
Ревью кода показало цену этого подхода. Цитата из источника:
> при `api.telegram.org`, отвечающем молчанием, процесс висит в `getMe` без
> ограничения времени: HTTP-вход не открыт, панель не открыта, `/health` не
> отвечает вовсе, воркеры не запущены, в журнале — ни строки.
То есть перезапуск в минуту чужой аварии оставлял без работы приём по HTTP,
панель и конвейер, которому Telegram не нужен вовсе. Паспорт при этом называет
основным входом приложение, а бот и HTTP API — дополняющими его.
Тем же ревью снят довод, на котором держалась прежняя редакция. Она утверждала,
что «Telegram не признал бота» и «до Telegram не дошли» различать нечем. Цитата
из источника:
> Различать есть чем: ответ Bot API приезжает своим типом с кодом, транспортный
> отказ — нашим после чистки, и одно от другого отделяется проверкой типа.
> Утверждение держалось на незнании библиотеки, а не на её устройстве.
Решение владельца: недоступность Telegram на старт приложения не влияет.
Из него следует второе, без которого оно невыполнимо: ожидание при сборке
ограничено сроком. Пока срока не было, недоступность не отличалась от подъёма.
Срок стоит только на сборке — длинный опрос им не ограничен, иначе он рвался бы
на каждом круге.
## Последствия
- `+` авария Telegram не роняет основной вход, панель и конвейер: сервис
поднимается и обрабатывает уже принятое;
- `+` опечатка в токене по-прежнему заметна: Telegram отвечает отказом, и старт
не проходит;
- `+` молчащий Telegram больше не вешает подъём бессрочно;
- `` долгая недоступность Telegram даёт сервис, работающий без бота, а
отправители в это время не получают ответов. Замена «узнать неоткуда» —
запись журнала при старте и признак поднятости входа метрикой;
- `` токен, не разбирающийся как часть адреса (перенос строки из шаблона
выкладки), Telegram не отвергает — его отвергает разбор адреса, и такой случай
попадает в недоступность, а не в ошибку настройки. Заметен он записью журнала,
а не отказом старта.
+1
View File
@@ -35,6 +35,7 @@
| Дата | Запись | Статус | | Дата | Запись | Статус |
| --- | --- | --- | | --- | --- | --- |
| 2026-08-13 | [Недоступность Telegram подъёму сервиса не мешает](ADR-2026-08-13-telegram-outage-does-not-block-startup.md) | |
| 2026-08-12 | [Файл записи закрыт защищённым полем и отдаётся вошедшему по токену файла](ADR-2026-08-12-protected-file-behind-session.md) | | | 2026-08-12 | [Файл записи закрыт защищённым полем и отдаётся вошедшему по токену файла](ADR-2026-08-12-protected-file-behind-session.md) | |
| 2026-08-12 | [Сессия живёт семь суток и не продлевает саму себя](ADR-2026-08-12-session-without-refresh.md) | | | 2026-08-12 | [Сессия живёт семь суток и не продлевает саму себя](ADR-2026-08-12-session-without-refresh.md) | |
| 2026-08-12 | [Кого пускать в сервис, решает правило провайдера, а не сервис](ADR-2026-08-12-access-delegated-to-provider.md) | | | 2026-08-12 | [Кого пускать в сервис, решает правило провайдера, а не сервис](ADR-2026-08-12-access-delegated-to-provider.md) | |
+20 -10
View File
@@ -14,16 +14,20 @@
от 2026-08-13 его нормы живут в самих шагах, их проверках и от 2026-08-13 его нормы живут в самих шагах, их проверках и
[conventions/go-linters.md](conventions/go-linters.md). [conventions/go-linters.md](conventions/go-linters.md).
- [intake](../openspec/specs/intake/spec.md) — **только приём по HTTP**: приём и - [intake](../openspec/specs/intake/spec.md) — **приём по HTTP плюс наличие
опрос за сессией, имя отправителя не доходит ни до хранилища, ни до журнала, входов**: приём и опрос за сессией, имя отправителя не доходит ни до
метка метрики несёт только известное расширение. Задачи хранилища, ни до журнала, метка метрики несёт только известное расширение, а
незаданный вход Telegram не мешает подъёму. Задачи
`http-handler-tests-never-green` и `no-user-filename-in-log` 2026-08-11, `http-handler-tests-never-green` и `no-user-filename-in-log` 2026-08-11,
`pocketbase-storage` и `oidc-login` 2026-08-12. Приём из Telegram здесь не `pocketbase-storage` и `oidc-login` 2026-08-12,
описан; `local-run-without-telegram-token` 2026-08-13. Приём из Telegram по существу —
кто допущен и как забирается запись — здесь по-прежнему не описан;
- [pipeline](../openspec/specs/pipeline/spec.md) — пустой прогон воркера, захват - [pipeline](../openspec/specs/pipeline/spec.md) — пустой прогон воркера, захват
задачи и срок его протухания, число попыток, состояние «мертва» и пауза перед задачи и срок его протухания, число попыток, состояние «мертва», пауза перед
повтором: задачи `errors-as-instead-of-typecast` 2026-08-11 и повтором и недоставленный ответ отправителю: задачи
`pocketbase-storage` 2026-08-12. Переходы состояний и отмена контекста посреди шага остаются `errors-as-instead-of-typecast` 2026-08-11, `pocketbase-storage` 2026-08-12 и
`local-run-without-telegram-token` 2026-08-13. Переходы состояний и отмена
контекста посреди шага остаются
долгом; что именно не описано, перечисляет раздел `Purpose` самой спеки; долгом; что именно не описано, перечисляет раздел `Purpose` самой спеки;
- [storage](../openspec/specs/storage/spec.md) — где живут запись, её метаданные - [storage](../openspec/specs/storage/spec.md) — где живут запись, её метаданные
и её файл, как файл отдаётся и что видит владелец: задача `pocketbase-storage` и её файл, как файл отдаётся и что видит владелец: задача `pocketbase-storage`
@@ -110,15 +114,21 @@
- **Где работает, что рядом, кто перезапускает:** один контейнер на личном - **Где работает, что рядом, кто перезапускает:** один контейнер на личном
сервере, разворачивает и перезапускает Ansible из `pet-project-server`. Рядом — сервере, разворачивает и перезапускает Ansible из `pet-project-server`. Рядом —
обратный прокси, который публикует HTTP-порт наружу. обратный прокси, который публикует HTTP-порт наружу.
- **Пустой токен бота нельзя разворачивать раньше образа, который его понимает.**
Пустое значение стало объявленным режимом 2026-08-13; версии до неё роняли на
нём старт с кодом 1 **до** открытия порта. Значит, откат образа при уже
применённом пустом токене останавливает не бот, а весь сервис — вместе с HTTP
и панелью. Порядок: сперва образ, потом конфиг; при откате — наоборот.
Воспроизведено ревью кода на прежней версии.
- **Внешние зависимости поимённо и чем каждая отказывает.** Столбец «отвечает - **Внешние зависимости поимённо и чем каждая отказывает.** Столбец «отвечает
медленно» читается вместе с тем, что таймаута нет ни у одного обращения медленно» читается вместе с тем, что таймаута нет ни у одного обращения
наружу — [database.md](database.md), «Настройки с числовым значением»: наружу — [database.md](database.md), «Настройки с числовым значением»:
<!-- канон: поведение → openspec/specs/conversion, recognition --> <!-- канон: поведение → openspec/specs/intake, pipeline -->
| Зависимость | Падает | Отвечает медленно | Молчит | Отдаёт мусор | | Зависимость | Падает | Отвечает медленно | Молчит | Отдаёт мусор |
| --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- |
| Telegram Bot API | Бот не стартует, приложение продолжает работу без него | Скачивание файла висит бесконечно | Длинный опрос пуст, новые задачи не заводятся | Файл скачался битым, отказ вылезет на конвертации | | Telegram Bot API | Сервис поднимается без Telegram и работает по HTTP; старт роняет только ответ «такого бота нет». Норму держит [intake](../openspec/specs/intake/spec.md), «Недоступный или незаданный вход Telegram не мешает подъёму» | На старте — ждём не дольше срока, дальше поднимаемся без Telegram. У поднятого сервиса скачивание файла висит бесконечно: там срока нет | То же, что «отвечает медленно»: на старте — подъём без Telegram по истечении срока, у поднятого — длинный опрос пуст и новые задачи не заводятся | Файл скачался битым, отказ вылезет на конвертации |
| Yandex SpeechKit | Шаг возвращает ошибку, задача остаётся на повтор | Захват держится час, задача не двигается | Операция вечно `in progress`, повтор каждые 5 секунд | Пустой текст — задача завершается заглушкой «на записи нет текста» | | Yandex SpeechKit | Шаг возвращает ошибку, задача остаётся на повтор | Захват держится час, задача не двигается | Операция вечно `in progress`, повтор каждые 5 секунд | Пустой текст — задача завершается заглушкой «на записи нет текста» |
| ↳ *остановка сервиса* | Принятие операции от отмены защищено своим пределом в 10 секунд: операцию там могли принять и начать считать деньги, а потерянный идентификатор заставил бы повтор оплатить ту же запись второй раз. Заливка в Object Storage отменяется штатно — её повтор бесплатен, объект ложится под тем же ключом | — | — | — | | ↳ *остановка сервиса* | Принятие операции от отмены защищено своим пределом в 10 секунд: операцию там могли принять и начать считать деньги, а потерянный идентификатор заставил бы повтор оплатить ту же запись второй раз. Заливка в Object Storage отменяется штатно — её повтор бесплатен, объект ложится под тем же ключом | — | — | — |
| Yandex Object Storage | Заливка падает, задача остаётся в `converted` | То же, что падение: висит до конца захвата | — | SpeechKit не прочитает объект и вернёт отказ операции | | Yandex Object Storage | Заливка падает, задача остаётся в `converted` | То же, что падение: висит до конца захвата | — | SpeechKit не прочитает объект и вернёт отказ операции |
+8 -4
View File
@@ -116,10 +116,14 @@ Ansible из `pet-project-server`). Приложение просто читае
- ключи внешних сервисов не пусты. - ключи внешних сервисов не пусты.
*Расхождение:* `LoadConfig` проверяет только существование файла и разбирает *Расхождение:* `LoadConfig` проверяет только существование файла и разбирает
TOML. Пустой токен бота ловится в `NewTelegramController` уже после старта, и TOML. Пустые ключи Yandex ловятся в конструкторе распознавателя, и там процесс
приложение продолжает работу без бота; пустые ключи Yandex ловятся в выходит с кодом 1. Единого места проверки нет.
конструкторе распознавателя, и вот там процесс уже выходит с кодом 1. Единого
места проверки нет. Токен бота под это расхождение больше не подпадает: он судится при сборке
клиента, до подъёма сервера, и разрез у него объявленный — пустое значение
означает отказ от входа и даёт подъём без Telegram, непустое негодное роняет
старт как ошибка настройки. Нормирует это `openspec/specs/intake`, «Недоступный
или незаданный вход Telegram не мешает подъёму».
Секция `[auth]` — первая, у которой проверка своя и стоит на старте: Секция `[auth]` — первая, у которой проверка своя и стоит на старте:
`AuthConfig.Validate()` зовётся из `main.go` сразу после загрузки и роняет `AuthConfig.Validate()` зовётся из `main.go` сразу после загрузки и роняет
+8 -3
View File
@@ -65,9 +65,14 @@ transcriber — **приложение, а не библиотека**: внеш
вызывающему нужны **данные** ошибки. Достаём `errors.As`. Не плодим типы там, вызывающему нужны **данные** ошибки. Достаём `errors.As`. Не плодим типы там,
где хватает sentinel. где хватает sentinel.
Сегодня в проекте три типизированные ошибки, и данные несёт только одна: Сегодня в проекте две типизированные ошибки, и обе несут данные:
`contract.JobNotFoundError` (состояние и сообщение), `contract.NoopJobError` `contract.JobNotFoundError` (состояние и сообщение) и `contract.NoopJobError`
(состояние), `tg.EmptyBotTokenError` (без полей — уместнее sentinel). (состояние). Третья, `tg.EmptyBotTokenError`, была ровно тем случаем, против
которого написано правило — тип без полей, — и снята задачей
`local-run-without-telegram-token` 2026-08-13; её место занял sentinel
`telegram.ErrEmptyToken`. Рядом с ним живёт `contract.ErrDeliveryChannelDown`
тоже sentinel и по той же причине: заглушка отправителя не знает ни задачи, ни
чата, и нести ей нечего.
## Граница и трансляция: приватный и публичный канал ## Граница и трансляция: приватный и публичный канал
+1
View File
@@ -147,6 +147,7 @@ capability, и третий смысл развёл бы одно слово п
| Таймаут мягкой остановки | 5 секунд | конфиг, `[server] shutdown_timeout` | — | | Таймаут мягкой остановки | 5 секунд | конфиг, `[server] shutdown_timeout` | — |
| Таймаут жёсткой остановки | 20 секунд | конфиг, `[server] force_shutdown_timeout` | — | | Таймаут жёсткой остановки | 20 секунд | конфиг, `[server] force_shutdown_timeout` | — |
| Таймаут обновлений Telegram | 10 секунд | конфиг, `[telegram] update_timeout` | — | | Таймаут обновлений Telegram | 10 секунд | конфиг, `[telegram] update_timeout` | — |
| Срок ожидания Telegram при сборке клиента | 10 секунд | `adapter/telegram.ProbeTimeout` | решение, не замер: одно обращение за `getMe` укладывается в доли секунды, дольше Telegram считается недоступным и сервис поднимается без него. Длинный опрос этим сроком не ограничен — клиент подменяется сразу после сборки |
| Качество кодирования vorbis | `-q:a 4` | `adapter/converter/ffmpeg/ffmpeg.go` | — | | Качество кодирования vorbis | `-q:a 4` | `adapter/converter/ffmpeg/ffmpeg.go` | — |
| Жизнь приглашения завести владельца панели | 30 минут | умолчание PocketBase | — | | Жизнь приглашения завести владельца панели | 30 минут | умолчание PocketBase | — |
| Потолок размера одной записи | 8 ГиБ | `entity.MaxRecordSize` | расчётный потолок в шесть часов с запасом на видео | | Потолок размера одной записи | 8 ГиБ | `entity.MaxRecordSize` | расчётный потолок в шесть часов с запасом на видео |
+12 -6
View File
@@ -226,12 +226,18 @@ API и имя не откатываются обратной правкой по
длительность от подставного источника. Своего теста у длительность от подставного источника. Своего теста у
`adapter/metaviewer/ffmpeg` нет; решение и его цена — в `adapter/metaviewer/ffmpeg` нет; решение и его цена — в
[adr/ADR-2026-08-11-stub-adapters-in-tests.md](adr/ADR-2026-08-11-stub-adapters-in-tests.md); [adr/ADR-2026-08-11-stub-adapters-in-tests.md](adr/ADR-2026-08-11-stub-adapters-in-tests.md);
- **всё, что требует поднять сервис целиком.** Локальный запуск роняет адаптер - **работа сервиса с настоящими внешними собеседниками.** Сам сервис поднять
Telegram: он проверяет токен обращением к Telegram, а боевым токеном теперь можно: с пустым `telegram.bot_token` он встаёт и работает одним входом
запускаться запрещено. Значит поведенческая верификация живым прогоном (`openspec/specs/intake`, «Недоступный или незаданный вход Telegram не мешает
недоступна ни одной задаче, и заменяют её проверки поверх настоящего роутера подъёму»). Живой прогон — осмотр HTTP, панели, журнала и остановки — доступен
хранилища. Замечено 2026-08-12 задачей `oidc-login`; своей задачи на это пока теперь любой задаче. Прежняя формулировка «всё, что требует поднять сервис целиком»
нет. снята задачей `local-run-without-telegram-token` 2026-08-13.
**Остаток**: за настоящий Telegram, SpeechKit и Object Storage живой прогон
по-прежнему не отвечает — боевым токеном запускаться запрещено, ключи Yandex в
прогоне выдуманные, а распознавание подменяют в коде. Проверить живьём можно
подъём, отказ старта, маршруты и остановку; нельзя — приём из Telegram,
расшифровку и заливку.
## Журнал дефектов ## Журнал дефектов
+7
View File
@@ -281,6 +281,13 @@ Telegram отправителю.
отдают готовым. Правило — [conventions/logging.md](conventions/logging.md), отдают готовым. Правило — [conventions/logging.md](conventions/logging.md),
случай — [review.md](review.md), оракул — `internal/adapter/telegram/bot_test.go`. случай — [review.md](review.md), оракул — `internal/adapter/telegram/bot_test.go`.
Ещё один путь закрыт задачей `local-run-without-telegram-token` 2026-08-13, и до
неё он был открыт: токен, не разбирающийся как часть адреса (перенос строки из
шаблона выкладки, невычищенная `%`-последовательность), роняет сборку клиента
**раньше** обращения к нему — то есть мимо чистки на границе клиента. Отказ
конструктора теперь чистится отдельно. Нашло это ревью кода тремя проходами
независимо; оракул — там же, в `bot_test.go`.
## Что вне модели ## Что вне модели
Перечислить явно. Перечислить явно.
+26
View File
@@ -0,0 +1,26 @@
package telegram
import (
"git.vakhrushev.me/av/transcriber/internal/contract"
)
// AbsentMessageSender подставляется вместо отправителя Telegram, когда токен
// бота не задан и клиента заводить не из чего. Он ничего не отправляет и на
// всякий ответ отдаёт `contract.ErrDeliveryChannelDown`.
//
// Заглушка, а не пустой отправитель: необязательная зависимость, доехавшая до
// ядра нулём, роняет процесс на первой же задаче из Telegram, а проверка на
// месте употребления завела бы в ядре знание о том, как собран сервис.
//
// Молчит он намеренно. Записать недоставку заглушке нечем: контракт отправки
// несёт текст, чат и сообщение для ответа, а идентификатора задачи в нём нет.
// Пишет поэтому шаг конвейера, который задачу знает.
type AbsentMessageSender struct{}
func NewAbsentMessageSender() *AbsentMessageSender {
return &AbsentMessageSender{}
}
func (s *AbsentMessageSender) Send(_ string, _ int64, _ *int) error {
return contract.ErrDeliveryChannelDown
}
+37
View File
@@ -0,0 +1,37 @@
package telegram
import (
"log/slog"
"net/http"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"git.vakhrushev.me/av/transcriber/internal/contract"
)
// Заглушка отдаёт «канал не поднят» и молчит: записать недоставку ей нечем —
// идентификатора задачи контракт отправки не несёт, и пишет её шаг конвейера.
func TestAbsentSenderReportsChannelDown(t *testing.T) {
sender := NewAbsentMessageSender()
err := sender.Send("расшифровка записи", 100, nil)
require.ErrorIs(t, err, contract.ErrDeliveryChannelDown)
}
// Непустой годный токен по-прежнему даёт настоящего отправителя: прежний путь
// сохранён, и меняется только то, что клиента теперь отдают готовым.
func TestSenderIsBuiltFromLiveBot(t *testing.T) {
bot, _ := newProbeBot(t, func(w http.ResponseWriter, _ *http.Request) {
if _, err := w.Write([]byte(getMeResponse)); err != nil {
t.Errorf("подставной Telegram не смог ответить: %v", err)
}
})
sender := NewTelegramMessageSender(bot, slog.New(slog.DiscardHandler))
require.NotNil(t, sender)
assert.Same(t, bot, sender.bot, "отправитель говорит с тем же клиентом, что и транспорт")
}
+28 -1
View File
@@ -7,6 +7,7 @@ import (
"net/http" "net/http"
"net/url" "net/url"
"strings" "strings"
"time"
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
) )
@@ -43,9 +44,35 @@ func newBot(token, endpoint string, logger *slog.Logger) (*tgbotapi.BotAPI, erro
return nil, fmt.Errorf("failed to set telegram logger: %w", err) return nil, fmt.Errorf("failed to set telegram logger: %w", err)
} }
return tgbotapi.NewBotAPIWithClient(token, endpoint, &safeClient{inner: &http.Client{}}) // Сборка ходит за `getMe` и стоит на пути старта — раньше HTTP-сервера,
// панели и воркеров. Без срока ожидания молчащий Telegram (соединение
// принято, ответа нет) вешал бы весь подъём бессрочно: порт не слушается,
// проба здоровья не отвечает, а в журнале ни строки.
probe := &safeClient{inner: &http.Client{Timeout: ProbeTimeout}}
// Отказ конструктора чистится здесь, а не клиентом: адрес собирается
// строкой с токеном внутри, и `http.NewRequest` падает на его разборе
// **до** обращения к клиенту — то есть мимо `safeClient`. Токен с
// управляющим символом или неверной `%`-последовательностью иначе уезжает
// в журнал целиком: перенос строки в конце значения ловится так же.
bot, err := tgbotapi.NewBotAPIWithClient(token, endpoint, probe)
if err != nil {
return nil, WithoutURL(err)
} }
// Дальше живёт длинный опрос, и срок ему не нужен: он ждёт обновлений
// столько, сколько задано настройкой, и клиент со сроком рвал бы его.
bot.Client = &safeClient{inner: &http.Client{}}
return bot, nil
}
// ProbeTimeout — сколько ждём Telegram при сборке клиента. Число выбрано
// решением, а не замером: одно обращение за `getMe` укладывается в доли
// секунды, а десять секунд — потолок, после которого Telegram считается
// недоступным и сервис поднимается без него.
const ProbeTimeout = 10 * time.Second
// safeClient — клиент, чей отказ не несёт адреса. Библиотека объявляет // safeClient — клиент, чей отказ не несёт адреса. Библиотека объявляет
// зависимость интерфейсом `HTTPClient` и возвращает наш отказ вызывающему // зависимость интерфейсом `HTTPClient` и возвращает наш отказ вызывающему
// нетронутым, поэтому чистка отсюда доходит до каждого вызова Bot API. // нетронутым, поэтому чистка отсюда доходит до каждого вызова Bot API.
+32
View File
@@ -63,6 +63,27 @@ func TestBotAPIFailureDoesNotCarryToken(t *testing.T) {
}) })
} }
// Токен, ломающий разбор адреса, — второй путь отказа конструктора, и до
// недавнего он был открыт: `http.NewRequest` падает раньше обращения к клиенту,
// то есть мимо чистки на его границе. Так выглядит перенос строки, приехавший
// с секретом из шаблона выкладки, и невычищенная `%`-последовательность.
func TestBotConstructionFailureOnUnparsableTokenDoesNotCarryToken(t *testing.T) {
broken := map[string]string{
"перенос строки": probeToken + "\n",
"негодная escape-пара": "7654321:AAH%zzSECRETtokenVALUE",
}
for name, token := range broken {
t.Run(name, func(t *testing.T) {
_, err := newBot(token, tgbotapi.APIEndpoint, slog.New(slog.DiscardHandler))
require.Error(t, err)
assert.NotContains(t, err.Error(), token, "токен уехал в отказ: %v", err)
assert.NotContains(t, err.Error(), "api.telegram.org", "адрес остался в отказе: %v", err)
})
}
}
// Отказ конструктора несёт тот же путь: `NewBotAPIWithClient` ходит за `getMe`, // Отказ конструктора несёт тот же путь: `NewBotAPIWithClient` ходит за `getMe`,
// и контейнер, стартующий раньше сети, печатал бы токен в первую же секунду. // и контейнер, стартующий раньше сети, печатал бы токен в первую же секунду.
func TestBotConstructionFailureDoesNotCarryToken(t *testing.T) { func TestBotConstructionFailureDoesNotCarryToken(t *testing.T) {
@@ -92,10 +113,21 @@ func TestLibraryLoggerRedactsToken(t *testing.T) {
} }
// Пустой токен — законный исход подъёма без Telegram, и узнаётся он по смыслу. // Пустой токен — законный исход подъёма без Telegram, и узнаётся он по смыслу.
// Обратное тоже нормируется: отказ негодного токена не должен читаться как
// отказ от входа, иначе сборка при старте подставит заглушку там, где нужен
// отказ, и молча потеряет бота.
func TestEmptyTokenIsRecognizedByValue(t *testing.T) { func TestEmptyTokenIsRecognizedByValue(t *testing.T) {
_, err := NewBot("", slog.New(slog.DiscardHandler)) _, err := NewBot("", slog.New(slog.DiscardHandler))
require.ErrorIs(t, err, ErrEmptyToken) require.ErrorIs(t, err, ErrEmptyToken)
server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
server.Close()
_, err = newBot(probeToken, server.URL+"/bot%s/%s", slog.New(slog.DiscardHandler))
require.Error(t, err)
require.NotErrorIs(t, err, ErrEmptyToken)
} }
// WithoutURL снимает адрес, но не причину: `errors.Is` по цепочке продолжает // WithoutURL снимает адрес, но не причину: `errors.Is` по цепочке продолжает
+6 -9
View File
@@ -15,18 +15,15 @@ type TelegramMessageSender struct {
logger *slog.Logger logger *slog.Logger
} }
func NewTelegramMessageSender(botToken string, logger *slog.Logger) (*TelegramMessageSender, error) { // NewTelegramMessageSender принимает готового клиента, а не токен. Клиента
// Клиент заводится единой точкой: её отказ не несёт токена, а отказ // заводит сборка при старте — одного на отправителя и на транспорт бота: пока
// конструктора несёт — `NewBotAPI` зовёт `getMe`. // его строили здесь и там порознь, два пути одного старта разошлись в том,
bot, err := NewBot(botToken, logger) // терпеть ли негодный токен, и согласовывать их приходилось руками.
if err != nil { func NewTelegramMessageSender(bot *tgbotapi.BotAPI, logger *slog.Logger) *TelegramMessageSender {
return nil, err
}
return &TelegramMessageSender{ return &TelegramMessageSender{
bot: bot, bot: bot,
logger: logger, logger: logger,
}, nil }
} }
func (s *TelegramMessageSender) Send(text string, chatId int64, replyToMessageId *int) error { func (s *TelegramMessageSender) Send(text string, chatId int64, replyToMessageId *int) error {
+13 -1
View File
@@ -1,6 +1,18 @@
package contract package contract
import "fmt" import (
"errors"
"fmt"
)
// ErrDeliveryChannelDown — канал, которым отвечают отправителю, не поднят.
// Отдаётся отправителем-заглушкой, которого получает ядро, когда вход не
// настроен.
//
// Значение сентинельное, а не тип: соседям по ряду есть что нести — состояние,
// идентификатор задачи, — а этому нечего. Заглушка не знает ни задачи, ни чата,
// и запись о недоставке делает шаг, у которого задача под рукой.
var ErrDeliveryChannelDown = errors.New("delivery channel is down")
type JobNotFoundError struct { type JobNotFoundError struct {
State string State string
-6
View File
@@ -328,9 +328,3 @@ func (c *TelegramController) isAudioDocument(document *tgbotapi.Document) bool {
return false return false
} }
type EmptyBotTokenError struct{}
func (e *EmptyBotTokenError) Error() string {
return "telegram bot token is empty"
}
+22
View File
@@ -44,6 +44,28 @@ var (
[]string{"source_format", "target_format", "error"}, []string{"source_format", "target_format", "error"},
) )
// Поднят ли вход приёма. Единственный канал наблюдения, автоматизированный
// у владельца: потерянный вход иначе виден только строкой журнала при
// старте, а проба здоровья отвечает «ok» и без него.
IntakeUpGauge = promauto.NewGaugeVec(
prometheus.GaugeOpts{
Name: "transcriber_intake_up",
Help: "Whether an intake channel is up (1) or not (0)",
},
[]string{"channel"},
)
// Ответы, которые не удалось доставить отправителю. Работа при этом
// сделана, шаг отказа не объявляет, и без счётчика недоставка видна только
// в журнале — до его ротации.
UndeliveredReplyCounter = promauto.NewCounterVec(
prometheus.CounterOpts{
Name: "transcriber_undelivered_reply_count",
Help: "Count of replies that could not be delivered to the sender",
},
[]string{"reason"},
)
// Размер файла после конвертации (в байтах) // Размер файла после конвертации (в байтах)
OutputFileSizeHistogram = promauto.NewHistogramVec( OutputFileSizeHistogram = promauto.NewHistogramVec(
prometheus.HistogramOpts{ prometheus.HistogramOpts{
+53 -4
View File
@@ -551,19 +551,43 @@ func (s *TranscribeService) failJob(job *entity.TranscribeJob, holder string, jo
return s.send(job, errorMessage) return s.send(job, errorMessage)
} }
// send отвечает отправителю там, откуда пришла запись, и отказ отправки // send отвечает отправителю там, откуда пришла запись. Отказ отправки поднимает
// поднимает вверх: он принадлежит шагу. // вверх: он принадлежит шагу.
//
// Кроме недоставки — её шаг записывает и завершается без отказа. Ответ уходит
// после того, как достигнутое состояние сохранено: работа к этой минуте
// сделана, и объявленный отказ засчитался бы воркеру сбоем и лёг бы владельцу
// записью отказа. Повтор делу не помогает — ни бот, ни адресат от ожидания не
// появятся, — поэтому причина недоставки живёт в журнале, а не в состоянии
// задачи.
//
// Служебные поля завершённой задачи отказ бы при этом не переписал: переход в
// терминальное состояние снимает захват, и повторная запись натыкается на
// «захват потерян». Довод держится на счётчике и журнале, а не на этом.
func (s *TranscribeService) send(job *entity.TranscribeJob, text string) error { func (s *TranscribeService) send(job *entity.TranscribeJob, text string) error {
if job.Source != entity.SourceTelegram { if job.Source != entity.SourceTelegram {
return nil return nil
} }
// Адресата у задачи нет: отвечать некуда, и повторять нечего. Уровень здесь
// выше, чем у неподнятого канала, и это не педантизм: пустой чат у задачи
// из Telegram — симптом порчи записи, а самый коварный её источник назван
// инвариантом «колонки очереди правятся в четырёх местах». Утони этот
// сигнал в одном ряду со штатным «бот не настроен» — и обнуление колонки
// заметит только отправитель, переставший получать ответы.
if job.TgChatId == nil { if job.TgChatId == nil {
s.logger.Error("Telegram chat not specified", "job_id", job.Id) s.undelivered(job, slog.LevelError, "chat is not specified")
return fmt.Errorf("tg chat id not specified, job id: %s", job.Id) return nil
} }
if err := s.tgSender.Send(text, *job.TgChatId, job.TgReplyMessageId); err != nil { if err := s.tgSender.Send(text, *job.TgChatId, job.TgReplyMessageId); err != nil {
// Канал не поднят: сервис работает без этого входа, и это объявленный
// режим, а не поломка.
if errors.Is(err, contract.ErrDeliveryChannelDown) {
s.undelivered(job, slog.LevelWarn, "delivery channel is down")
return nil
}
s.logger.Error("Failed to sent message to client", "job_id", job.Id) s.logger.Error("Failed to sent message to client", "job_id", job.Id)
return fmt.Errorf("failed to sent message to client, job id: %s, err: %w", job.Id, err) return fmt.Errorf("failed to sent message to client, job id: %s, err: %w", job.Id, err)
} }
@@ -571,6 +595,31 @@ func (s *TranscribeService) send(job *entity.TranscribeJob, text string) error {
return nil return nil
} }
// undelivered записывает недоставленный ответ и считает его в метрику. Уровень
// приходит от причины: объявленный режим — «может стать проблемой», порча
// записи — событие для разбора.
//
// Идентификатор задачи обязателен, иначе владелец видит, что ответ не ушёл, но
// не может найти, чей; текста ответа в записи нет — он содержимое чужой записи.
//
// Счётчик нужен потому, что журнал контейнера живёт до ротации, а вопрос «кому
// не ответили за последние сутки» задают позже.
func (s *TranscribeService) undelivered(job *entity.TranscribeJob, level slog.Level, reason string) {
metrics.UndeliveredReplyCounter.WithLabelValues(reason).Inc()
// Уровень выбирается ветвлением, а не передачей контекста: контекст здесь
// брать неоткуда — ответ идёт после сохранения состояния, — а выдуманный
// `context.Background()` соврал бы про отмену и цеплялся бы правилами.
switch level {
case slog.LevelError:
s.logger.Error(undeliveredMessage, "job_id", job.Id, "reason", reason)
default:
s.logger.Warn(undeliveredMessage, "job_id", job.Id, "reason", reason)
}
}
const undeliveredMessage = "Reply was not delivered"
// notify отвечает отправителю там, где поднимать отказ некуда: задача уже // notify отвечает отправителю там, где поднимать отказ некуда: задача уже
// доведена до конца, и отказ отправки остаётся записью в журнале владельца. // доведена до конца, и отказ отправки остаётся записью в журнале владельца.
func (s *TranscribeService) notify(job *entity.TranscribeJob, text string) { func (s *TranscribeService) notify(job *entity.TranscribeJob, text string) {
+140
View File
@@ -0,0 +1,140 @@
package service
import (
"bytes"
"log/slog"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
"git.vakhrushev.me/av/transcriber/internal/contract"
"git.vakhrushev.me/av/transcriber/internal/entity"
)
// Ответ отправителю уходит после того, как достигнутое состояние сохранено.
// Значит, недоставка не может быть отказом шага: объявленный отказ засчитался
// бы воркеру сбоем, лёг бы владельцу записью отказа и переписал бы служебные
// поля завершённой задачи. Причин недоставки две, исход у них общий.
// downSender изображает неподнятый канал доставки: так ведёт себя заглушка,
// которую ядро получает вместо отправителя Telegram.
type downSender struct {
calls int
}
func (s *downSender) Send(string, int64, *int) error {
s.calls++
return contract.ErrDeliveryChannelDown
}
// journalEnv пересобирает сервис с названным отправителем и своим журналом:
// утверждения судят и состояние задачи, и то, что увидел владелец.
func journalEnv(
t *testing.T,
env *pipelineEnv,
rec contract.AudioRecognizer,
sender contract.TelegramMessageSender,
) (*TranscribeService, *bytes.Buffer) {
t.Helper()
journal := &bytes.Buffer{}
svc := NewTranscribeService(
env.jobRepo,
env.fileRepo,
&okMetaViewer{},
&failingConverter{},
rec,
sender,
slog.New(slog.NewTextHandler(journal, &slog.HandlerOptions{Level: slog.LevelDebug})),
)
return svc, journal
}
// Канал не поднят: задача доводится до конца, шаг отказа не объявляет, а
// владелец узнаёт о недоставке из журнала.
func TestUndeliveredOnDownChannelKeepsJobDone(t *testing.T) {
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
rec := &scriptedRecognizer{result: entity.NewInProgressResult()}
job := transcribingJob(t, env, rec)
rec.result = entity.NewCompletedResult()
rec.text = "расшифровка записи"
sender := &downSender{}
svc, journal := journalEnv(t, env, rec, sender)
// Шаг завершается без отказа — именно это воркер считает в свой счётчик.
require.NoError(t, svc.FindAndRunTranscribeCheckJob(t.Context()))
assert.Equal(t, 1, sender.calls, "ответ до отправителя доехал")
after, err := env.jobRepo.GetByID(job.Id)
require.NoError(t, err)
assert.Equal(t, entity.StateDone, after.State, "задача осталась в достигнутом состоянии")
require.NotNil(t, after.TranscriptionText)
assert.Equal(t, "расшифровка записи", *after.TranscriptionText, "расшифровка сохранена")
assert.Nil(t, after.ErrorText, "отказ задаче не приписан")
written := journal.String()
assert.Contains(t, written, "Reply was not delivered", "недоставка названа")
assert.Contains(t, written, job.Id, "запись несёт идентификатор задачи")
assert.Contains(t, written, "level=WARN", "объявленный режим — «может стать проблемой»")
assert.NotContains(t, written, "расшифровка записи", "текста расшифровки в журнале нет")
}
// Адресат у задачи не назван: исход тот же. Прежде эта ветка объявляла отказ
// шага на уже завершённой работе.
func TestUndeliveredWithoutChatKeepsJobDone(t *testing.T) {
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
rec := &scriptedRecognizer{result: entity.NewInProgressResult()}
job := transcribingJob(t, env, rec)
// Задача из Telegram, у которой чат не назван: такую отдаёт правка в панели.
// Колонка чистится мимо захвата — иначе setup унёс бы задачу у шага.
record, err := env.app.FindRecordById(migrations.JobsCollection, job.Id)
require.NoError(t, err)
record.Set("tg_chat_id", nil)
require.NoError(t, env.app.Save(record))
rec.result = entity.NewCompletedResult()
rec.text = "расшифровка записи"
sender := &downSender{}
svc, journal := journalEnv(t, env, rec, sender)
require.NoError(t, svc.FindAndRunTranscribeCheckJob(t.Context()))
assert.Equal(t, 0, sender.calls, "до отправителя дело не дошло: адресата нет")
after, err := env.jobRepo.GetByID(job.Id)
require.NoError(t, err)
assert.Equal(t, entity.StateDone, after.State)
assert.Nil(t, after.ErrorText, "отказ задаче не приписан")
written := journal.String()
assert.Contains(t, written, "Reply was not delivered")
assert.Contains(t, written, job.Id)
assert.Contains(t, written, "chat is not specified", "причина названа")
assert.Contains(t, written, "level=ERROR",
"порча записи громче штатного «бот не настроен»: иначе сигнал утонет")
}
// Запись, принятая по HTTP, до отправителя не доходит вовсе: недоставки нет, и
// записи о ней в журнале быть не должно — иначе журнал владельца заполнят
// строки о задачах основного входа.
func TestApiJobDoesNotReachSenderAndLogsNothing(t *testing.T) {
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
job, err := env.service.CreateJobFromApi(t.Context(), strings.NewReader("запись"), "voice.ogg")
require.NoError(t, err)
sender := &downSender{}
svc, journal := journalEnv(t, env, &scriptedRecognizer{}, sender)
require.NoError(t, svc.send(job, "расшифровка записи"))
assert.Equal(t, 0, sender.calls, "отправителя не звали")
assert.NotContains(t, journal.String(), "Reply was not delivered", "недоставки не было")
}
+17 -27
View File
@@ -17,12 +17,11 @@ import (
ffmpegmv "git.vakhrushev.me/av/transcriber/internal/adapter/metaviewer/ffmpeg" ffmpegmv "git.vakhrushev.me/av/transcriber/internal/adapter/metaviewer/ffmpeg"
"git.vakhrushev.me/av/transcriber/internal/adapter/recognizer/yandex" "git.vakhrushev.me/av/transcriber/internal/adapter/recognizer/yandex"
pbrepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase" pbrepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase"
"git.vakhrushev.me/av/transcriber/internal/adapter/telegram"
"git.vakhrushev.me/av/transcriber/internal/config" "git.vakhrushev.me/av/transcriber/internal/config"
"git.vakhrushev.me/av/transcriber/internal/contract"
httpcontroller "git.vakhrushev.me/av/transcriber/internal/controller/http" httpcontroller "git.vakhrushev.me/av/transcriber/internal/controller/http"
tgcontroller "git.vakhrushev.me/av/transcriber/internal/controller/tg" tgcontroller "git.vakhrushev.me/av/transcriber/internal/controller/tg"
"git.vakhrushev.me/av/transcriber/internal/controller/worker" "git.vakhrushev.me/av/transcriber/internal/controller/worker"
"git.vakhrushev.me/av/transcriber/internal/metrics"
"git.vakhrushev.me/av/transcriber/internal/service" "git.vakhrushev.me/av/transcriber/internal/service"
"github.com/joho/godotenv" "github.com/joho/godotenv"
"github.com/pocketbase/pocketbase/apis" "github.com/pocketbase/pocketbase/apis"
@@ -89,9 +88,9 @@ func main() {
metaviewer := ffmpegmv.NewFfmpegMetaViewer() metaviewer := ffmpegmv.NewFfmpegMetaViewer()
converter := ffmpegconv.NewFfmpegConverter() converter := ffmpegconv.NewFfmpegConverter()
tgSender, err := telegram.NewTelegramMessageSender(cfg.Telegram.BotToken, logger) tgBot, tgSender, err := buildTelegram(cfg.Telegram.BotToken, logger)
if err != nil { if err != nil {
logger.Error("failed to create audio telegram sender", "error", err) logger.Error("Failed to create Telegram bot", "error", err)
os.Exit(1) os.Exit(1)
} }
@@ -141,13 +140,17 @@ func main() {
UserWhiteList: cfg.Server.UsersWhiteList, UserWhiteList: cfg.Server.UsersWhiteList,
} }
// Клиента бота заводит единая точка: её отказ не несёт токена, тогда как // Транспорт поднимается только там, где есть клиент: о том, что бота нет,
// отказ `NewBotAPI` несёт — он ходит за `getMe`. // сказано выше единственной записью, и вторая здесь была бы записью о том
tgController, err := newTelegramController(cfg.Telegram.BotToken, tgConfig, transcribeService, jobRepo, logger) // же факте.
var tgController *tgcontroller.TelegramController
if tgBot != nil {
tgController, err = tgcontroller.NewTelegramController(tgConfig, tgBot, transcribeService, jobRepo, logger)
if err != nil { if err != nil {
logger.Error("Failed to create Telegram controller", "error", err) logger.Error("Failed to create Telegram controller", "error", err)
// Не останавливаем приложение, если Telegram бот не создан os.Exit(1)
} else { }
// Запускаем Telegram бот в отдельной горутине // Запускаем Telegram бот в отдельной горутине
wg.Add(1) wg.Add(1)
go func() { go func() {
@@ -179,6 +182,11 @@ func main() {
}(w) }(w)
} }
// Вход по HTTP поднимается всегда: он основной, и отдельного разреза у него
// нет. Признак ставится рядом с признаком Telegram, чтобы владелец судил об
// обоих входах одним отбором.
metrics.IntakeUpGauge.WithLabelValues("http").Set(1)
// Наши маршруты живут на роутере хранилища: панель отдаётся тем же портом, // Наши маршруты живут на роутере хранилища: панель отдаётся тем же портом,
// и второму серверу на нём взяться неоткуда. // и второму серверу на нём взяться неоткуда.
transcribeHandler := httpcontroller.NewTranscribeHandler(jobRepo, transcribeService, logger) transcribeHandler := httpcontroller.NewTranscribeHandler(jobRepo, transcribeService, logger)
@@ -328,21 +336,3 @@ func main() {
logger.Info("Transcriber service stopped") logger.Info("Transcriber service stopped")
} }
// newTelegramController собирает бота и транспорт вокруг него. Токен доходит
// до единой точки `internal/adapter/telegram` и дальше не идёт: транспорт его
// не видит вовсе, а отказ, который увидит журнал, адреса с токеном не несёт.
func newTelegramController(
botToken string,
cfg tgcontroller.TelegramConfig,
transcribeService *service.TranscribeService,
jobRepo contract.TranscriptJobRepository,
logger *slog.Logger,
) (*tgcontroller.TelegramController, error) {
bot, err := telegram.NewBot(botToken, logger)
if err != nil {
return nil, err
}
return tgcontroller.NewTelegramController(cfg, bot, transcribeService, jobRepo, logger)
}
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-08-13
@@ -0,0 +1,207 @@
## 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
Нет.
@@ -0,0 +1,54 @@
## Why
Сервис принимает записи двумя входами — ботом Telegram и HTTP API, — но
поднимается только тогда, когда настроены оба: пустой токен бота кончает старт
отказом раньше, чем встаёт HTTP-сервер. Боевым токеном запускаться запрещено, и
из этого следует, что **поднять сервис и посмотреть на него живьём не может
никто**: всякая задача, меняющая поведение, проверяется одними тестами.
Намерение «работать без Telegram» в сервисе уже есть — отдельное значение «токен
не задан» и терпимость к отказу сборки бота при старте, — но один путь его
отменяет, и потому оно ничего не значит.
## What Changes
- Ненастроенный вход Telegram больше не мешает подъёму: сервис встаёт и работает
оставшимся входом — принимает записи по HTTP, расшифровывает их и отдаёт текст
туда же. Об отсутствии бота сервис говорит одной строкой журнала при старте, а
не молчанием.
- Задача, пришедшая из Telegram и дошедшая до ответа тогда, когда бота нет,
доводится до конца, а факт недоставки уезжает в журнал владельца. Сегодня такая
задача уронила бы процесс.
- Запрет запускаться боевым токеном остаётся: рядом с ним появляется способ
поднять сервис без токена вовсе.
Ломки нет: с заданным токеном не меняется ничего.
## Capabilities
### New Capabilities
Новых нет: оба требования ложатся в capability, чей раздел `Purpose` сам
называет их своим предметом и приглашает дописать.
### Modified Capabilities
- `intake`: добавляется требование о подъёме с ненастроенным входом Telegram —
сервис работает оставшимся входом. Приём из Telegram по существу (кто допущен,
как скачивается запись) остаётся ненормированным, и оговорка спеки об этом
сохраняется;
- `pipeline`: добавляется требование об ответе отправителю, чей вход не поднят —
шаг не роняется, задача доводится до конца, недоставка идёт в журнал.
## Impact
- сборка сервиса при старте: отправитель ответов и клиент бота;
- ответ отправителю в конвейере расшифровки;
- секция `[telegram]` конфига и её образец `config.dist.toml`;
- `CLAUDE.md`, раздел «Запреты» — рядом с запретом на боевой токен встаёт способ
подняться без него;
- `docs/review.md`, подраздел «Недоступно проверке» — строка о недоступности
живого прогона сужается;
- `docs/architecture.md` — перечень capability и то, что каждая нормирует.
Внешних зависимостей, схемы хранилища и контракта HTTP API изменение не трогает.
@@ -0,0 +1,172 @@
# Ревью изменения `start-without-telegram-token` — отчёт триажа
Прогон 2026-08-13. Отчёт сохранён оркестратором: агент триажа записывать
`.md` не вправе.
## Сводка
- **Режим:** по графу; изменение не закоммичено, база диффа `origin/master`.
- **Метка:** `large` — крупное × знакомое. Повторная разметка после правок
дизайна: первая давала `medium`, исходя из того, что ядро не тронуто; правки
ревью дизайна это допущение сняли.
- **Гейт:** зелёный целиком, 13 шагов, включая `-race`, `golangci-lint`,
`govulncheck`. Оракул снят проходом `autotests`, триаж гейт не перезапускал.
- **Находок на входе:** 19 (specs 3, code 6, architecture 3, adversary 4,
ops 3, autotests 0). **Осталось:** 6 в основном списке, 2 гипотезы,
2 кандидата в промоут; срезы названы поимённо.
### План с исходом по каждой теме
| тема | дом | глубина | кто закрывает | исход |
| --- | --- | --- | --- | --- |
| requirements | `openspec/specs` + дельты change | разбор | specs | закрыта, 3 находки |
| autotests | `CLAUDE.md`, «Гейт» | — | autotests | закрыта, 0 находок |
| conventions | `docs/conventions/` | разбор | code | закрыта, 6 находок |
| architecture | `docs/architecture.md` + `passport.md` | доказательство | architecture | закрыта, 3 находки |
| security | `docs/security.md` | доказательство | adversary | закрыта, 4 находки |
| operations | `docs/architecture.md` «Эксплуатация» + `database.md` | доказательство | ops | закрыта, 3 находки |
Тем без дома нет, тем без отчёта нет. Своих тем у проекта нет, `basics` не
запускался — все темы ядра закрыты именными проходами. Побочное следствие:
независимого второго голоса о заниженности метки на прогоне не было.
## Блокирует мердж
### 1. Токен бота уезжает в журнал целиком при опечатке — critical
`internal/adapter/telegram/bot.go` возвращал отказ конструктора без чистки.
Отказ рождается в `http.NewRequest` на разборе адреса — **до** обращения к
клиенту, то есть мимо `safeClient` и `WithoutURL`. Токен с управляющим символом
или неверной `%`-последовательностью печатался в журнал целиком.
Оракул: воспроизведено тремя проходами независимо и триажем отдельно. Нарушены
инвариант `CLAUDE.md` «Секрет не покидает конфиг» (critical) и MUST дельта-спеки
`intake`.
**Исход: починено инлайн.** Отказ конструктора пропущен через `WithoutURL`.
Заодно закрыта дыра в собственной проверке: прежний тест судил запрет **годным**
токеном, то есть случаем, который и так работал. Добавлен тест с токеном,
ломающим разбор адреса.
### 2. Молчащий Telegram вешает старт навсегда — major
Клиент собран из `&http.Client{}` без срока ожидания, сборка стоит до подъёма
сервера. При Telegram, отвечающем молчанием, процесс висит бесконечно: порт не
слушается, `/health` не отвечает, воркеры не запущены, в журнале ни строки.
Поведение предсуществует изменению, но изменение **записывает его нормой**.
Довод дизайна «различать нечем» проверяемо неверен: отказ Bot API приезжает
типом `*tgbotapi.Error` с кодом, транспортный — нашим после чистки.
**Исход: развилка владельцу.** Цена дописана в таблицу отказов
`docs/architecture.md`; выбор поведения — за владельцем.
## Стоит исправить сейчас
### 3. Сервис без Telegram выглядит здоровым — major
`/health` отдаёт статические `200 ok` и о входах не знает; серий `transcriber_*`
на `/metrics` при неподнятом боте ноль; поля «доставлено» в схеме нет. Владелец
узнаёт о потерянном входе только из журнала контейнера и только до ротации.
**Исход: развилка владельцу.** Попутно исправлено фактическое: обоснование нормы
называло третьим последствием перезапись служебных полей завершённой задачи —
такого не бывает, переход в терминальное состояние снимает захват, и повторная
запись натыкается на «захват потерян». Довод сведён к двум последствиям.
### 4. Задача из Telegram, потерявшая чат, считается успешной — major
Было `ERROR` и отказ шага, стало `WARN` и успех. Тем самым снят самый громкий
детектор класса, который `CLAUDE.md` называет самым коварным: колонка, выпавшая
из пары `acquireColumns`/`acquiredRow`, обнуляет чат у задачи, попавшей к
воркеру.
**Исход: развилка владельцу** — развести уровни по причине или оставить.
### 5. Разрез старта не держался ни одним тестом — minor
Инвертируй разрез — весь набор оставался зелёным.
**Исход: починено инлайн.** Решение вынесено из `main` в `telegramFromBot` и
накрыто тремя случаями. Обращение к Telegram отделено от решения намеренно:
обращение ходит в сеть и в проверке недоступно, а разрез проверять надо.
### 6. Образец конфига и три документа описывали снятое поведение — minor
`config.dist.toml` оставлял непустой плейсхолдер, хотя собственный комментарий
рядом объявлял пустой токен режимом: копия образца старт роняла.
`docs/conventions/config.md` описывал снятый механизм строкой «Расхождение», а
она в этом проекте выдаёт индульгенцию будущим ревью. `docs/conventions/errors.md`
перечислял удалённый тип. Маркер канона в `docs/architecture.md` ссылался на
несуществующие capability.
**Исход: починено инлайн, все четыре места.**
## Чем кончились развилки — решения владельца 2026-08-13
- **Находка 2 (молчащий Telegram).** Выбран вариант сверх предложенных:
недоступность Telegram на старт не влияет. Разрез перенесён с «пусто /
непусто» на «ответил ли Telegram»: ответ «такого бота нет» роняет старт,
недоступность даёт подъём без Telegram с записью `WARN`. Из решения следует
срок ожидания при сборке — без него недоступность неотличима от подъёма.
- **Находка 3 (наблюдаемость).** Выбран счётчик и признак входов: метрика
поднятости по каждому входу и счётчик недоставленных ответов с причиной
меткой. Колонку в задаче не заводили — это шаг схемы и необратимое.
- **Находка 4 (уровень).** Уровни разведены: неподнятый вход — `WARN`,
неназванный адресат — `ERROR`.
**Отдельно о самом прогоне.** Живой прогон, снятый проходом `adversary`, оставил
процесс работающим на том же порту, и он держал его ещё час. Часть моих проверок
после переделки мерила этот чужой процесс, а не новую сборку; обнаружено по
отсутствию новой метрики, исправлено остановкой процесса и повторным прогоном.
Кандидат в правило: прогон, поднимающий сервис, обязан снимать его за собой, а
проверяющий — убеждаться, что порт занят его собственной сборкой.
## Гипотезы без доказательства
- **`{"ok":true,"result":null}` считается успешной доставкой.** Механизм доказан
на подставном сервере, вторая половина — что живой Telegram так отвечает — не
доказана и по правилам проекта недоказуема. Предсуществует изменению.
- **Второй `os.Exit(1)` недостижим сегодня.** Приемлемая страховка, не дефект:
конструктор объявляет отказ в сигнатуре, и разобрать его вызывающий обязан.
## Кандидаты в промоут
- **Порядок выкладки: конфиг с пустым токеном нельзя выкатывать раньше бинаря.**
Воспроизведено на `origin/master` в отдельном worktree: откат бинаря при уже
применённом пустом токене останавливает весь сервис. Это правило эксплуатации,
которого в проекте нет; дом — `docs/architecture.md`, «Эксплуатация».
- **Сверка документов на упоминания удалённых идентификаторов.** Три из четырёх
мест находки 6 — прямые ссылки на снесённый код и несуществующие capability.
Ловит это `av-dev:doc-healthcheck`, которого зовут руками.
## Границы покрытия
**Что не проверил ни один проход** (`docs/review.md`, «Недоступно проверке»):
поведение SpeechKit и Object Storage под нагрузкой; реальный профиль нагрузки;
стойкость `ffmpeg` к вредоносному входу; поведение настоящей Authelia; поведение
браузера с куками.
**Перестали проверять сознательно:** разбор вывода настоящего `ffprobe`; работа
сервиса с настоящими внешними собеседниками. Подъём живьём стал доступен как раз
этим изменением, но остаток — приём из Telegram, расшифровка, заливка — не
проверяет никто.
**Чего не принесёт ни один прогон:**
1. Решения проекта не сверялись — `docs/adr/` процессный, прогон его не
открывает. Расхождение с записанным решением ловит `av-dev:doc-healthcheck`.
2. Записанные наблюдения не использовались — `docs/research/` тоже процессный.
Всякое число этого отчёта снято на этом прогоне.
3. Поимённой сверки с руководствами по стилю Go не задавал ни один проход.
4. Альтернативной реализации, с которой можно сдиффить решения, у конвейера нет.
**Сработавшие потолки.** Потолок триажа: 19 находок → 6. Срезано поимённо:
дубли в тестах (близко к вкусовщине; починено попутно, тот же файл правился
находкой 1); избыточность представлений факта «Telegram не поднят» — шесть
вместо четырёх, одно сократимо, последствие не названо; откат бинаря — уехал в
промоут; вырожденный ответ библиотеки — в гипотезы.
**Отдельная находка о самом прогоне:** проходы отдавали сводки пересказом, и
свои блоки «Coverage of this pass» с потолками до триажа дошли не все — узнать,
срезал ли `code` или `adversary` что-то у себя, из отчёта нельзя.
@@ -0,0 +1,90 @@
## Purpose
Приём записи и опрос готовности задачи расшифровки: что считается принятой
записью, что уезжает в ответ и что происходит, когда запись не удалось
прочитать. Плюс наличие входов: с каким из них сервис вправе подняться.
Приём по существу описан пока **только для HTTP** — того, что нормируют
проверки. Про вход Telegram нормировано одно: настроен он или нет и что из этого
следует для подъёма. Кто допущен к боту и как забирается присланная им запись,
требованиями по-прежнему не описано — требование, написанное без проверки, это
предположение, а не норма. Первая задача, которая трогает поведение приёма из
Telegram, дописывает его сюда.
## ADDED Requirements
### Requirement: Недоступный или незаданный вход Telegram не мешает подъёму
Сервис SHALL подниматься, когда вход Telegram поднять не удалось, и MUST
продолжать работу оставшимся входом: приём по HTTP, опрос готовности и конвейер
расшифровки работают в полном объёме. Неподнятый вход MUST быть назван в журнале
**ровно одной** записью уровня `WARN` при старте — с причиной и без значения
токена.
Исключение одно, и оно проходит по тому, **ответил ли Telegram**. Ответ «такого
бота нет» — ошибка настройки: бот по этому токену не появится ни от ожидания, ни
от повтора, и старт MUST кончаться отказом. Сервис, молча потерявший бота после
опечатки в токене, перестаёт отвечать своим отправителям, и узнать об этом было
бы неоткуда.
Всё прочее — недоступность: сеть, DNS, авария Bot API, истёкший срок ожидания.
Она MUST не влиять на подъём. Основной вход сервиса — не Telegram, и класть его
целиком из-за чужой аварии нельзя: перезапуск в такую минуту оставил бы без
работы и приём по HTTP, и панель, и конвейер, которому Telegram не нужен вовсе.
Ожидание при сборке MUST быть ограничено сроком. Без него недоступность
неотличима от подъёма: обращение к Telegram стоит на пути старта, и молчащий
собеседник останавливал бы его бессрочно — без записи, без порта и без пробы
здоровья.
Требование нормирует **наличие входа**, а не приём из него.
#### Scenario: Токен не задан
- **GIVEN** в настройках сервиса токен бота пуст
- **WHEN** сервис запускается
- **THEN** он поднимается и принимает записи по HTTP
- **AND** конвейер расшифровки работает
- **AND** бот не заведён, а в журнале ровно одна запись уровня `WARN` о том, что
он не поднят и почему
#### Scenario: Токен задан и годен
- **GIVEN** в настройках сервиса стоит токен, по которому Telegram признаёт бота
- **WHEN** сервис запускается
- **THEN** он поднимается и работает обоими входами
#### Scenario: Telegram не отвечает
- **GIVEN** в настройках сервиса стоит непустой токен
- **AND** Telegram недоступен либо не отвечает дольше отведённого срока
- **WHEN** сервис запускается
- **THEN** он поднимается и принимает записи по HTTP
- **AND** бот не заведён, а в журнале запись уровня `WARN` с причиной
- **AND** запись не несёт значения токена
#### Scenario: Telegram ответил, что такого бота нет
- **GIVEN** в настройках сервиса стоит непустой токен
- **AND** Telegram отвечает отказом на этот токен
- **WHEN** сервис запускается
- **THEN** старт кончается отказом
- **AND** ни журнал, ни текст отказа не несут значения токена
### Requirement: Поднятые входы видны наблюдателю
Сервис SHALL отдавать признак поднятости по каждому входу приёма отдельной
метрикой. Признак MUST выставляться при сборке входа и MUST различать поднятый
вход и неподнятый.
Требование стоит на том, что иначе потерянный вход не виден ничем: проба
здоровья отвечает «сервис работает» и при неподнятом боте, а запись журнала
живёт до ротации и вопрос «работает ли вход сейчас» не отвечает. Метрика —
единственный канал наблюдения, который у владельца автоматизирован.
#### Scenario: Вход Telegram не поднят
- **GIVEN** сервис поднялся без Telegram
- **WHEN** наблюдатель читает метрики
- **THEN** признак поднятости входа Telegram равен нулю
- **AND** признак поднятости входа HTTP равен единице
@@ -0,0 +1,80 @@
## Purpose
Конвейер расшифровки: как задача движется по состояниям, что делает воркер,
когда работы нет, что считается отказом шага и что бывает с ответом отправителю,
когда доставить его некуда.
Описаны пустой прогон воркера, неделимость захвата и срок его протухания, число
попыток и состояние «мертва», нарастающая пауза перед повтором, условие записи
результата держателем захвата и недоставка ответа при неподнятом входе.
Сознательно не описаны: цепочка переходов `created → converted → transcribe →
done | failed`, отмена контекста посреди шага и освобождение ресурсов внешних
клиентов. Это не значит, что такого поведения нет: оно живёт в коде, а
требования на него не написаны, потому что требование без проверки —
предположение, а не норма. Первая задача, которая трогает любое из
перечисленного, дописывает его сюда.
## ADDED Requirements
### Requirement: Недоставленный ответ не роняет шаг
Шаг конвейера SHALL доводить задачу до достигнутого состояния, когда ответ
отправителю доставить не удалось, и MUST не считать недоставку отказом шага.
Недоставка MUST быть записана в журнал владельца, MUST нести идентификатор
задачи, MUST называть причину и MUST считаться отдельной метрикой с причиной
меткой.
Причин у недоставки две, и исход у них общий: **вход отправителя не поднят**
задача заведена прошлым запуском, а сервис поднялся без этого входа; и **адресат
у задачи не назван** — источником значится Telegram, а чата в задаче нет.
Уровень записи MUST различать эти причины. Неподнятый вход — объявленный режим,
и его уровень «может стать проблемой». Неназванный адресат — симптом порчи
записи: у задачи из Telegram чат есть всегда, и пропасть он может только от
дефекта, самый коварный источник которого назван инвариантом проекта про колонки
очереди. Один уровень на обе причины утопил бы этот сигнал в потоке штатных
записей о ненастроенном боте.
Общий исход — не упрощение, а следствие момента: ответ уходит **после** того, как
достигнутое состояние сохранено. Работа к этой минуте сделана, и объявленный
отказ засчитался бы воркеру сбоем и лёг бы владельцу записью отказа — то есть
соврал бы про исход дважды. Повтор делу не помогает: ни бот, ни адресат от
ожидания не появятся. Поэтому задача остаётся в достигнутом состоянии, в повтор
не уходит и в `failed` не переводится, а причина недоставки живёт в записи
журнала, а не в состоянии задачи.
Идентификатор задачи в записи обязателен: без него владелец видит, что ответ не
ушёл, но не может найти, чей. Текст расшифровки и сообщение отправителя в эту
запись MUST не попадать — приватность содержимого записи требование не
ослабляет.
Отложенной доставки это требование не заводит: ответ, не ушедший сегодня, не
уходит и потом. Забрать расшифровку можно там же, где лежат остальные.
#### Scenario: Вход отправителя не поднят
- **GIVEN** задача принята входом Telegram прошлым запуском сервиса
- **AND** сервис поднялся без этого входа
- **WHEN** шаг конвейера доходит до ответа отправителю
- **THEN** шаг завершается без отказа, и воркер не считает прогон сбоем
- **AND** задача остаётся в достигнутом состоянии, в повтор не уходит и в
`failed` не переводится
- **AND** в журнале есть запись уровня `WARN` о недоставке с идентификатором
задачи и причиной
- **AND** счётчик недоставленных ответов вырос с этой причиной меткой
- **AND** ни текста расшифровки, ни сообщения отправителя в этой записи нет
#### Scenario: Адресат у задачи не назван
- **GIVEN** у задачи источником значится Telegram, а чат не назван
- **WHEN** шаг конвейера доходит до ответа отправителю
- **THEN** шаг завершается без отказа, и воркер не считает прогон сбоем
- **AND** задача остаётся в достигнутом состоянии
- **AND** в журнале есть запись уровня `ERROR` о недоставке с идентификатором
задачи и причиной: неназванный адресат — симптом порчи записи
#### Scenario: Отвечать некуда, потому что запись пришла не из Telegram
- **GIVEN** задача принята по HTTP
- **WHEN** шаг конвейера доходит до ответа отправителю
- **THEN** шаг завершается без отказа и без записи о недоставке
@@ -0,0 +1,127 @@
## 1. Отправитель, который не отправляет
- [x] 1.1 Завести среди контрактов значение отказа «канал доставки не поднят» —
рядом с «работы нет» и «захват потерян», узнаваемое тем же способом
- [x] 1.2 Завести в пакете отправителя Telegram заглушку, реализующую контракт
отправки: она ничего не отправляет и на всякий ответ возвращает это
значение
- [x] 1.3 Проверить тестом, что заглушка возвращает именно его и ничего не пишет
сама
## 2. Ответ отправителю в конвейере
- [x] 2.1 Научить ответ отправителю узнавать это значение: пишется запись уровня
`WARN` с идентификатором задачи и причиной, шаг завершается без отказа
- [x] 2.2 Свести к тому же исходу вторую причину недоставки — задачу источника
Telegram без названного чата: сегодня она даёт отказ шага на уже
завершённой работе, то есть ложный сбой в счётчике воркера и перезапись
служебных полей
- [x] 2.3 Проверить, что в записи нет ни текста расшифровки, ни сообщения
отправителя
- [x] 2.4 Тест конвейера: задача источника Telegram доходит до ответа через
заглушку — шаг без отказа, состояние задачи не откатывается, в повтор она
не уходит и в `failed` не переводится
- [x] 2.5 Тест: задача источника Telegram без чата даёт тот же исход
- [x] 2.6 Тест: задача, принятая по HTTP, до заглушки не доходит и записи о
недоставке не порождает
## 3. Сборка при старте
- [x] 3.1 Свести сборку клиента бота к одной: отправитель ответов принимает
готового клиента вместо токена, транспорт получает того же
- [x] 3.2 Поставить разрез в этом единственном месте: пустой токен даёт заглушку
и одну запись уровня `WARN` о неподнятом боте, любой другой отказ сборки
роняет старт
- [x] 3.3 Тест на непустой токен, с которым бот не заводится: старт роняется.
Живой Telegram не нужен — адрес подставляется, как в имеющемся тесте
клиента
- [x] 3.4 Проверить, что ни запись о неподнятом боте, ни текст отказа старта не
несут значения токена
- [x] 3.5 Проверить остановку: сигнал остановки на конфиге с пустым токеном
завершает процесс тем же кодом и в тот же срок, что и с токеном
- [x] 3.6 Удалить неупотребляемый тип отказа «токен пуст» в транспорте бота —
третье представление того же факта
## 4. Настройки и их образец
- [x] 4.1 Описать в образце конфига, что пустой токен означает подъём без
Telegram и что при этом перестаёт работать
- [x] 4.2 Назвать там же остальные секции, без которых сервис не поднимется:
настройки входа и Yandex требуют непустых значений, при локальном прогоне
годятся выдуманные, наружу при старте не ходит ни одна
## 5. Проверки
- [x] 5.1 Тест на сборку отправителя с непустым токеном: прежний путь сохранён
- [x] 5.2 Живой прогон: конфиг с пустым токеном и заполненными по 4.2 секциями,
`GET /health` отвечает `200`, в выводе есть запись о неподнятом боте
- [x] 5.3 `task gate` зелёный
## 7. Развилки ревью кода — решения владельца 2026-08-13
- [x] 7.1 Недоступность Telegram на старт не влияет: разрез перенесён на «ответил
ли Telegram». Ответ «такого бота нет» роняет старт, всё прочее даёт подъём
без Telegram с записью `WARN`
- [x] 7.2 Ограничить ожидание при сборке клиента сроком — без него недоступность
неотличима от подъёма; длинный опрос сроком не ограничен
- [x] 7.3 Признак поднятости входов метрикой и счётчик недоставленных ответов с
причиной меткой
- [x] 7.4 Развести уровни недоставки: неподнятый вход — `WARN`, неназванный
адресат — `ERROR` (симптом порчи записи)
- [x] 7.5 Проверки на все четыре ветки сборки и на оба уровня недоставки
## 6. Документы
- [x] 6.1 `CLAUDE.md`, раздел «Запреты»: рядом с запретом на боевой токен встаёт
способ подняться без него
- [x] 6.2 `docs/review.md`, подраздел «Недоступно проверке»: строка о живом
прогоне сужается **с остатком** — подъём и осмотр стали доступны, прогон с
пустыми ключами Yandex по-прежнему нет
- [x] 6.3 `docs/architecture.md`: перечень capability отражает, что нормируют
`intake` и `pipeline` после этого изменения
- [x] 6.4 `docs/architecture.md`, таблица отказов внешних зависимостей: строка
про Telegram сегодня обещает дежурному «бот не стартует, приложение
продолжает работу без него» — привести к новому разрезу ссылкой на
требование, не перенося поведение в обзор
## Критерии приёмки
Первые три — дословно из записи задачи `local-run-without-telegram-token`.
**Четвёртый переписан** решением владельца на чекпоинте 2026-08-13: в прежней
редакции он требовал, чтобы задача осталась пригодной к повтору либо перешла в
`failed`, а дизайн отверг оба исхода с ценой, и норма `pipeline` требует прямо
обратного. Прежняя редакция сделала бы приёмку зелёной на поведении, которое это
же изменение запрещает. Запись задачи поправлена тем же решением.
- Сервис поднимается с пустым токеном бота: HTTP отвечает, воркеры идут, бот не
создан. Оракул — запуск с конфигом без токена и запрос `GET /health`: код 200.
- Отсутствие бота названо в журнале один раз при старте, а не молчанием. Оракул —
тот же запуск: в выводе есть строка о том, что бот не поднят и почему.
- Поведение с настоящим токеном не изменилось. Оракул — тест на создание
отправителя с непустым токеном: прежний путь сохранён.
- Задача из Telegram, дошедшая до ответа при отсутствующем боте, не роняет
процесс и не теряется молча: она остаётся в достигнутом состоянии, в повтор не
уходит и в `failed` не переводится, а недоставка видна записью журнала с
идентификатором задачи. Оракул — тест конвейера с задачей источника Telegram и
заглушкой вместо отправителя.
- Задача источника Telegram без названного чата даёт тот же исход, а не отказ
шага. Оракул — тест конвейера на такой задаче: воркеру сбой не засчитан,
служебные поля завершённой задачи не переписаны.
Сверх записи задачи — из ревью дизайна:
- Непустой токен, с которым бот не заводится, роняет старт. Оракул — тест с
подставным адресом Bot API.
- Записей о неподнятом боте ровно одна. Оракул — живой прогон с пустым токеном:
отбор по журналу даёт одну строку, а не две.
- Клиент бота собирается в одном месте. Оракул — отправитель ответов принимает
клиента, а не токен, и `NewBot` зовётся из сборки при старте однажды.
Сверх ревью кода — решения владельца по трём развилкам:
- Недоступность Telegram подъёму не мешает, ответ «такого бота нет» роняет старт.
Оракул — проверки на четыре ветки сборки.
- Поднятость входов видна метрикой. Оракул — живой прогон с пустым токеном:
признак входа Telegram равен нулю, признак HTTP — единице.
- Неназванный адресат пишется уровнем `ERROR`, неподнятый вход — `WARN`. Оракул
— проверки конвейера на обе причины.
+83 -5
View File
@@ -4,12 +4,15 @@
Приём записи и опрос готовности задачи расшифровки: что считается принятой Приём записи и опрос готовности задачи расшифровки: что считается принятой
записью, что уезжает в ответ и что происходит, когда запись не удалось записью, что уезжает в ответ и что происходит, когда запись не удалось
прочитать. прочитать. Плюс наличие входов: с каким из них сервис вправе подняться.
Приём по существу описан пока **только для HTTP** — того, что нормируют
проверки. Про вход Telegram нормировано одно: настроен он или нет и что из этого
следует для подъёма. Кто допущен к боту и как забирается присланная им запись,
требованиями по-прежнему не описано — требование, написанное без проверки, это
предположение, а не норма. Первая задача, которая трогает поведение приёма из
Telegram, дописывает его сюда.
Описан пока **только приём по HTTP** — тот, что нормируют проверки. Приём из
Telegram делит с ним общий шаг заведения задачи, но требований на него нет:
требование, написанное без проверки, — предположение, а не норма. Первая задача,
которая трогает поведение приёма из Telegram, дописывает его сюда.
## Requirements ## Requirements
### Requirement: Приём записи по HTTP ### Requirement: Приём записи по HTTP
@@ -256,3 +259,78 @@ Telegram делит с ним общий шаг заведения задачи,
- **WHEN** программа спрашивает состояние по неизвестному идентификатору - **WHEN** программа спрашивает состояние по неизвестному идентификатору
- **THEN** ответ имеет код `404` и сообщение о ненайденной задаче - **THEN** ответ имеет код `404` и сообщение о ненайденной задаче
### Requirement: Недоступный или незаданный вход Telegram не мешает подъёму
Сервис SHALL подниматься, когда вход Telegram поднять не удалось, и MUST
продолжать работу оставшимся входом: приём по HTTP, опрос готовности и конвейер
расшифровки работают в полном объёме. Неподнятый вход MUST быть назван в журнале
**ровно одной** записью уровня `WARN` при старте — с причиной и без значения
токена.
Исключение одно, и оно проходит по тому, **ответил ли Telegram**. Ответ «такого
бота нет» — ошибка настройки: бот по этому токену не появится ни от ожидания, ни
от повтора, и старт MUST кончаться отказом. Сервис, молча потерявший бота после
опечатки в токене, перестаёт отвечать своим отправителям, и узнать об этом было
бы неоткуда.
Всё прочее — недоступность: сеть, DNS, авария Bot API, истёкший срок ожидания.
Она MUST не влиять на подъём. Основной вход сервиса — не Telegram, и ронять его
целиком из-за чужой аварии нельзя: перезапуск в такую минуту оставил бы без
работы и приём по HTTP, и панель, и конвейер, которому Telegram не нужен вовсе.
Ожидание при сборке MUST быть ограничено сроком. Без него недоступность
неотличима от подъёма: обращение к Telegram стоит на пути старта, и молчащий
собеседник останавливал бы его бессрочно — без записи, без порта и без пробы
здоровья.
Требование нормирует **наличие входа**, а не приём из него.
#### Scenario: Токен не задан
- **GIVEN** в настройках сервиса токен бота пуст
- **WHEN** сервис запускается
- **THEN** он поднимается и принимает записи по HTTP
- **AND** конвейер расшифровки работает
- **AND** бот не заведён, а в журнале ровно одна запись уровня `WARN` о том, что
он не поднят и почему
#### Scenario: Токен задан и годен
- **GIVEN** в настройках сервиса стоит токен, по которому Telegram признаёт бота
- **WHEN** сервис запускается
- **THEN** он поднимается и работает обоими входами
#### Scenario: Telegram не отвечает
- **GIVEN** в настройках сервиса стоит непустой токен
- **AND** Telegram недоступен либо не отвечает дольше отведённого срока
- **WHEN** сервис запускается
- **THEN** он поднимается и принимает записи по HTTP
- **AND** бот не заведён, а в журнале запись уровня `WARN` с причиной
- **AND** запись не несёт значения токена
#### Scenario: Telegram ответил, что такого бота нет
- **GIVEN** в настройках сервиса стоит непустой токен
- **AND** Telegram отвечает отказом на этот токен
- **WHEN** сервис запускается
- **THEN** старт кончается отказом
- **AND** ни журнал, ни текст отказа не несут значения токена
### Requirement: Поднятые входы видны наблюдателю
Сервис SHALL отдавать признак поднятости по каждому входу приёма отдельной
метрикой. Признак MUST выставляться при сборке входа и MUST различать поднятый
вход и неподнятый.
Требование стоит на том, что иначе потерянный вход не виден ничем: проба
здоровья отвечает «сервис работает» и при неподнятом боте, а запись журнала
живёт до ротации и вопрос «работает ли вход сейчас» не отвечает. Метрика —
единственный канал наблюдения, который у владельца автоматизирован.
#### Scenario: Вход Telegram не поднят
- **GIVEN** сервис поднялся без Telegram
- **WHEN** наблюдатель читает метрики
- **THEN** признак поднятости входа Telegram равен нулю
- **AND** признак поднятости входа HTTP равен единице
+73 -8
View File
@@ -3,16 +3,19 @@
## Purpose ## Purpose
Конвейер расшифровки: как задача движется по состояниям, что делает воркер, Конвейер расшифровки: как задача движется по состояниям, что делает воркер,
когда работы нет, и что считается отказом шага. когда работы нет, что считается отказом шага и что бывает с ответом отправителю,
когда доставить его некуда.
Описаны пустой прогон воркера, неделимость захвата и срок его протухания, число Описаны пустой прогон воркера, неделимость захвата и срок его протухания, число
попыток и состояние «мертва», нарастающая пауза перед повтором и условие записи попыток и состояние «мертва», нарастающая пауза перед повтором, условие записи
результата держателем захвата. Сознательно не описаны: цепочка переходов результата держателем захвата и недоставка ответа при неподнятом входе.
`created → converted → transcribe → done | failed`, отмена контекста посреди Сознательно не описаны: цепочка переходов `created → converted → transcribe →
шага и освобождение ресурсов внешних клиентов. Это не значит, что такого done | failed`, отмена контекста посреди шага и освобождение ресурсов внешних
поведения нет: оно живёт в коде, а требования на него не написаны, потому что клиентов. Это не значит, что такого поведения нет: оно живёт в коде, а
требование без проверки — предположение, а не норма. Первая задача, которая требования на него не написаны, потому что требование без проверки —
трогает любое из перечисленного, дописывает его сюда. предположение, а не норма. Первая задача, которая трогает любое из
перечисленного, дописывает его сюда.
## Requirements ## Requirements
### Requirement: Пустой прогон воркера — не отказ ### Requirement: Пустой прогон воркера — не отказ
@@ -260,3 +263,65 @@ MUST расти с числом её попыток до объявленног
- **THEN** задержка до следующей проверки каждый раз одна и та же - **THEN** задержка до следующей проверки каждый раз одна и та же
- **AND** число попыток задачи не растёт - **AND** число попыток задачи не растёт
### Requirement: Недоставленный ответ не роняет шаг
Шаг конвейера SHALL доводить задачу до достигнутого состояния, когда ответ
отправителю доставить не удалось, и MUST не считать недоставку отказом шага.
Недоставка MUST быть записана в журнал владельца, MUST нести идентификатор
задачи, MUST называть причину и MUST считаться отдельной метрикой с причиной
меткой.
Причин у недоставки две, и исход у них общий: **вход отправителя не поднят**
задача заведена прошлым запуском, а сервис поднялся без этого входа; и **адресат
у задачи не назван** — источником значится Telegram, а чата в задаче нет.
Уровень записи MUST различать эти причины. Неподнятый вход — объявленный режим,
и его уровень «может стать проблемой». Неназванный адресат — симптом порчи
записи: у задачи из Telegram чат есть всегда, и пропасть он может только от
дефекта, самый коварный источник которого назван инвариантом проекта про колонки
очереди. Один уровень на обе причины утопил бы этот сигнал в потоке штатных
записей о ненастроенном боте.
Общий исход — не упрощение, а следствие момента: ответ уходит **после** того, как
достигнутое состояние сохранено. Работа к этой минуте сделана, и объявленный
отказ засчитался бы воркеру сбоем и лёг бы владельцу записью отказа — то есть
соврал бы про исход дважды. Повтор делу не помогает: ни бот, ни адресат от
ожидания не появятся. Поэтому задача остаётся в достигнутом состоянии, в повтор
не уходит и в `failed` не переводится, а причина недоставки живёт в записи
журнала, а не в состоянии задачи.
Идентификатор задачи в записи обязателен: без него владелец видит, что ответ не
ушёл, но не может найти, чей. Текст расшифровки и сообщение отправителя в эту
запись MUST не попадать — приватность содержимого записи требование не
ослабляет.
Отложенной доставки это требование не заводит: ответ, не ушедший сегодня, не
уходит и потом. Забрать расшифровку можно там же, где лежат остальные.
#### Scenario: Вход отправителя не поднят
- **GIVEN** задача принята входом Telegram прошлым запуском сервиса
- **AND** сервис поднялся без этого входа
- **WHEN** шаг конвейера доходит до ответа отправителю
- **THEN** шаг завершается без отказа, и воркер не считает прогон сбоем
- **AND** задача остаётся в достигнутом состоянии, в повтор не уходит и в
`failed` не переводится
- **AND** в журнале есть запись уровня `WARN` о недоставке с идентификатором
задачи и причиной
- **AND** счётчик недоставленных ответов вырос с этой причиной меткой
- **AND** ни текста расшифровки, ни сообщения отправителя в этой записи нет
#### Scenario: Адресат у задачи не назван
- **GIVEN** у задачи источником значится Telegram, а чат не назван
- **WHEN** шаг конвейера доходит до ответа отправителю
- **THEN** шаг завершается без отказа, и воркер не считает прогон сбоем
- **AND** задача остаётся в достигнутом состоянии
- **AND** в журнале есть запись уровня `ERROR` о недоставке с идентификатором
задачи и причиной: неназванный адресат — симптом порчи записи
#### Scenario: Отвечать некуда, потому что запись пришла не из Telegram
- **GIVEN** задача принята по HTTP
- **WHEN** шаг конвейера доходит до ответа отправителю
- **THEN** шаг завершается без отказа и без записи о недоставке
@@ -44,5 +44,11 @@
отправителя с непустым токеном: прежний путь сохранён. отправителя с непустым токеном: прежний путь сохранён.
- Задача из Telegram, дошедшая до ответа при отсутствующем боте, не роняет - Задача из Telegram, дошедшая до ответа при отсутствующем боте, не роняет
процесс и не теряется молча. Оракул — тест конвейера с заведённой задачей процесс и не теряется молча. Оракул — тест конвейера с заведённой задачей
источника Telegram и без отправителя: процесс жив, задача осталась пригодной к источника Telegram и без отправителя: процесс жив, задача осталась в
повтору либо перешла в `failed`, и об этом есть строка журнала. достигнутом состоянии, в повтор не ушла и в `failed` не переведена, а
недоставка видна записью журнала с идентификатором задачи.
Правка критерия 2026-08-13, решение владельца на чекпоинте: прежняя редакция
требовала повтора либо `failed`, и оба исхода отвергнуты дизайном с ценой —
пометка отказа на удавшейся работе врёт панели и метрике, а повтор не помогает,
потому что бот от ожидания не появится.
+67
View File
@@ -0,0 +1,67 @@
package main
import (
"errors"
"log/slog"
"git.vakhrushev.me/av/transcriber/internal/adapter/telegram"
"git.vakhrushev.me/av/transcriber/internal/contract"
"git.vakhrushev.me/av/transcriber/internal/metrics"
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
)
// buildTelegram заводит вход Telegram при старте. Клиент собирается **один
// раз** и достаётся обоим — отправителю ответов и транспорту бота. Пока его
// строили порознь, два пути одного старта разошлись: один ронял процесс на
// негодном токене, другой терпел, и согласовывать их приходилось руками.
func buildTelegram(botToken string, logger *slog.Logger) (*tgbotapi.BotAPI, contract.TelegramMessageSender, error) {
bot, err := telegram.NewBot(botToken, logger)
return telegramFromBot(bot, err, logger)
}
// telegramFromBot решает, чем обернулась сборка клиента, и это решение —
// единственное содержательное здесь. Оно отделено от самого обращения к
// Telegram намеренно: обращение ходит в сеть и в проверке недоступно, а
// разрез проверять надо, иначе его молча вернут к прежнему виду.
//
// Разрез проходит по тому, **ответил ли Telegram**, и это решение владельца
// от 2026-08-13: недоступность Telegram на старт сервиса не влияет.
//
// - токен не задан — законный отказ от входа: сервис работает оставшимся;
// - Telegram ответил отказом — ошибка настройки: бота по этому токену не
// существует, ждать нечего, и старт роняется. Молча потерянный бот
// перестаёт отвечать отправителям, а узнать об этом было бы неоткуда;
// - до Telegram не дошли — недоступность: сеть, DNS, авария Bot API,
// истёкший срок ожидания. Сервис поднимается без Telegram, потому что
// основной вход у него другой, и класть его из-за чужой аварии нельзя.
//
// Ядро во всех трёх случаях получает непустого отправителя: необязательная
// зависимость, доехавшая до него нулём, уронила бы первую же задачу из
// Telegram.
func telegramFromBot(
bot *tgbotapi.BotAPI,
err error,
logger *slog.Logger,
) (*tgbotapi.BotAPI, contract.TelegramMessageSender, error) {
var apiErr *tgbotapi.Error
switch {
case errors.Is(err, telegram.ErrEmptyToken):
metrics.IntakeUpGauge.WithLabelValues("telegram").Set(0)
logger.Warn("Telegram bot is not started", "reason", "bot token is not set in configuration")
return nil, telegram.NewAbsentMessageSender(), nil
case errors.As(err, &apiErr):
// Отказ токена не несёт: его чистит единая точка `telegram.NewBot`.
return nil, nil, err
case err != nil:
metrics.IntakeUpGauge.WithLabelValues("telegram").Set(0)
logger.Warn("Telegram bot is not started", "reason", "telegram is unreachable", "error", err)
return nil, telegram.NewAbsentMessageSender(), nil
default:
metrics.IntakeUpGauge.WithLabelValues("telegram").Set(1)
return bot, telegram.NewTelegramMessageSender(bot, logger), nil
}
}
+101
View File
@@ -0,0 +1,101 @@
package main
import (
"bytes"
"errors"
"log/slog"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
"git.vakhrushev.me/av/transcriber/internal/adapter/telegram"
"git.vakhrushev.me/av/transcriber/internal/contract"
)
// Разрез сборки — то, ради чего написано изменение, — до этих проверок не
// держался ничем: инвертируй его, и весь набор оставался зелёным.
//
// Судится решение, а не обращение к Telegram: обращение ходит в сеть, а
// боевым токеном запускаться запрещено.
func journalLogger() (*slog.Logger, *bytes.Buffer) {
journal := &bytes.Buffer{}
return slog.New(slog.NewTextHandler(journal, &slog.HandlerOptions{Level: slog.LevelDebug})), journal
}
// Пустой токен — законный отказ от входа: старт продолжается, ядро получает
// заглушку, а владелец узнаёт об этом одной записью.
func TestTelegramFromBotOnEmptyTokenGivesAbsentSender(t *testing.T) {
logger, journal := journalLogger()
bot, sender, err := telegramFromBot(nil, telegram.ErrEmptyToken, logger)
require.NoError(t, err, "пустой токен старт не роняет")
assert.Nil(t, bot, "клиента нет — транспорт не поднимется")
require.NotNil(t, sender, "ядро получает отправителя всегда, а не ноль")
require.ErrorIs(t, sender.Send("любой ответ", 1, nil), contract.ErrDeliveryChannelDown,
"заглушка говорит, что канал не поднят")
written := journal.String()
assert.Contains(t, written, "Telegram bot is not started", "о неподнятом боте сказано")
assert.Contains(t, written, "level=WARN", "уровень — «может стать проблемой»")
assert.Equal(t, 1, strings.Count(written, "Telegram bot is not started"),
"запись ровно одна: вторая была бы записью о том же факте")
}
// Telegram ответил, что такого бота нет, — ошибка настройки, а не режим: бот по
// этому токену не появится ни от ожидания, ни от повтора, и старт роняется.
func TestTelegramFromBotOnRejectedTokenReturnsError(t *testing.T) {
logger, journal := journalLogger()
rejected := &tgbotapi.Error{Code: 401, Message: "Unauthorized"}
bot, sender, err := telegramFromBot(nil, rejected, logger)
require.ErrorIs(t, err, rejected, "отказ поднят вызывающему нетронутым")
assert.Nil(t, bot)
assert.Nil(t, sender, "заглушка тут не подставляется: это ошибка, а не режим")
assert.NotContains(t, journal.String(), "Telegram bot is not started",
"о законном отсутствии входа речи нет: вход заявлен и отвергнут")
}
// До Telegram не дошли — недоступность: на подъём сервиса она не влияет.
// Решение владельца 2026-08-13; иначе чужая авария кладёт и основной вход, и
// панель, и конвейер, которому Telegram не нужен вовсе.
func TestTelegramFromBotOnUnreachableTelegramKeepsStarting(t *testing.T) {
logger, journal := journalLogger()
unreachable := errors.New("dial tcp: connection refused")
bot, sender, err := telegramFromBot(nil, unreachable, logger)
require.NoError(t, err, "недоступность Telegram старт не роняет")
assert.Nil(t, bot, "клиента нет — транспорт не поднимется")
require.NotNil(t, sender, "ядро получает отправителя всегда, а не ноль")
require.ErrorIs(t, sender.Send("любой ответ", 1, nil), contract.ErrDeliveryChannelDown)
written := journal.String()
assert.Contains(t, written, "Telegram bot is not started", "о неподнятом входе сказано")
assert.Contains(t, written, "unreachable", "причина названа")
assert.Contains(t, written, "level=WARN")
}
// Годный токен даёт настоящего отправителя и клиента для транспорта: прежний
// путь сохранён, и о неподнятом боте не говорится ничего.
func TestTelegramFromBotOnLiveBotGivesRealSender(t *testing.T) {
logger, journal := journalLogger()
live := &tgbotapi.BotAPI{}
bot, sender, err := telegramFromBot(live, nil, logger)
require.NoError(t, err)
assert.Same(t, live, bot, "транспорт получит того же клиента, что и отправитель")
require.NotNil(t, sender)
_, stub := sender.(*telegram.AbsentMessageSender)
assert.False(t, stub, "это настоящий отправитель, а не заглушка")
assert.NotContains(t, journal.String(), "Telegram bot is not started")
}