From b733a84d6aeb301fda082774de359cef4bd90fe5 Mon Sep 17 00:00:00 2001 From: Anton Vakhrushev Date: Thu, 13 Aug 2026 19:10:08 +0300 Subject: [PATCH] =?UTF-8?q?telegram:=20=D1=81=D0=B5=D1=80=D0=B2=D0=B8?= =?UTF-8?q?=D1=81=20=D0=BF=D0=BE=D0=B4=D0=BD=D0=B8=D0=BC=D0=B0=D0=B5=D1=82?= =?UTF-8?q?=D1=81=D1=8F=20=D0=B1=D0=B5=D0=B7=20=D0=B1=D0=BE=D1=82=D0=B0=20?= =?UTF-8?q?=D0=B8=20=D1=80=D0=B0=D0=B1=D0=BE=D1=82=D0=B0=D0=B5=D1=82=20?= =?UTF-8?q?=D0=BE=D0=B4=D0=BD=D0=B8=D0=BC=20=D0=B2=D1=85=D0=BE=D0=B4=D0=BE?= =?UTF-8?q?=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Клиент бота собирается один раз и достаётся отправителю и транспорту; разрез прошёл по «ответил ли Telegram»: ответ «такого бота нет» роняет старт, недоступность даёт подъём без Telegram (ADR-2026-08-13). Ожидание при сборке ограничено сроком — иначе молчащий Telegram вешал подъём. - Недоставленный ответ не роняет шаг: пишется с job_id и считается метрикой, уровень по причине — WARN для неподнятого входа, ERROR для неназванного адресата. Заведены transcriber_intake_up и transcriber_undelivered_reply_count. - Закрыта утечка токена в журнал: отказ разбора адреса рождается раньше обращения к клиенту, то есть мимо чистки на его границе. --- CLAUDE.md | 6 +- config.dist.toml | 21 +- ...-telegram-outage-does-not-block-startup.md | 58 +++++ docs/adr/README.md | 1 + docs/architecture.md | 30 ++- docs/conventions/config.md | 12 +- docs/conventions/errors.md | 11 +- docs/database.md | 1 + docs/review.md | 18 +- docs/security.md | 7 + internal/adapter/telegram/absent.go | 26 +++ internal/adapter/telegram/absent_test.go | 37 ++++ internal/adapter/telegram/bot.go | 29 ++- internal/adapter/telegram/bot_test.go | 32 +++ internal/adapter/telegram/sender.go | 15 +- internal/contract/error.go | 14 +- internal/controller/tg/tg.go | 6 - internal/metrics/metrics.go | 22 ++ internal/service/transcribe.go | 57 ++++- internal/service/undelivered_test.go | 140 ++++++++++++ main.go | 48 ++-- .../.openspec.yaml | 2 + .../design.md | 207 ++++++++++++++++++ .../proposal.md | 54 +++++ .../review/triage.md | 172 +++++++++++++++ .../specs/intake/spec.md | 90 ++++++++ .../specs/pipeline/spec.md | 80 +++++++ .../tasks.md | 127 +++++++++++ openspec/specs/intake/spec.md | 88 +++++++- openspec/specs/pipeline/spec.md | 81 ++++++- .../items/local-run-without-telegram-token.md | 10 +- telegram_build.go | 67 ++++++ telegram_build_test.go | 101 +++++++++ 33 files changed, 1579 insertions(+), 91 deletions(-) create mode 100644 docs/adr/ADR-2026-08-13-telegram-outage-does-not-block-startup.md create mode 100644 internal/adapter/telegram/absent.go create mode 100644 internal/adapter/telegram/absent_test.go create mode 100644 internal/service/undelivered_test.go create mode 100644 openspec/changes/archive/2026-08-13-start-without-telegram-token/.openspec.yaml create mode 100644 openspec/changes/archive/2026-08-13-start-without-telegram-token/design.md create mode 100644 openspec/changes/archive/2026-08-13-start-without-telegram-token/proposal.md create mode 100644 openspec/changes/archive/2026-08-13-start-without-telegram-token/review/triage.md create mode 100644 openspec/changes/archive/2026-08-13-start-without-telegram-token/specs/intake/spec.md create mode 100644 openspec/changes/archive/2026-08-13-start-without-telegram-token/specs/pipeline/spec.md create mode 100644 openspec/changes/archive/2026-08-13-start-without-telegram-token/tasks.md create mode 100644 telegram_build.go create mode 100644 telegram_build_test.go diff --git a/CLAUDE.md b/CLAUDE.md index b1d82b0..c0d6892 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -186,7 +186,11 @@ task gate # весь набор проверок разом (`data/storage/<коллекция>/<запись>/`). Локальный каталог данных — свой, его ронять и пересоздавать можно свободно. - **Боевым токеном бота не запускаться.** Второй процесс с тем же токеном - перехватывает обновления у работающего, и пользователь теряет ответы. + перехватывает обновления у работающего, и пользователь теряет ответы. Запускай + с **пустым** `telegram.bot_token`: сервис поднимается без Telegram и работает + одним входом, по HTTP. Пустого токена для подъёма мало — секции `[auth]` и + `[yandex]` проверяются на старте, но наружу при этом не ходят, так что годятся + выдуманные непустые значения; подробности строками в `config.dist.toml`. - **Yandex Cloud за деньги.** Распознавание и хранение в Object Storage оплачиваются по факту. Прогон на реальных ключах ради проверки кода запрещён — подставляй `internal/adapter/recognizer/memory.go`. diff --git a/config.dist.toml b/config.dist.toml index 32cf45d..8abeffd 100644 --- a/config.dist.toml +++ b/config.dist.toml @@ -61,8 +61,25 @@ secure_cookie = true # Telegram Bot Configuration [telegram] -# Токен Telegram бота (получить у @BotFather в Telegram) -bot_token = "your_telegram_bot_token_here" +# Токен Telegram бота (получить у @BotFather в Telegram). +# +# Пустое значение — объявленный режим: сервис поднимается без Telegram и +# работает одним входом, по HTTP. Бот при этом не заводится, записи из Telegram +# не принимаются, а ответы на задачи, принятые оттуда прежде, не уходят — +# недоставка видна записью журнала, расшифровка достаётся из панели и по HTTP. +# Старт роняет только один случай — Telegram ответил, что бота по такому токену +# нет: ждать тут нечего, это опечатка в настройке. Недоступность Telegram (сеть, +# DNS, авария Bot API) подъёму не мешает: сервис встаёт без бота и говорит об +# этом записью журнала, потому что основной вход у него другой. +# +# Локальный прогон идёт именно так — боевым токеном запускаться запрещено: +# второй процесс с тем же токеном перехватывает обновления у работающего. +# Пустого токена для подъёма мало: секции [auth] и [yandex] проверяются на +# старте и роняют процесс на пустых ключах. Наружу при старте не ходит ни одна +# из них, поэтому для локального прогона годятся выдуманные непустые значения — +# адреса [auth] должны лишь разбираться как ссылки. Расшифровка при выдуманных +# ключах не работает: её подменяют в коде. +bot_token = "" # Таймаут обновлений Telegram бота (в секундах) update_timeout = 10 diff --git a/docs/adr/ADR-2026-08-13-telegram-outage-does-not-block-startup.md b/docs/adr/ADR-2026-08-13-telegram-outage-does-not-block-startup.md new file mode 100644 index 0000000..d93e4e3 --- /dev/null +++ b/docs/adr/ADR-2026-08-13-telegram-outage-does-not-block-startup.md @@ -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 не отвергает — его отвергает разбор адреса, и такой случай + попадает в недоступность, а не в ошибку настройки. Заметен он записью журнала, + а не отказом старта. diff --git a/docs/adr/README.md b/docs/adr/README.md index 0300b22..f840844 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -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-session-without-refresh.md) | | | 2026-08-12 | [Кого пускать в сервис, решает правило провайдера, а не сервис](ADR-2026-08-12-access-delegated-to-provider.md) | | diff --git a/docs/architecture.md b/docs/architecture.md index d90e7fe..482107f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -14,16 +14,20 @@ от 2026-08-13 его нормы живут в самих шагах, их проверках и [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, - `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) — пустой прогон воркера, захват - задачи и срок его протухания, число попыток, состояние «мертва» и пауза перед - повтором: задачи `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` самой спеки; - [storage](../openspec/specs/storage/spec.md) — где живут запись, её метаданные и её файл, как файл отдаётся и что видит владелец: задача `pocketbase-storage` @@ -110,15 +114,21 @@ - **Где работает, что рядом, кто перезапускает:** один контейнер на личном сервере, разворачивает и перезапускает Ansible из `pet-project-server`. Рядом — обратный прокси, который публикует HTTP-порт наружу. +- **Пустой токен бота нельзя разворачивать раньше образа, который его понимает.** + Пустое значение стало объявленным режимом 2026-08-13; версии до неё роняли на + нём старт с кодом 1 **до** открытия порта. Значит, откат образа при уже + применённом пустом токене останавливает не бот, а весь сервис — вместе с HTTP + и панелью. Порядок: сперва образ, потом конфиг; при откате — наоборот. + Воспроизведено ревью кода на прежней версии. - **Внешние зависимости поимённо и чем каждая отказывает.** Столбец «отвечает медленно» читается вместе с тем, что таймаута нет ни у одного обращения наружу — [database.md](database.md), «Настройки с числовым значением»: - + | Зависимость | Падает | Отвечает медленно | Молчит | Отдаёт мусор | | --- | --- | --- | --- | --- | - | Telegram Bot API | Бот не стартует, приложение продолжает работу без него | Скачивание файла висит бесконечно | Длинный опрос пуст, новые задачи не заводятся | Файл скачался битым, отказ вылезет на конвертации | + | Telegram Bot API | Сервис поднимается без Telegram и работает по HTTP; старт роняет только ответ «такого бота нет». Норму держит [intake](../openspec/specs/intake/spec.md), «Недоступный или незаданный вход Telegram не мешает подъёму» | На старте — ждём не дольше срока, дальше поднимаемся без Telegram. У поднятого сервиса скачивание файла висит бесконечно: там срока нет | То же, что «отвечает медленно»: на старте — подъём без Telegram по истечении срока, у поднятого — длинный опрос пуст и новые задачи не заводятся | Файл скачался битым, отказ вылезет на конвертации | | Yandex SpeechKit | Шаг возвращает ошибку, задача остаётся на повтор | Захват держится час, задача не двигается | Операция вечно `in progress`, повтор каждые 5 секунд | Пустой текст — задача завершается заглушкой «на записи нет текста» | | ↳ *остановка сервиса* | Принятие операции от отмены защищено своим пределом в 10 секунд: операцию там могли принять и начать считать деньги, а потерянный идентификатор заставил бы повтор оплатить ту же запись второй раз. Заливка в Object Storage отменяется штатно — её повтор бесплатен, объект ложится под тем же ключом | — | — | — | | Yandex Object Storage | Заливка падает, задача остаётся в `converted` | То же, что падение: висит до конца захвата | — | SpeechKit не прочитает объект и вернёт отказ операции | diff --git a/docs/conventions/config.md b/docs/conventions/config.md index f6f4c43..ffb87a5 100644 --- a/docs/conventions/config.md +++ b/docs/conventions/config.md @@ -116,10 +116,14 @@ Ansible из `pet-project-server`). Приложение просто читае - ключи внешних сервисов не пусты. *Расхождение:* `LoadConfig` проверяет только существование файла и разбирает -TOML. Пустой токен бота ловится в `NewTelegramController` уже после старта, и -приложение продолжает работу без бота; пустые ключи Yandex ловятся в -конструкторе распознавателя, и вот там процесс уже выходит с кодом 1. Единого -места проверки нет. +TOML. Пустые ключи Yandex ловятся в конструкторе распознавателя, и там процесс +выходит с кодом 1. Единого места проверки нет. + +Токен бота под это расхождение больше не подпадает: он судится при сборке +клиента, до подъёма сервера, и разрез у него объявленный — пустое значение +означает отказ от входа и даёт подъём без Telegram, непустое негодное роняет +старт как ошибка настройки. Нормирует это `openspec/specs/intake`, «Недоступный +или незаданный вход Telegram не мешает подъёму». Секция `[auth]` — первая, у которой проверка своя и стоит на старте: `AuthConfig.Validate()` зовётся из `main.go` сразу после загрузки и роняет diff --git a/docs/conventions/errors.md b/docs/conventions/errors.md index f0c0780..e7fc914 100644 --- a/docs/conventions/errors.md +++ b/docs/conventions/errors.md @@ -65,9 +65,14 @@ transcriber — **приложение, а не библиотека**: внеш вызывающему нужны **данные** ошибки. Достаём `errors.As`. Не плодим типы там, где хватает sentinel. -Сегодня в проекте три типизированные ошибки, и данные несёт только одна: -`contract.JobNotFoundError` (состояние и сообщение), `contract.NoopJobError` -(состояние), `tg.EmptyBotTokenError` (без полей — уместнее sentinel). +Сегодня в проекте две типизированные ошибки, и обе несут данные: +`contract.JobNotFoundError` (состояние и сообщение) и `contract.NoopJobError` +(состояние). Третья, `tg.EmptyBotTokenError`, была ровно тем случаем, против +которого написано правило — тип без полей, — и снята задачей +`local-run-without-telegram-token` 2026-08-13; её место занял sentinel +`telegram.ErrEmptyToken`. Рядом с ним живёт `contract.ErrDeliveryChannelDown` — +тоже sentinel и по той же причине: заглушка отправителя не знает ни задачи, ни +чата, и нести ей нечего. ## Граница и трансляция: приватный и публичный канал diff --git a/docs/database.md b/docs/database.md index 27d4e91..0ff1a9b 100644 --- a/docs/database.md +++ b/docs/database.md @@ -147,6 +147,7 @@ capability, и третий смысл развёл бы одно слово п | Таймаут мягкой остановки | 5 секунд | конфиг, `[server] shutdown_timeout` | — | | Таймаут жёсткой остановки | 20 секунд | конфиг, `[server] force_shutdown_timeout` | — | | Таймаут обновлений Telegram | 10 секунд | конфиг, `[telegram] update_timeout` | — | +| Срок ожидания Telegram при сборке клиента | 10 секунд | `adapter/telegram.ProbeTimeout` | решение, не замер: одно обращение за `getMe` укладывается в доли секунды, дольше Telegram считается недоступным и сервис поднимается без него. Длинный опрос этим сроком не ограничен — клиент подменяется сразу после сборки | | Качество кодирования vorbis | `-q:a 4` | `adapter/converter/ffmpeg/ffmpeg.go` | — | | Жизнь приглашения завести владельца панели | 30 минут | умолчание PocketBase | — | | Потолок размера одной записи | 8 ГиБ | `entity.MaxRecordSize` | расчётный потолок в шесть часов с запасом на видео | diff --git a/docs/review.md b/docs/review.md index ad5ecf3..fb27d05 100644 --- a/docs/review.md +++ b/docs/review.md @@ -226,12 +226,18 @@ API и имя не откатываются обратной правкой по длительность от подставного источника. Своего теста у `adapter/metaviewer/ffmpeg` нет; решение и его цена — в [adr/ADR-2026-08-11-stub-adapters-in-tests.md](adr/ADR-2026-08-11-stub-adapters-in-tests.md); -- **всё, что требует поднять сервис целиком.** Локальный запуск роняет адаптер - Telegram: он проверяет токен обращением к Telegram, а боевым токеном - запускаться запрещено. Значит поведенческая верификация живым прогоном - недоступна ни одной задаче, и заменяют её проверки поверх настоящего роутера - хранилища. Замечено 2026-08-12 задачей `oidc-login`; своей задачи на это пока - нет. +- **работа сервиса с настоящими внешними собеседниками.** Сам сервис поднять + теперь можно: с пустым `telegram.bot_token` он встаёт и работает одним входом + (`openspec/specs/intake`, «Недоступный или незаданный вход Telegram не мешает + подъёму»). Живой прогон — осмотр HTTP, панели, журнала и остановки — доступен + теперь любой задаче. Прежняя формулировка «всё, что требует поднять сервис целиком» + снята задачей `local-run-without-telegram-token` 2026-08-13. + + **Остаток**: за настоящий Telegram, SpeechKit и Object Storage живой прогон + по-прежнему не отвечает — боевым токеном запускаться запрещено, ключи Yandex в + прогоне выдуманные, а распознавание подменяют в коде. Проверить живьём можно + подъём, отказ старта, маршруты и остановку; нельзя — приём из Telegram, + расшифровку и заливку. ## Журнал дефектов diff --git a/docs/security.md b/docs/security.md index 0472d06..8df33ee 100644 --- a/docs/security.md +++ b/docs/security.md @@ -281,6 +281,13 @@ Telegram отправителю. отдают готовым. Правило — [conventions/logging.md](conventions/logging.md), случай — [review.md](review.md), оракул — `internal/adapter/telegram/bot_test.go`. +Ещё один путь закрыт задачей `local-run-without-telegram-token` 2026-08-13, и до +неё он был открыт: токен, не разбирающийся как часть адреса (перенос строки из +шаблона выкладки, невычищенная `%`-последовательность), роняет сборку клиента +**раньше** обращения к нему — то есть мимо чистки на границе клиента. Отказ +конструктора теперь чистится отдельно. Нашло это ревью кода тремя проходами +независимо; оракул — там же, в `bot_test.go`. + ## Что вне модели Перечислить явно. diff --git a/internal/adapter/telegram/absent.go b/internal/adapter/telegram/absent.go new file mode 100644 index 0000000..f49a100 --- /dev/null +++ b/internal/adapter/telegram/absent.go @@ -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 +} diff --git a/internal/adapter/telegram/absent_test.go b/internal/adapter/telegram/absent_test.go new file mode 100644 index 0000000..05432ad --- /dev/null +++ b/internal/adapter/telegram/absent_test.go @@ -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, "отправитель говорит с тем же клиентом, что и транспорт") +} diff --git a/internal/adapter/telegram/bot.go b/internal/adapter/telegram/bot.go index e56d878..195447e 100644 --- a/internal/adapter/telegram/bot.go +++ b/internal/adapter/telegram/bot.go @@ -7,6 +7,7 @@ import ( "net/http" "net/url" "strings" + "time" 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 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 — клиент, чей отказ не несёт адреса. Библиотека объявляет // зависимость интерфейсом `HTTPClient` и возвращает наш отказ вызывающему // нетронутым, поэтому чистка отсюда доходит до каждого вызова Bot API. diff --git a/internal/adapter/telegram/bot_test.go b/internal/adapter/telegram/bot_test.go index e4b58fd..b99fb80 100644 --- a/internal/adapter/telegram/bot_test.go +++ b/internal/adapter/telegram/bot_test.go @@ -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`, // и контейнер, стартующий раньше сети, печатал бы токен в первую же секунду. func TestBotConstructionFailureDoesNotCarryToken(t *testing.T) { @@ -92,10 +113,21 @@ func TestLibraryLoggerRedactsToken(t *testing.T) { } // Пустой токен — законный исход подъёма без Telegram, и узнаётся он по смыслу. +// Обратное тоже нормируется: отказ негодного токена не должен читаться как +// отказ от входа, иначе сборка при старте подставит заглушку там, где нужен +// отказ, и молча потеряет бота. func TestEmptyTokenIsRecognizedByValue(t *testing.T) { _, err := NewBot("", slog.New(slog.DiscardHandler)) 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` по цепочке продолжает diff --git a/internal/adapter/telegram/sender.go b/internal/adapter/telegram/sender.go index ab6976c..ebce845 100644 --- a/internal/adapter/telegram/sender.go +++ b/internal/adapter/telegram/sender.go @@ -15,18 +15,15 @@ type TelegramMessageSender struct { logger *slog.Logger } -func NewTelegramMessageSender(botToken string, logger *slog.Logger) (*TelegramMessageSender, error) { - // Клиент заводится единой точкой: её отказ не несёт токена, а отказ - // конструктора несёт — `NewBotAPI` зовёт `getMe`. - bot, err := NewBot(botToken, logger) - if err != nil { - return nil, err - } - +// NewTelegramMessageSender принимает готового клиента, а не токен. Клиента +// заводит сборка при старте — одного на отправителя и на транспорт бота: пока +// его строили здесь и там порознь, два пути одного старта разошлись в том, +// терпеть ли негодный токен, и согласовывать их приходилось руками. +func NewTelegramMessageSender(bot *tgbotapi.BotAPI, logger *slog.Logger) *TelegramMessageSender { return &TelegramMessageSender{ bot: bot, logger: logger, - }, nil + } } func (s *TelegramMessageSender) Send(text string, chatId int64, replyToMessageId *int) error { diff --git a/internal/contract/error.go b/internal/contract/error.go index 68f2d83..67af32f 100644 --- a/internal/contract/error.go +++ b/internal/contract/error.go @@ -1,6 +1,18 @@ package contract -import "fmt" +import ( + "errors" + "fmt" +) + +// ErrDeliveryChannelDown — канал, которым отвечают отправителю, не поднят. +// Отдаётся отправителем-заглушкой, которого получает ядро, когда вход не +// настроен. +// +// Значение сентинельное, а не тип: соседям по ряду есть что нести — состояние, +// идентификатор задачи, — а этому нечего. Заглушка не знает ни задачи, ни чата, +// и запись о недоставке делает шаг, у которого задача под рукой. +var ErrDeliveryChannelDown = errors.New("delivery channel is down") type JobNotFoundError struct { State string diff --git a/internal/controller/tg/tg.go b/internal/controller/tg/tg.go index cba0f49..c31b5cf 100644 --- a/internal/controller/tg/tg.go +++ b/internal/controller/tg/tg.go @@ -328,9 +328,3 @@ func (c *TelegramController) isAudioDocument(document *tgbotapi.Document) bool { return false } - -type EmptyBotTokenError struct{} - -func (e *EmptyBotTokenError) Error() string { - return "telegram bot token is empty" -} diff --git a/internal/metrics/metrics.go b/internal/metrics/metrics.go index c5d8b11..c0a485d 100644 --- a/internal/metrics/metrics.go +++ b/internal/metrics/metrics.go @@ -44,6 +44,28 @@ var ( []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( prometheus.HistogramOpts{ diff --git a/internal/service/transcribe.go b/internal/service/transcribe.go index db4362f..6114f97 100644 --- a/internal/service/transcribe.go +++ b/internal/service/transcribe.go @@ -551,19 +551,43 @@ func (s *TranscribeService) failJob(job *entity.TranscribeJob, holder string, jo return s.send(job, errorMessage) } -// send отвечает отправителю там, откуда пришла запись, и отказ отправки -// поднимает вверх: он принадлежит шагу. +// send отвечает отправителю там, откуда пришла запись. Отказ отправки поднимает +// вверх: он принадлежит шагу. +// +// Кроме недоставки — её шаг записывает и завершается без отказа. Ответ уходит +// после того, как достигнутое состояние сохранено: работа к этой минуте +// сделана, и объявленный отказ засчитался бы воркеру сбоем и лёг бы владельцу +// записью отказа. Повтор делу не помогает — ни бот, ни адресат от ожидания не +// появятся, — поэтому причина недоставки живёт в журнале, а не в состоянии +// задачи. +// +// Служебные поля завершённой задачи отказ бы при этом не переписал: переход в +// терминальное состояние снимает захват, и повторная запись натыкается на +// «захват потерян». Довод держится на счётчике и журнале, а не на этом. func (s *TranscribeService) send(job *entity.TranscribeJob, text string) error { if job.Source != entity.SourceTelegram { return nil } + // Адресата у задачи нет: отвечать некуда, и повторять нечего. Уровень здесь + // выше, чем у неподнятого канала, и это не педантизм: пустой чат у задачи + // из Telegram — симптом порчи записи, а самый коварный её источник назван + // инвариантом «колонки очереди правятся в четырёх местах». Утони этот + // сигнал в одном ряду со штатным «бот не настроен» — и обнуление колонки + // заметит только отправитель, переставший получать ответы. if job.TgChatId == nil { - s.logger.Error("Telegram chat not specified", "job_id", job.Id) - return fmt.Errorf("tg chat id not specified, job id: %s", job.Id) + s.undelivered(job, slog.LevelError, "chat is not specified") + return 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) 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 } +// 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 отвечает отправителю там, где поднимать отказ некуда: задача уже // доведена до конца, и отказ отправки остаётся записью в журнале владельца. func (s *TranscribeService) notify(job *entity.TranscribeJob, text string) { diff --git a/internal/service/undelivered_test.go b/internal/service/undelivered_test.go new file mode 100644 index 0000000..db54ba6 --- /dev/null +++ b/internal/service/undelivered_test.go @@ -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", "недоставки не было") +} diff --git a/main.go b/main.go index 1794762..6635a26 100644 --- a/main.go +++ b/main.go @@ -17,12 +17,11 @@ import ( ffmpegmv "git.vakhrushev.me/av/transcriber/internal/adapter/metaviewer/ffmpeg" "git.vakhrushev.me/av/transcriber/internal/adapter/recognizer/yandex" 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/contract" httpcontroller "git.vakhrushev.me/av/transcriber/internal/controller/http" tgcontroller "git.vakhrushev.me/av/transcriber/internal/controller/tg" "git.vakhrushev.me/av/transcriber/internal/controller/worker" + "git.vakhrushev.me/av/transcriber/internal/metrics" "git.vakhrushev.me/av/transcriber/internal/service" "github.com/joho/godotenv" "github.com/pocketbase/pocketbase/apis" @@ -89,9 +88,9 @@ func main() { metaviewer := ffmpegmv.NewFfmpegMetaViewer() converter := ffmpegconv.NewFfmpegConverter() - tgSender, err := telegram.NewTelegramMessageSender(cfg.Telegram.BotToken, logger) + tgBot, tgSender, err := buildTelegram(cfg.Telegram.BotToken, logger) 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) } @@ -141,13 +140,17 @@ func main() { UserWhiteList: cfg.Server.UsersWhiteList, } - // Клиента бота заводит единая точка: её отказ не несёт токена, тогда как - // отказ `NewBotAPI` несёт — он ходит за `getMe`. - tgController, err := newTelegramController(cfg.Telegram.BotToken, tgConfig, transcribeService, jobRepo, logger) - if err != nil { - logger.Error("Failed to create Telegram controller", "error", err) - // Не останавливаем приложение, если Telegram бот не создан - } else { + // Транспорт поднимается только там, где есть клиент: о том, что бота нет, + // сказано выше единственной записью, и вторая здесь была бы записью о том + // же факте. + var tgController *tgcontroller.TelegramController + if tgBot != nil { + tgController, err = tgcontroller.NewTelegramController(tgConfig, tgBot, transcribeService, jobRepo, logger) + if err != nil { + logger.Error("Failed to create Telegram controller", "error", err) + os.Exit(1) + } + // Запускаем Telegram бот в отдельной горутине wg.Add(1) go func() { @@ -179,6 +182,11 @@ func main() { }(w) } + // Вход по HTTP поднимается всегда: он основной, и отдельного разреза у него + // нет. Признак ставится рядом с признаком Telegram, чтобы владелец судил об + // обоих входах одним отбором. + metrics.IntakeUpGauge.WithLabelValues("http").Set(1) + // Наши маршруты живут на роутере хранилища: панель отдаётся тем же портом, // и второму серверу на нём взяться неоткуда. transcribeHandler := httpcontroller.NewTranscribeHandler(jobRepo, transcribeService, logger) @@ -328,21 +336,3 @@ func main() { 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) -} diff --git a/openspec/changes/archive/2026-08-13-start-without-telegram-token/.openspec.yaml b/openspec/changes/archive/2026-08-13-start-without-telegram-token/.openspec.yaml new file mode 100644 index 0000000..b6b2d1f --- /dev/null +++ b/openspec/changes/archive/2026-08-13-start-without-telegram-token/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-13 diff --git a/openspec/changes/archive/2026-08-13-start-without-telegram-token/design.md b/openspec/changes/archive/2026-08-13-start-without-telegram-token/design.md new file mode 100644 index 0000000..945fab7 --- /dev/null +++ b/openspec/changes/archive/2026-08-13-start-without-telegram-token/design.md @@ -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 + +Нет. diff --git a/openspec/changes/archive/2026-08-13-start-without-telegram-token/proposal.md b/openspec/changes/archive/2026-08-13-start-without-telegram-token/proposal.md new file mode 100644 index 0000000..b6aee1c --- /dev/null +++ b/openspec/changes/archive/2026-08-13-start-without-telegram-token/proposal.md @@ -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 изменение не трогает. diff --git a/openspec/changes/archive/2026-08-13-start-without-telegram-token/review/triage.md b/openspec/changes/archive/2026-08-13-start-without-telegram-token/review/triage.md new file mode 100644 index 0000000..094ce0d --- /dev/null +++ b/openspec/changes/archive/2026-08-13-start-without-telegram-token/review/triage.md @@ -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` что-то у себя, из отчёта нельзя. diff --git a/openspec/changes/archive/2026-08-13-start-without-telegram-token/specs/intake/spec.md b/openspec/changes/archive/2026-08-13-start-without-telegram-token/specs/intake/spec.md new file mode 100644 index 0000000..a380bbd --- /dev/null +++ b/openspec/changes/archive/2026-08-13-start-without-telegram-token/specs/intake/spec.md @@ -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 равен единице diff --git a/openspec/changes/archive/2026-08-13-start-without-telegram-token/specs/pipeline/spec.md b/openspec/changes/archive/2026-08-13-start-without-telegram-token/specs/pipeline/spec.md new file mode 100644 index 0000000..00c62ab --- /dev/null +++ b/openspec/changes/archive/2026-08-13-start-without-telegram-token/specs/pipeline/spec.md @@ -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** шаг завершается без отказа и без записи о недоставке diff --git a/openspec/changes/archive/2026-08-13-start-without-telegram-token/tasks.md b/openspec/changes/archive/2026-08-13-start-without-telegram-token/tasks.md new file mode 100644 index 0000000..8543694 --- /dev/null +++ b/openspec/changes/archive/2026-08-13-start-without-telegram-token/tasks.md @@ -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`. Оракул + — проверки конвейера на обе причины. diff --git a/openspec/specs/intake/spec.md b/openspec/specs/intake/spec.md index e88d200..bf2c279 100644 --- a/openspec/specs/intake/spec.md +++ b/openspec/specs/intake/spec.md @@ -4,12 +4,15 @@ Приём записи и опрос готовности задачи расшифровки: что считается принятой записью, что уезжает в ответ и что происходит, когда запись не удалось -прочитать. +прочитать. Плюс наличие входов: с каким из них сервис вправе подняться. + +Приём по существу описан пока **только для HTTP** — того, что нормируют +проверки. Про вход Telegram нормировано одно: настроен он или нет и что из этого +следует для подъёма. Кто допущен к боту и как забирается присланная им запись, +требованиями по-прежнему не описано — требование, написанное без проверки, это +предположение, а не норма. Первая задача, которая трогает поведение приёма из +Telegram, дописывает его сюда. -Описан пока **только приём по HTTP** — тот, что нормируют проверки. Приём из -Telegram делит с ним общий шаг заведения задачи, но требований на него нет: -требование, написанное без проверки, — предположение, а не норма. Первая задача, -которая трогает поведение приёма из Telegram, дописывает его сюда. ## Requirements ### Requirement: Приём записи по HTTP @@ -256,3 +259,78 @@ Telegram делит с ним общий шаг заведения задачи, - **WHEN** программа спрашивает состояние по неизвестному идентификатору - **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 равен единице diff --git a/openspec/specs/pipeline/spec.md b/openspec/specs/pipeline/spec.md index 5ca293d..bfeaa69 100644 --- a/openspec/specs/pipeline/spec.md +++ b/openspec/specs/pipeline/spec.md @@ -3,16 +3,19 @@ ## Purpose Конвейер расшифровки: как задача движется по состояниям, что делает воркер, -когда работы нет, и что считается отказом шага. +когда работы нет, что считается отказом шага и что бывает с ответом отправителю, +когда доставить его некуда. Описаны пустой прогон воркера, неделимость захвата и срок его протухания, число -попыток и состояние «мертва», нарастающая пауза перед повтором и условие записи -результата держателем захвата. Сознательно не описаны: цепочка переходов -`created → converted → transcribe → done | failed`, отмена контекста посреди -шага и освобождение ресурсов внешних клиентов. Это не значит, что такого -поведения нет: оно живёт в коде, а требования на него не написаны, потому что -требование без проверки — предположение, а не норма. Первая задача, которая -трогает любое из перечисленного, дописывает его сюда. +попыток и состояние «мертва», нарастающая пауза перед повтором, условие записи +результата держателем захвата и недоставка ответа при неподнятом входе. +Сознательно не описаны: цепочка переходов `created → converted → transcribe → +done | failed`, отмена контекста посреди шага и освобождение ресурсов внешних +клиентов. Это не значит, что такого поведения нет: оно живёт в коде, а +требования на него не написаны, потому что требование без проверки — +предположение, а не норма. Первая задача, которая трогает любое из +перечисленного, дописывает его сюда. + ## Requirements ### Requirement: Пустой прогон воркера — не отказ @@ -260,3 +263,65 @@ MUST расти с числом её попыток до объявленног - **THEN** задержка до следующей проверки каждый раз одна и та же - **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** шаг завершается без отказа и без записи о недоставке diff --git a/tasks/items/local-run-without-telegram-token.md b/tasks/items/local-run-without-telegram-token.md index e3d528d..3e1a46e 100644 --- a/tasks/items/local-run-without-telegram-token.md +++ b/tasks/items/local-run-without-telegram-token.md @@ -44,5 +44,11 @@ отправителя с непустым токеном: прежний путь сохранён. - Задача из Telegram, дошедшая до ответа при отсутствующем боте, не роняет процесс и не теряется молча. Оракул — тест конвейера с заведённой задачей - источника Telegram и без отправителя: процесс жив, задача осталась пригодной к - повтору либо перешла в `failed`, и об этом есть строка журнала. + источника Telegram и без отправителя: процесс жив, задача осталась в + достигнутом состоянии, в повтор не ушла и в `failed` не переведена, а + недоставка видна записью журнала с идентификатором задачи. + +Правка критерия 2026-08-13, решение владельца на чекпоинте: прежняя редакция +требовала повтора либо `failed`, и оба исхода отвергнуты дизайном с ценой — +пометка отказа на удавшейся работе врёт панели и метрике, а повтор не помогает, +потому что бот от ожидания не появится. diff --git a/telegram_build.go b/telegram_build.go new file mode 100644 index 0000000..1e5a8df --- /dev/null +++ b/telegram_build.go @@ -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 + } +} diff --git a/telegram_build_test.go b/telegram_build_test.go new file mode 100644 index 0000000..18aa025 --- /dev/null +++ b/telegram_build_test.go @@ -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") +}