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