diff --git a/CLAUDE.md b/CLAUDE.md index bf2dae8..91e7c06 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -187,10 +187,14 @@ task gate # весь набор проверок разом ронять и пересоздавать можно свободно. - **Боевым токеном бота не запускаться.** Второй процесс с тем же токеном перехватывает обновления у работающего, и пользователь теряет ответы. Запускай - с **пустым** `telegram.bot_token`: сервис поднимается без Telegram и работает - одним входом, по HTTP. Пустого токена для подъёма мало — секции `[auth]` и - `[yandex]` проверяются на старте, но наружу при этом не ходят, так что годятся - выдуманные непустые значения; подробности строками в `config.dist.toml`. + с `telegram.enabled = false`: сервис поднимается без Telegram, к нему не уходит + ни одного обращения, и работает он одним входом, по HTTP. Пустого + `bot_token` для этого мало и больше не значит ничего: включён вход или нет, + решает отдельный признак `telegram.enabled`, а пустой ключ при `enabled = true` + роняет старт. Выключенного входа + для подъёма тоже мало: секции `[auth]` и `[yandex]` проверяются на старте, но + наружу при этом не ходят, так что годятся выдуманные непустые значения; + подробности строками в `config.dist.toml`. - **Yandex Cloud за деньги.** Распознавание и хранение в Object Storage оплачиваются по факту. Прогон на реальных ключах ради проверки кода запрещён — подставляй `internal/adapter/recognizer/memory.go`. diff --git a/config.dist.toml b/config.dist.toml index 8abeffd..6951b3a 100644 --- a/config.dist.toml +++ b/config.dist.toml @@ -61,24 +61,37 @@ secure_cookie = true # Telegram Bot Configuration [telegram] -# Токен Telegram бота (получить у @BotFather в Telegram). +# Нужен ли сервису вход Telegram. Ключ **обязателен**: умолчания у него нет, и +# файл без него негоден — сервис выходит с ошибкой настройки, назвав недостающий +# ключ. Умолчание было бы угаданным намерением, а признак заведён затем, чтобы +# намерение объявляли: любое умолчание делает одну из двух ошибок тихой — либо +# бот молча пропадает, либо файл без признака молча работает. # -# Пустое значение — объявленный режим: сервис поднимается без Telegram и -# работает одним входом, по HTTP. Бот при этом не заводится, записи из Telegram -# не принимаются, а ответы на задачи, принятые оттуда прежде, не уходят — +# false — сервис поднимается без Telegram и работает одним входом, по HTTP. Бот +# не заводится, к Telegram не уходит ни одного обращения, записи из Telegram не +# принимаются, а ответы на задачи, принятые оттуда прежде, не уходят — # недоставка видна записью журнала, расшифровка достаётся из панели и по HTTP. -# Старт роняет только один случай — Telegram ответил, что бота по такому токену -# нет: ждать тут нечего, это опечатка в настройке. Недоступность Telegram (сеть, -# DNS, авария Bot API) подъёму не мешает: сервис встаёт без бота и говорит об -# этом записью журнала, потому что основной вход у него другой. +# О выключенном входе сервис говорит одной записью журнала «к сведению»: это +# выбор владельца, а не отклонение. # -# Локальный прогон идёт именно так — боевым токеном запускаться запрещено: -# второй процесс с тем же токеном перехватывает обновления у работающего. -# Пустого токена для подъёма мало: секции [auth] и [yandex] проверяются на -# старте и роняют процесс на пустых ключах. Наружу при старте не ходит ни одна -# из них, поэтому для локального прогона годятся выдуманные непустые значения — -# адреса [auth] должны лишь разбираться как ссылки. Расшифровка при выдуманных -# ключах не работает: её подменяют в коде. +# true — сервис поднимает бота. Пустой bot_token при этом роняет старт: бота по +# пустому ключу не существует. Старт роняет и ответ Telegram «такого бота нет» — +# это опечатка в ключе, ждать тут нечего. А вот недоступность Telegram (сеть, +# DNS, авария Bot API) подъёму не мешает: сервис встаёт без бота и предупреждает +# записью журнала, потому что основной вход у него другой. +# +# Локальный прогон идёт с false — боевым токеном запускаться запрещено: второй +# процесс с тем же токеном перехватывает обновления у работающего. Выключенного +# входа для подъёма мало: секции [auth] и [yandex] проверяются на старте и +# роняют процесс на пустых ключах. Наружу при старте не ходит ни одна из них, +# поэтому для локального прогона годятся выдуманные непустые значения — адреса +# [auth] должны лишь разбираться как ссылки. Расшифровка при выдуманных ключах +# не работает: её подменяют в коде. +enabled = false + +# Токен Telegram бота (получить у @BotFather в Telegram). Только ключ доступа: +# включением входа он больше не заведует, этим занят enabled выше. При +# enabled = false не читается вовсе. bot_token = "" # Таймаут обновлений Telegram бота (в секундах) diff --git a/docs/adr/ADR-2026-08-13-telegram-intent-declared-not-inferred.md b/docs/adr/ADR-2026-08-13-telegram-intent-declared-not-inferred.md new file mode 100644 index 0000000..e537541 --- /dev/null +++ b/docs/adr/ADR-2026-08-13-telegram-intent-declared-not-inferred.md @@ -0,0 +1,66 @@ +# Намерение объявляется признаком, а не выводится из ключа доступа + +- **Дата:** 2026-08-13 +- **Источник:** openspec/changes/archive/2026-08-13-telegram-enabled-flag/design.md + +## Решение + +Вход Telegram включается отдельным признаком `telegram.enabled`, а `bot_token` +означает только доступ. Признак **обязателен**: умолчания у него нет, и файл +настроек без него негоден — сервис выходит с ошибкой настройки, назвав +недостающий ключ. + +## Почему + +Прежде пустой ключ доступа значил разом две вещи — «вход выключен намеренно» и +«ключа нет», — и сервис поднимался без бота в обоих случаях. Цена расхождения +падала на выкладку: файл настроек собирает Ansible, и потерянный при сборке ключ +выглядел для сервиса как решение владельца. + +Умолчания у признака нет, и это **намеренный отказ от очевидного подхода** — +булев ключ обычно заводят с умолчанием. Цитата из источника: + +> умолчание — это угаданное намерение, а признак заводится ровно затем, чтобы +> намерение объявляли. Файл, где его забыли, одинаково плохо читается в обе +> стороны, и любое умолчание делает одну из двух ошибок тихой. + +Отвергнуты оба умолчания. «Включён» — файл без признака работал бы «как-нибудь», +и разница между объявленным и угаданным намерением исчезала бы ровно там, где её +завели. «Выключен» — первый же подъём после выкладки выключил бы бота молча, то +есть дал бы исход, против которого написано само требование. + +Отсутствие ключа судит **разбор**, а не значение: `toml.MetaData.IsDefined` +отличает «не задан» от «задан ложным», тогда как нулевое значение `bool` у обоих +одинаковое. Форма поля с указателем отвергнута: указатель пережил бы проверку и +уехал к потребителям, где `nil` уже невозможен, но выглядит возможным. + +Тем же решением закрыт разрез текста отказа при разборе файла настроек. Цитата +из источника: + +> Пересказывать библиотеку нельзя: она собирает текст отказа из разбираемого +> куска файла, и оборванная строка секретного ключа уехала бы в журнал вместе со +> значением. + +Норму держит инвариант «Секрет не покидает конфиг», а форму записи — конвенция +настроек. Спеки загрузку настроек не нормируют, и это назначено явно: загрузка +не принадлежит ни одной заведённой capability. + +## Последствия + +- `+` потерянный при сборке файла ключ доступа роняет старт вслух, а не оставляет + сервис работать в половину силы; +- `+` выключенный вход перестал быть поводом для предупреждения: решение + владельца сообщается записью «к сведению», а предупреждение осталось за тем, + чего владелец не выбирал, — недоступностью Telegram; +- `+` оборванная строка секретного ключа больше не уносит значение в журнал + контейнера; +- `−` **порядок выкладки стал обязательным**: шаблон настроек обязан получить + признак раньше накатки образа, иначе сервис не поднимется вовсе. Правило живёт + в [architecture.md](../architecture.md), раздел «Эксплуатация», и задаётся там + по ключу, а не по файлу целиком; +- `−` один путь молчаливой потери бота остался: признак, ошибочно собранный + как «выключен», отличим от решения владельца только записью журнала. Признак + поднятости входа тут не помощник — он равен нулю и при недоступности Telegram; +- `−` отказ разбора файла настроек стал беднее на текст библиотеки: место и ключ + названы, а что именно в строке не так — нет. Плата принята ради инварианта, + помеченного необратимым. diff --git a/docs/adr/ADR-2026-08-13-telegram-outage-does-not-block-startup.md b/docs/adr/ADR-2026-08-13-telegram-outage-does-not-block-startup.md index d93e4e3..66eba06 100644 --- a/docs/adr/ADR-2026-08-13-telegram-outage-does-not-block-startup.md +++ b/docs/adr/ADR-2026-08-13-telegram-outage-does-not-block-startup.md @@ -56,3 +56,11 @@ выкладки), Telegram не отвергает — его отвергает разбор адреса, и такой случай попадает в недоступность, а не в ошибку настройки. Заметен он записью журнала, а не отказом старта. + +*Уточнено 2026-08-13:* исходов сборки клиента, роняющих старт, стало два — +к ответу «такого бота нет» добавился пустой ключ доступа при включённом входе. +Решение это не меняет: пустой ключ ошибкой настройки и был, просто прежде он +выражал ещё и отказ от входа, а теперь отказ выражает признак `telegram.enabled` +и до сборки клиента не доходит вовсе. Недоступность Telegram по-прежнему подъёму +не мешает — ровно как решено здесь. Разведение двух значений — отдельная запись, +[ADR-2026-08-13-telegram-intent-declared-not-inferred](ADR-2026-08-13-telegram-intent-declared-not-inferred.md). diff --git a/docs/adr/README.md b/docs/adr/README.md index f840844..65a0d53 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -35,6 +35,7 @@ | Дата | Запись | Статус | | --- | --- | --- | +| 2026-08-13 | [Намерение объявляется признаком, а не выводится из ключа доступа](ADR-2026-08-13-telegram-intent-declared-not-inferred.md) | | | 2026-08-13 | [Недоступность Telegram подъёму сервиса не мешает](ADR-2026-08-13-telegram-outage-does-not-block-startup.md) | | | 2026-08-12 | [Файл записи закрыт защищённым полем и отдаётся вошедшему по токену файла](ADR-2026-08-12-protected-file-behind-session.md) | | | 2026-08-12 | [Сессия живёт семь суток и не продлевает саму себя](ADR-2026-08-12-session-without-refresh.md) | | diff --git a/docs/architecture.md b/docs/architecture.md index b98a6af..5a744ce 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -17,7 +17,7 @@ - [intake](../openspec/specs/intake/spec.md) — **приём по HTTP плюс наличие входов**: приём и опрос за сессией, имя отправителя не доходит ни до хранилища, ни до журнала, метка метрики несёт только известное расширение, а - незаданный вход Telegram не мешает подъёму. Задачи + выключенный вход Telegram не мешает подъёму. Задачи `http-handler-tests-never-green` и `no-user-filename-in-log` 2026-08-11, `pocketbase-storage` и `oidc-login` 2026-08-12, `local-run-without-telegram-token` 2026-08-13. Приём из Telegram по существу — @@ -114,12 +114,26 @@ - **Где работает, что рядом, кто перезапускает:** один контейнер на личном сервере, разворачивает и перезапускает Ansible из `pet-project-server`. Рядом — обратный прокси, который публикует HTTP-порт наружу. -- **Пустой токен бота нельзя разворачивать раньше образа, который его понимает.** - Пустое значение стало объявленным режимом 2026-08-13; версии до неё роняли на - нём старт с кодом 1 **до** открытия порта. Значит, откат образа при уже - применённом пустом токене останавливает не бот, а весь сервис — вместе с HTTP - и панелью. Порядок: сперва образ, потом конфиг; при откате — наоборот. - Воспроизведено ревью кода на прежней версии. +- **Порядок выкладки задаётся по ключу, а не по файлу целиком.** Общего правила + «сперва образ» или «сперва конфиг» нет: два ключа секции Telegram требуют + противоположного, и оба правила действуют одновременно. + - **Признак включения `telegram.enabled` едет в конфиг раньше образа.** Он + обязателен с 2026-08-13, умолчания у него нет, и образ, который его ждёт, + без него выходит с кодом 1 **до** открытия порта — вместе с HTTP, панелью и + конвейером. Прежний образ лишний ключ TOML просто не читает, поэтому ранняя + правка конфига безопасна, а поздняя роняет сервис. + - **Пустой ключ доступа `telegram.bot_token` едет позже образа.** Образы + старше 2026-08-13 роняли старт на пустом ключе, тоже до открытия порта. + - **Откат при выключенном входе** допустим только на образ от 2026-08-13 и + новее. На более старом состояния «сервис поднят, бот опущен» не существует + вовсе: пустой ключ роняет старт, негодный роняет старт, годный поднимает + бота. Откат туда делают с непустым годным ключом, приняв, что бот поднимется. + - **Откат образа при `enabled = false` и заполненном ключе** отменяет решение + владельца молча: прежний образ признака не видит и поднимает бота. Если вход + был выключен потому, что бот с этим токеном поднят где-то ещё, два процесса + поделят один длинный опрос и часть ответов до людей не дойдёт. + + Ревью кода воспроизвело порядок на прежней версии, живой прогон — на нынешней. - **Внешние зависимости поимённо и чем каждая отказывает.** Столбец «отвечает медленно» читается вместе с тем, что таймаута нет ни у одного обращения наружу — [database.md](database.md), «Настройки с числовым значением»: @@ -128,7 +142,7 @@ | Зависимость | Падает | Отвечает медленно | Молчит | Отдаёт мусор | | --- | --- | --- | --- | --- | - | Telegram Bot API | Сервис поднимается без Telegram и работает по HTTP; старт роняет только ответ «такого бота нет». Норму держит [intake](../openspec/specs/intake/spec.md), «Недоступный или незаданный вход Telegram не мешает подъёму» | На старте — ждём не дольше срока, дальше поднимаемся без Telegram. У поднятого сервиса скачивание файла висит бесконечно: там срока нет | То же, что «отвечает медленно»: на старте — подъём без Telegram по истечении срока, у поднятого — длинный опрос пуст и новые задачи не заводятся | Файл скачался битым, отказ вылезет на конвертации | + | Telegram Bot API | Сервис поднимается без Telegram и работает по HTTP; старт роняют только ошибки настройки — ответ «такого бота нет» и включённый вход с пустым ключом доступа. Норму держит [intake](../openspec/specs/intake/spec.md), «Признак включения решает, поднимается ли вход Telegram» | На старте — ждём не дольше срока, дальше поднимаемся без Telegram. У поднятого сервиса скачивание файла висит бесконечно: там срока нет | То же, что «отвечает медленно»: на старте — подъём без Telegram по истечении срока, у поднятого — длинный опрос пуст и новые задачи не заводятся | Файл скачался битым, отказ вылезет на конвертации | | Yandex SpeechKit | Шаг возвращает ошибку, задача остаётся на повтор | Захват держится час, задача не двигается | Операция вечно `in progress`, повтор каждые 5 секунд | Пустой текст — задача завершается заглушкой «на записи нет текста» | | ↳ *остановка сервиса* | Принятие операции от отмены защищено своим пределом в 10 секунд: операцию там могли принять и начать считать деньги, а потерянный идентификатор заставил бы повтор оплатить ту же запись второй раз. Заливка в Object Storage отменяется штатно — её повтор бесплатен, объект ложится под тем же ключом | — | — | — | | Yandex Object Storage | Заливка падает, задача остаётся в `converted` | То же, что падение: висит до конца захвата | — | SpeechKit не прочитает объект и вернёт отказ операции | diff --git a/docs/conventions/config.md b/docs/conventions/config.md index ffb87a5..96c4181 100644 --- a/docs/conventions/config.md +++ b/docs/conventions/config.md @@ -102,6 +102,17 @@ Ansible из `pet-project-server`). Приложение просто читае - Загрузчик на старте проверяет, что обязательные секреты не пусты (ловит криво отрендеренный файл) — см. «Проверка и остановка на старте». - В логи секреты не попадают — см. [logging.md](logging.md), «Безопасность». +- **Отказ загрузки настроек не несёт содержимого файла.** Текст такого отказа + собирает библиотека разбора, и собирает она его из разбираемого куска: + `toml.ParseError` кладёт в сообщение само значение («Invalid float value: %q»). + Оборванная кавычка в строке секретного ключа — типовая поломка криво + отрендеренного шаблона выкладки — уносит ключ в журнал контейнера целиком, а + инвариант «секрет не покидает конфиг» помечен необратимым. Поэтому отказ + разбора пересобирается своими словами: путь, строка, столбец и последний ключ, + без сообщения библиотеки. Прочие отказы декодера (несовпадение типов, + неподдерживаемый тип) собраны из имён ключей и типов, значений в них нет, и их + текст остаётся как есть — иначе за разборчивость отказа платили бы там, где + платить не за что. ## Проверка и остановка на старте @@ -119,11 +130,16 @@ Ansible из `pet-project-server`). Приложение просто читае TOML. Пустые ключи Yandex ловятся в конструкторе распознавателя, и там процесс выходит с кодом 1. Единого места проверки нет. -Токен бота под это расхождение больше не подпадает: он судится при сборке -клиента, до подъёма сервера, и разрез у него объявленный — пустое значение -означает отказ от входа и даёт подъём без Telegram, непустое негодное роняет -старт как ошибка настройки. Нормирует это `openspec/specs/intake`, «Недоступный -или незаданный вход Telegram не мешает подъёму». +Под это расхождение больше не подпадают два ключа секции `[telegram]` — признак +включения и ключ доступа, — и проверок у них две. Третий ключ секции, +`update_timeout`, границ по-прежнему не проверяет никто, и ноль в нём обращает +длинный опрос в непрерывный. Обязательность признака включения судит загрузчик — только разбор отличает +«ключ не задан» от «ключ задан ложным», потому что нулевое значение `bool` у +обоих одинаковое. Заполненность ключа доступа судит `TelegramConfig.Validate()` из +`main.go`, рядом с проверкой `[auth]`: пустой `bot_token` при `enabled = true` — +ошибка настройки и отказ старта. Непустой негодный по-прежнему судится при сборке +клиента, до подъёма сервера. Нормирует это `openspec/specs/intake`, «Признак +включения решает, поднимается ли вход Telegram». Секция `[auth]` — первая, у которой проверка своя и стоит на старте: `AuthConfig.Validate()` зовётся из `main.go` сразу после загрузки и роняет @@ -142,3 +158,11 @@ TOML. Пустые ключи Yandex ловятся в конструкторе ([ADR](../adr/ADR-2026-08-12-single-data-dir-config-key.md)). - Умолчания задаются в `defaultConfig()`, файл их перекрывает. Новое поле требует правки обоих мест. +- **Обязательное поле — поле, у которого умолчания нет намеренно.** Умолчание у + такого поля было бы угаданным намерением, и одна из двух ошибок стала бы + тихой. Форма записи: умолчания нет ни в `defaultConfig()` (причина — строкой + комментария у самого поля), ни по нулевому значению типа; присутствие ключа + судит **разбор** — `MetaData.IsDefined` из `toml.DecodeFile`, — потому что + значение отличить «не задано» от «задано нулём» не позволяет. В + `config.dist.toml` у поля стоит значение свежей установки. Первое такое поле — + `telegram.enabled`. diff --git a/docs/research/README.md b/docs/research/README.md index c25295a..267a5d8 100644 --- a/docs/research/README.md +++ b/docs/research/README.md @@ -22,6 +22,7 @@ SpeechKit, Yandex Object Storage и `ffmpeg`. Мерить нужно то, чт | Дата | Запись | О чём | | --- | --- | --- | +| 2026-08-13 | [Разбор TOML: какое семейство отказов несёт значения из файла](toml-decode-errors.md) | Значения только в `ParseError.Message`, врущее поле `Line`, отказ значением в BurntSushi/toml v1.5.0 | | 2026-08-12 | [PocketBase: умолчания, которые ломают штатный сценарий](pocketbase-defaults.md) | Потолок файла 5 МиБ, тело 32 МиБ, таймаут чтения, суффикс имени, хук правки | | 2026-08-11 | [gRPC-клиент SpeechKit: когда закрытие вообще может отказать](grpc-client-close.md) | Ленивое соединение и два исхода `Close` в grpc v1.74.2 | | 2026-08-11 | [Фреймворк приложения: Svelte, Vue и React на одном экране](spa-framework.md) | Размер собранной статики, цена шага сборки, что у трёх кандидатов одинаково | diff --git a/docs/research/toml-decode-errors.md b/docs/research/toml-decode-errors.md new file mode 100644 index 0000000..e6c7404 --- /dev/null +++ b/docs/research/toml-decode-errors.md @@ -0,0 +1,55 @@ +# Разбор TOML: какое семейство отказов несёт значения из файла + +Отвечает на вопрос, возникший по ходу задачи `telegram-enabled-flag`: можно ли +пересказывать отказ библиотеки разбора в журнал, если в файле настроек лежат +секреты. Наблюдение понадобилось потому, что ревью дизайна назвало этот путь +утечкой, а чинить его без разреза пришлось бы выбрасыванием всего текста отказа — +то есть платой разборчивостью на каждой опечатке. + +## Как снималось + +Не замером, а **чтением исходников** зависимости, зафиксированной в `go.mod`: +`github.com/BurntSushi/toml` версии **v1.5.0**. Смотрел `error.go`, `parse.go`, +`decode.go`, `meta.go`, `lex.go` в кэше модулей. Дополнительно прогонял +`toml.Decode` на правдоподобных опечатках — в каталоге вне репозитория, чтобы не +править код проекта. + +## Что выяснилось + +- **Значения из файла несёт ровно одно семейство отказов — `toml.ParseError`.** + Его поле `Message` собирается из разбираемого куска: `Invalid float value: %q` + (`parse.go:341`), `invalid duration: %q`, `%v is out of range`, `Invalid + integer %q`. Туда же лексер отдаёт свои отказы через `panicItemf` + (`parse.go:134`). +- **Прочие отказы декодера значений не содержат вовсе.** Их строит `md.e` + (`decode.go:577`) и `md.badtype` — из имён ключей, имён типов (`%T` через + `fmtType`) и длин. Обойдены все места: `decode.go:282,288,297,329,348,385,388,399,428,437,467,487,518,552,561`. +- **`LastKey` секрета нести не может.** Текущий ключ присваивается только после + `itemKeyEnd`, то есть после `=` (`parse.go:200`), а лексер ключа до `=` не + доходит (`lex.go:481-501`). Посторонняя строка со значением ключом не станет. +- **Поле `Line` у `ParseError` врёт, а `Position.Line` — нет.** `panicErr` и + `panicItemf` кладут в устаревшее поле `Line` значение `it.pos.Len`, то есть + **длину**, а не номер строки (`parse.go:97,106`). Брать надо `Position.Line`. +- **`ParseError` возвращается значением, не указателем** (`decode.go:564`, + `parse.go` целиком), поэтому `errors.As` берёт целью `toml.ParseError`, а не + `*toml.ParseError`. `Unwrap` у типа нет. +- **Ветка без последнего ключа достижима обычной опечаткой.** Незакрытая скобка + секции даёт `LastKey=""`: + + ``` + вход "[telegram\nenabled = true\n" + → LastKey="" err=toml: line 2: expected '.' or ']' to end table name, but got '\n' instead + ``` + +## Что из этого следует для кода + +Разрез по семейству отказа: `ParseError` пересобирается своими словами — путь, +строка, столбец, последний ключ, — а его `Message` не берётся; прочие отказы +проходят как есть. Так инвариант «Секрет не покидает конфиг» держится, а +несовпадение типов по-прежнему называет ключ и типы. + +**Наблюдение привязано к версии.** Версия, переложившая значение в другое +семейство или сменившая возврат на указатель, вернёт утечку молча. Держат это +проверки поломанного файла настроек в `internal/config/config_test.go`; при +подъёме версии библиотеки их отказ читается как сигнал перечитать эту записку, а +не как случайный шум. diff --git a/docs/review.md b/docs/review.md index 38b7491..a7602ac 100644 --- a/docs/review.md +++ b/docs/review.md @@ -22,6 +22,15 @@ новое поведение видно в выводе). Признак дешёвый: если ожидаемого нового поля, метрики или строки нет вовсе — вероятнее всего, отвечает не твой процесс. +**Ни один проход не сообщает свой потолок, и это надо читать как границу +покрытия.** Прогон `telegram-enabled-flag` 2026-08-13: у прохода есть потолок +находок, и устав велит объявлять строкой, сколько осталось за срезом и какого +рода. Ни один из четырёх проходов такой строки не дал, и заметил это только +триаж. Пока так, «находок больше нет» в отчёте прохода неотличимо от «больше не +поместилось». Выше прочих риск у прохода, вбирающего темы разом: у него одна +квота на три темы. Механизации нет — потолок объявляет сам проход, и заставить его нечем; +остаётся сверка триажа. + Что уже проверяет машина и о чём поэтому спрашивать не нужно — конвенция [conventions/go-linters.md](conventions/go-linters.md). Вопросы ниже — то, чего машина не проверяет; свойства, которые обязан проверять тест, — в «Типовых @@ -240,11 +249,13 @@ API и имя не откатываются обратной правкой по `adapter/metaviewer/ffmpeg` нет; решение и его цена — в [adr/ADR-2026-08-11-stub-adapters-in-tests.md](adr/ADR-2026-08-11-stub-adapters-in-tests.md); - **работа сервиса с настоящими внешними собеседниками.** Сам сервис поднять - теперь можно: с пустым `telegram.bot_token` он встаёт и работает одним входом - (`openspec/specs/intake`, «Недоступный или незаданный вход Telegram не мешает - подъёму»). Живой прогон — осмотр HTTP, панели, журнала и остановки — доступен + теперь можно: с `telegram.enabled = false` он встаёт и работает одним входом + (`openspec/specs/intake`, «Признак включения решает, поднимается ли вход + Telegram»). Живой прогон — осмотр HTTP, панели, журнала и остановки — доступен теперь любой задаче. Прежняя формулировка «всё, что требует поднять сервис целиком» - снята задачей `local-run-without-telegram-token` 2026-08-13. + снята задачей `local-run-without-telegram-token` 2026-08-13; рецепт прогона + сменился с пустого ключа доступа на выключенный вход задачей + `telegram-enabled-flag` того же дня. **Остаток**: за настоящий Telegram, SpeechKit и Object Storage живой прогон по-прежнему не отвечает — боевым токеном запускаться запрещено, ключи Yandex в @@ -260,6 +271,38 @@ API и имя не откатываются обратной правкой по истории git 2026-08-10: поле «Чем воспроизведён» называет у них коммит, а не оракул, и выдумывать оракул задним числом нельзя. +## 2026-08-13 — сторож инварианта про секрет искал подстроку, которой не бывает [пойман ревью] + +- **Где:** `internal/config/config_test.go`, проверка «значение ключа доступа не + попадает в отказ» задачи `telegram-enabled-flag`. Дефект в самой проверке, кода + сервиса он не касался +- **Симптом:** проверка была зелёной и утверждала, что отказ `TelegramConfig.Validate()` + не несёт значения ключа доступа. Приёмочный критерий задачи считался закрытым ею +- **Причина:** двойная, и каждая половина достаточна. Утверждение искало + подстроку `enabled = true при`, а в сообщении стоит `при enabled = true` — + порядок слов обратный, и такой подстроки не бывает ни при каком входе. Глубже: + `Validate()` отказывает **только** на пустом ключе, то есть значения, которым + можно проговориться, на этом пути не существует вовсе. Комментарий при этом + утверждал «Ключ непуст», а в теле стояло `BotToken: ""` — описан был не тот + вход, который задан +- **Чем воспроизведён:** триаж скопировал дерево во временный каталог и заменил + тело `Validate()` на утекающее — `fmt.Errorf("... bot_token=%q ...", c.BotToken)`. + Проверка осталась зелёной +- **Почему не поймали раньше:** проверка написана в той же задаче и той же рукой, + что и код; гейт зелёный, а зелёная проверка неотличима от работающей. Поймали + два прохода независимо — разбор кода и сверка требований +- **Что меняем:** проверка переписана честно и переименована: половина требования + «сообщение не несёт значения» на этом пути **вакуумна**, и это названо прямо, а + настоящий сторож той же нормы указан по имени — он живёт там, где непустой ключ + в отказ попасть действительно может, в проверках отказа разбора файла настроек. + Класс всплывает **третий раз** (2026-08-11 «проверка приёма не могла упасть», + 2026-08-12 «проверка не могла упасть: читала живую карту заголовков»), и в этот + раз он другой природы: прежние два ловились правилом линтера про источник + утверждения, а этот — про **вход**: у сторожа утечки вход обязан содержать + значение, которое может утечь, иначе сторож пуст независимо от формы + утверждения. Механизации у этого нет и, похоже, быть не может: «может ли здесь + вообще утечь» — суждение, а не форма. Остаётся проходу ревью + ## 2026-08-13 — остановка сервиса хоронила конвертируемую запись [пойман ревью] - **Где:** `internal/service/transcribe.go`, шаг конвертации — дефект завела та diff --git a/docs/security.md b/docs/security.md index 8df33ee..ee818eb 100644 --- a/docs/security.md +++ b/docs/security.md @@ -288,6 +288,20 @@ Telegram отправителю. конструктора теперь чистится отдельно. Нашло это ревью кода тремя проходами независимо; оракул — там же, в `bot_test.go`. +Третий путь закрыт задачей `telegram-enabled-flag` 2026-08-13, и он **шире +токена бота**: до неё утечь мог любой секрет конфига. Отказ разбора файла +настроек пересказывался как есть, а библиотека разбора собирает текст отказа из +разбираемого куска — `toml.ParseError` кладёт в сообщение само значение. Строка +секретного ключа с оборванной кавычкой — типовая поломка криво собранного +шаблона выкладки — уносила ключ в журнал контейнера целиком. Теперь такой отказ +пересобирается своими словами: путь, строка, столбец и последний ключ, без текста +библиотеки; прочие отказы декодера собраны из имён ключей и типов и потому +проходят как есть. Нашло это ревью дизайна, чинилось решением владельца в той же +работе. Правило — [conventions/config.md](conventions/config.md), «Секреты»; +оракулы — `internal/config/config_test.go`, проверки поломанного файла настроек. +Остаточный риск назван там же: разрез опирается на то, какое семейство отказов +несёт значения **в нынешней версии** библиотеки. + ## Что вне модели Перечислить явно. diff --git a/go.mod b/go.mod index 7e1f99d..05dbc14 100644 --- a/go.mod +++ b/go.mod @@ -49,6 +49,7 @@ require ( github.com/go-sql-driver/mysql v1.9.2 // indirect github.com/golang-jwt/jwt/v5 v5.3.1 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/kylelemons/godebug v1.1.0 // indirect github.com/mattn/go-colorable v0.1.15 // indirect github.com/mattn/go-isatty v0.0.23 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect diff --git a/internal/adapter/telegram/absent.go b/internal/adapter/telegram/absent.go index f49a100..b1d9925 100644 --- a/internal/adapter/telegram/absent.go +++ b/internal/adapter/telegram/absent.go @@ -4,9 +4,10 @@ import ( "git.vakhrushev.me/av/transcriber/internal/contract" ) -// AbsentMessageSender подставляется вместо отправителя Telegram, когда токен -// бота не задан и клиента заводить не из чего. Он ничего не отправляет и на -// всякий ответ отдаёт `contract.ErrDeliveryChannelDown`. +// AbsentMessageSender подставляется вместо отправителя Telegram, когда вход +// выключен признаком `telegram.enabled` либо Telegram оказался недоступен, и +// клиента заводить не из чего. Он ничего не отправляет и на всякий ответ отдаёт +// `contract.ErrDeliveryChannelDown`. // // Заглушка, а не пустой отправитель: необязательная зависимость, доехавшая до // ядра нулём, роняет процесс на первой же задаче из Telegram, а проверка на diff --git a/internal/adapter/telegram/bot.go b/internal/adapter/telegram/bot.go index 195447e..fa4c67d 100644 --- a/internal/adapter/telegram/bot.go +++ b/internal/adapter/telegram/bot.go @@ -12,8 +12,14 @@ import ( tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" ) -// ErrEmptyToken — токен бота не задан. Отдельным значением, потому что подъём -// без Telegram — законный исход: сервис продолжает работать с HTTP API. +// ErrEmptyToken — ключ доступа пуст при включённом входе, то есть **ошибка +// настройки**: старт роняется. Отдельным значением, чтобы отличаться от +// недоступности Telegram, у которой исход обратный — подъём без бота. +// +// Отказ от входа Telegram этим значением больше не выражается: намерение +// объявляет признак включения `telegram.enabled`, и выключенный вход отсеивается +// до всякого обращения сюда. Пустой ключ ловит проверка настроек ещё раньше, +// поэтому сюда он доходит только в обход проверки. var ErrEmptyToken = errors.New("telegram bot token is empty") // NewBot заводит клиента Bot API — и это **единая точка**, через которую с diff --git a/internal/config/config.go b/internal/config/config.go index 154c97e..0a3cef8 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1,6 +1,7 @@ package config import ( + "errors" "fmt" "net/url" "os" @@ -41,11 +42,33 @@ type YandexConfig struct { ObjStorageEndpoint string `toml:"object_storage_endpoint"` } +// TelegramConfig — вход Telegram. Признак включения объявляет намерение +// владельца, `BotToken` означает только доступ. Пока два значения жили в одном +// поле, пустой токен читался разом как «вход выключен» и как «ключ не доехал», +// и сервис поднимался без бота в обоих случаях. type TelegramConfig struct { + // Enabled — умолчания у него нет **намеренно**, и потому его нет в + // `defaultConfig()`: умолчание было бы угаданным намерением, а признак + // заведён затем, чтобы намерение объявляли. Отсутствие ключа в файле ловит + // `LoadConfig` — нулевое значение `bool` режима не выбирает. + Enabled bool `toml:"enabled"` BotToken string `toml:"bot_token"` UpdateTimeout int `toml:"update_timeout"` } +// Validate проверяет ключ доступа против объявленного намерения. Пустой ключ +// при включённом входе — ошибка настройки: бот по нему не появится, а тихий +// подъём без бота оставил бы отправителей без ответов. +// +// Названо имя ключа, а не значение: значение `bot_token` в журнал попасть не +// должно. +func (c TelegramConfig) Validate() error { + if c.Enabled && c.BotToken == "" { + return errors.New("telegram: не заполнен ключ bot_token при enabled = true") + } + return nil +} + // AuthConfig — вход через внешнего провайдера OIDC. Адреса, идентификатор // клиента и секрет приезжают сюда и приводятся к настройкам коллекции // пользователей при каждом подъёме: применённый шаг схемы не переписывается, и @@ -129,6 +152,7 @@ func defaultConfig() *Config { ObjStorageRegion: "ru-central1", ObjStorageEndpoint: "https://storage.yandexcloud.net/", }, + // Умолчания у `Enabled` здесь нет намеренно — причина у поля. Telegram: TelegramConfig{ BotToken: "", UpdateTimeout: 10, @@ -149,9 +173,48 @@ func LoadConfig(path string) (*Config, error) { config := defaultConfig() // Load configuration from file - if _, err := toml.DecodeFile(path, &config); err != nil { - return nil, fmt.Errorf("failed to decode config file: %w", err) + meta, err := toml.DecodeFile(path, &config) + if err != nil { + return nil, decodeError(path, err) + } + + // Признак включения входа Telegram обязателен: умолчания у него нет, и + // отличить «не задан» от «задан ложным» умеет только разбор — нулевое + // значение `bool` в структуре у обоих одинаковое. Отсюда и `meta`: наружу + // она не отдаётся, приговор выносится здесь. + if !meta.IsDefined("telegram", "enabled") { + return nil, errors.New("telegram: не задан ключ enabled; он объявляет, нужен ли сервису вход Telegram") } return config, nil } + +// decodeError переводит отказ разбора на свои слова. Пересказывать библиотеку +// нельзя: она собирает текст отказа из разбираемого куска файла, и оборванная +// строка секретного ключа уехала бы в журнал вместе со значением. +// +// Разрез идёт по семейству отказа, и значения несёт только одно: +// +// - `toml.ParseError` — сюда сведены отказы лексера и разбора значения, а его +// `Message` собран из разбираемого куска («Invalid float value: %q», +// «invalid duration: %q»). Берём строку, столбец и последний ключ — они +// безопасны, — а `Message` не берём; +// - прочие отказы декодера собраны из имён ключей и имён типов, значений в них +// нет вовсе. Их текст берём как есть: выбросив его, мы заплатили бы +// разборчивостью отказа там, где платить не за что. +// +// Две ветки не сводятся в одну намеренно. Сведённая к общему знаменателю, она +// либо вернёт утечку, либо оставит несовпадение типов без единого намёка. +func decodeError(path string, err error) error { + var parseErr toml.ParseError + if errors.As(err, &parseErr) { + if parseErr.LastKey != "" { + return fmt.Errorf("config file %s: разбор оборвался на строке %d, столбце %d, последний ключ %q", + path, parseErr.Position.Line, parseErr.Position.Col, parseErr.LastKey) + } + return fmt.Errorf("config file %s: разбор оборвался на строке %d, столбце %d", + path, parseErr.Position.Line, parseErr.Position.Col) + } + + return fmt.Errorf("failed to decode config file %s: %w", path, err) +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 65eda3b..1f3f380 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -1,6 +1,9 @@ package config import ( + "fmt" + "os" + "path/filepath" "strings" "testing" ) @@ -95,3 +98,179 @@ func TestAuthConfigValidateRejectsMalformedURL(t *testing.T) { }) } } + +// Признак включения объявляет намерение, ключ доступа означает только доступ. +// Пока эти два значения жили в одном поле, пустой токен читался разом как +// «вход выключен» и как «ключ не доехал». + +func TestTelegramConfigValidateAcceptsEnabledWithToken(t *testing.T) { + cfg := TelegramConfig{Enabled: true, BotToken: "123456:AA-fake"} + + if err := cfg.Validate(); err != nil { + t.Fatalf("включённый вход с ключом отвергнут: %v", err) + } +} + +func TestTelegramConfigValidateRejectsEnabledWithoutToken(t *testing.T) { + cfg := TelegramConfig{Enabled: true, BotToken: ""} + + err := cfg.Validate() + if err == nil { + t.Fatal("включённый вход без ключа доступа пропущен") + } + if !strings.Contains(err.Error(), "bot_token") { + t.Fatalf("имя ключа не названо: %v", err) + } +} + +// Выключенный вход на ключ доступа не смотрит вовсе: пустой ключ при нём — +// обычное состояние локального прогона, а не ошибка настройки. +func TestTelegramConfigValidateIgnoresTokenWhenDisabled(t *testing.T) { + cfg := TelegramConfig{Enabled: false, BotToken: ""} + + if err := cfg.Validate(); err != nil { + t.Fatalf("выключенный вход без ключа отвергнут: %v", err) + } +} + +// Половина требования «сообщение не несёт значения ключа» на этой проверке +// **вакуумна**, и честнее это назвать, чем изображать сторожа. +// +// `Validate()` отказывает ровно на пустом ключе — значения, которым можно +// проговориться, на этом пути не существует. Прежняя редакция сторожа искала +// подстроку, которой в сообщении нет ни при каком входе, и потому не могла +// упасть вовсе: правка на `%q` от токена оставила бы её зелёной. В проекте это +// третий пойманный случай проверки, не способной упасть. +// +// Настоящий сторож той же нормы живёт там, где непустой ключ в отказ попасть +// действительно может, — `TestLoadConfigMalformedSecretLineHidesValue` и +// `TestLoadConfigMalformedBeforeAnyKeyHidesValue`. Здесь проверяется то, что +// проверяемо: заполненный ключ проходит, пустой отвергается с именем ключа. +func TestTelegramConfigValidateNamesKeyWithoutValue(t *testing.T) { + filled := TelegramConfig{Enabled: true, BotToken: "123456:AAHfake-secret-token-value"} + if err := filled.Validate(); err != nil { + t.Fatalf("включённый вход с заполненным ключом отвергнут: %v", err) + } + + err := TelegramConfig{Enabled: true, BotToken: ""}.Validate() + if err == nil { + t.Fatal("включённый вход без ключа доступа пропущен") + } + if !strings.Contains(err.Error(), "bot_token") { + t.Fatalf("имя ключа не названо: %v", err) + } +} + +func writeConfig(t *testing.T, body string) string { + t.Helper() + + path := filepath.Join(t.TempDir(), "config.toml") + if err := os.WriteFile(path, []byte(body), 0o600); err != nil { + t.Fatalf("не удалось записать файл настроек: %v", err) + } + return path +} + +const validConfigBody = ` +[telegram] +enabled = false +bot_token = "" +` + +// Признак обязателен: файл без него негоден. Умолчание было бы угаданным +// намерением, а отличить «не задан» от «задан ложным» умеет только разбор — +// нулевое значение bool у обоих одинаковое. +func TestLoadConfigRejectsMissingTelegramEnabled(t *testing.T) { + path := writeConfig(t, "[telegram]\nbot_token = \"123456:AA-fake\"\n") + + _, err := LoadConfig(path) + if err == nil { + t.Fatal("файл без признака включения принят") + } + if !strings.Contains(err.Error(), "enabled") { + t.Fatalf("имя недостающего ключа не названо: %v", err) + } +} + +func TestLoadConfigReadsBothValuesOfTelegramEnabled(t *testing.T) { + for _, enabled := range []bool{true, false} { + t.Run(fmt.Sprintf("%t", enabled), func(t *testing.T) { + body := fmt.Sprintf("[telegram]\nenabled = %t\nbot_token = \"123456:AA-fake\"\n", enabled) + + cfg, err := LoadConfig(writeConfig(t, body)) + if err != nil { + t.Fatalf("годный файл отвергнут: %v", err) + } + if cfg.Telegram.Enabled != enabled { + t.Fatalf("признак доехал как %t, а в файле %t", cfg.Telegram.Enabled, enabled) + } + }) + } +} + +// Инвариант «секрет не покидает конфиг»: текст отказа разбора собирает чужая +// библиотека из разбираемого куска файла, и оборванная строка ключа доступа +// уехала бы в журнал вместе со значением. Отсюда собственное сообщение. +func TestLoadConfigMalformedSecretLineHidesValue(t *testing.T) { + const secret = "123456:AAHfake-secret-token-value" + // Кавычка не закрыта: разбор оборвётся на значении. + path := writeConfig(t, "[telegram]\nenabled = true\nbot_token = \""+secret+"\n") + + _, err := LoadConfig(path) + if err == nil { + t.Fatal("поломанный файл настроек принят") + } + + message := err.Error() + for _, part := range []string{secret, "AAHfake", "secret-token-value", "123456"} { + if strings.Contains(message, part) { + t.Fatalf("значение ключа доступа уехало в отказ: %v", err) + } + } + // Без места и ключа отказ нечинибелен: скрыть значение мало. + if !strings.Contains(message, "строке 3") { + t.Fatalf("номер строки не назван, чинить нечего: %v", err) + } + if !strings.Contains(message, "bot_token") { + t.Fatalf("ключ не назван, чинить нечего: %v", err) + } +} + +// Обратная сторона того же разреза: отказ несовпадения типов собран из имён +// ключей и типов, значений в нём нет, и выбрасывать его текст незачем. +func TestLoadConfigTypeMismatchKeepsDiagnostics(t *testing.T) { + path := writeConfig(t, "[server]\nport = \"8080\"\n"+validConfigBody) + + _, err := LoadConfig(path) + if err == nil { + t.Fatal("строка вместо числа принята") + } + if !strings.Contains(err.Error(), "port") { + t.Fatalf("имя ключа не названо, чинить нечего: %v", err) + } +} + +// Вторая ветка разреза: разбор оборвался до всякого ключа, и последнего ключа +// нет вовсе. Пропущенная скобка секции — обычная опечатка, а ветка эта самая +// уязвимая: именно в ней будущая правка легче всего протащит текст библиотеки +// обратно. +func TestLoadConfigMalformedBeforeAnyKeyHidesValue(t *testing.T) { + const secret = "123456:AAHsecret-token-value" + // У секции не закрыта скобка: разбор оборвётся, не назвав ни одного ключа. + path := writeConfig(t, "[telegram\nenabled = true\nbot_token = \""+secret+"\"\n") + + _, err := LoadConfig(path) + if err == nil { + t.Fatal("поломанный файл настроек принят") + } + + message := err.Error() + for _, part := range []string{secret, "AAHsecret", "secret-token-value"} { + if strings.Contains(message, part) { + t.Fatalf("значение ключа доступа уехало в отказ: %v", err) + } + } + if !strings.Contains(message, "строке") { + t.Fatalf("место отказа не названо, чинить нечего: %v", err) + } +} diff --git a/main.go b/main.go index 6635a26..19986bc 100644 --- a/main.go +++ b/main.go @@ -59,6 +59,14 @@ func main() { os.Exit(1) } + // Включённый вход без ключа доступа — ошибка настройки, а не режим: бот по + // пустому ключу не появится, а тихий подъём без него оставил бы отправителей + // без ответов. + if err := cfg.Telegram.Validate(); err != nil { + logger.Error("Unable to start with incomplete telegram settings", "error", err) + os.Exit(1) + } + // Загружаем переменные окружения из .env файла if err := godotenv.Load(); err != nil { logger.Warn("Warning: .env file not found, using system environment variables") @@ -88,7 +96,7 @@ func main() { metaviewer := ffmpegmv.NewFfmpegMetaViewer() converter := ffmpegconv.NewFfmpegConverter() - tgBot, tgSender, err := buildTelegram(cfg.Telegram.BotToken, logger) + tgBot, tgSender, err := buildTelegram(cfg.Telegram, logger) if err != nil { logger.Error("Failed to create Telegram bot", "error", err) os.Exit(1) diff --git a/openspec/changes/archive/2026-08-13-telegram-enabled-flag/.openspec.yaml b/openspec/changes/archive/2026-08-13-telegram-enabled-flag/.openspec.yaml new file mode 100644 index 0000000..b6b2d1f --- /dev/null +++ b/openspec/changes/archive/2026-08-13-telegram-enabled-flag/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-13 diff --git a/openspec/changes/archive/2026-08-13-telegram-enabled-flag/design.md b/openspec/changes/archive/2026-08-13-telegram-enabled-flag/design.md new file mode 100644 index 0000000..630d9d8 --- /dev/null +++ b/openspec/changes/archive/2026-08-13-telegram-enabled-flag/design.md @@ -0,0 +1,240 @@ +## Context + +Разрез «поднимать ли вход Telegram» сегодня проходит по пустоте ключа доступа: +`telegram.bot_token = ""` означает и «вход выключен намеренно», и «ключа нет». +Разрез объявлен решением владельца от 2026-08-13 и записан в +[ADR-2026-08-13-telegram-outage-does-not-block-startup](../../../docs/adr/ADR-2026-08-13-telegram-outage-does-not-block-startup.md); +здесь меняется не он, а то, **откуда** сервис узнаёт намерение владельца. + +Ограничения, с которыми считаемся: + +- файл настроек на сервере собирает Ansible из `pet-project-server`, и ключ + доступа приезжает туда из внешнего хранилища секретов. Значение, потерянное при + сборке, неотличимо от решения владельца; +- инвариант «секрет не покидает конфиг» — ни сообщение об отказе старта, ни + запись журнала не несут значения ключа. Проверка секции `[auth]` уже устроена + так и служит здесь образцом; +- локальный прогон боевым токеном запрещён, и подъём без Telegram — его обычный + режим. Он не должен стать труднее. + +## Goals / Non-Goals + +**Goals:** + +- признак включения объявляет намерение, ключ доступа означает только доступ; +- включённый вход без ключа роняет старт с внятным сообщением; +- выключенный вход сообщается записью журнала, не поднимая уровень до + предупреждения; +- локальный прогон одним входом остаётся одной строкой настройки. + +**Non-Goals:** + +- приём записи из Telegram, белый список и доставка ответов; +- чистка прочих путей, где секрет мог бы уехать наружу: работа закрывает один + названный ревью — текст отказа разбора файла настроек; +- единое место проверки настроек для всех секций: `[auth]` и `[telegram]` пока + проверяются каждая своим методом, и сведение их в один проход — отдельная + работа; +- правка шаблона настроек в `pet-project-server`: его правит человек, здесь он + только назван. + +## Decisions + +### Признак обязателен, умолчания у него нет + +Файл настроек без ключа `enabled` негоден: загрузка кончается отказом, и процесс +выходит с ошибкой настройки. Решение владельца от 2026-08-13. + +Довод: умолчание — это угаданное намерение, а признак заводится ровно затем, +чтобы намерение объявляли. Файл, где его забыли, одинаково плохо читается в обе +стороны, и любое умолчание делает одну из двух ошибок тихой. + +Альтернативы и причина отказа: + +- **умолчание «включён»** — отвергнуто владельцем: файл без признака работал бы + «как-нибудь», и разница между объявленным и угаданным намерением исчезала бы + ровно там, где её завели; +- **умолчание «выключен»** — отвергнуто и по тому же доводу, и отдельно: первый + же подъём после выкладки выключил бы бота молча. Это исход, против которого + написано само требование. + +Цена решения — порядок выкладки: шаблон настроек обязан получить признак раньше +образа. Она названа в разделе «Migration Plan» и на чекпоинте. + +### Отсутствие ключа ловит загрузчик, пустой ключ — проверка секции + +Разрез идёт по тому, **о чём судим**. Отсутствие ключа — свойство файла, и +видит его только разбор: `toml.DecodeFile` отдаёт `MetaData`, и `IsDefined` +отвечает, был ли ключ в файле вообще. Значение поля — свойство настройки, и +судит его `TelegramConfig.Validate()` по образцу `AuthConfig.Validate()`. + +Альтернатива — сделать поле `*bool` и свести обе проверки в `Validate()` — +отвергнута: указатель переживает проверку и уезжает к потребителям, где `nil` +уже невозможен, но выглядит возможным. Читатель настройки платит за форму, +нужную одному разбору. + +`MetaData` из `LoadConfig` наружу не отдаётся: отказ формируется на месте, и +знание о разборе не растекается. + +### Проверка ключа живёт в настройках, а не в сборке входа + +`TelegramConfig.Validate()` зовётся из `main.go` сразу после загрузки, рядом с +проверкой секции `[auth]`, роняет процесс, называет **имя** незаполненного ключа +и не касается значения. + +Альтернатива — оставить проверку внутри сборки клиента, как сейчас, — отвергнута: +сборка ходит в сеть, и отказ настройки смешался бы там с отказом Telegram. Читать +разрез пришлось бы по типу ошибки, а не по месту. + +### Ветка «токен пуст» в разборе сборки меняет исход + +Сегодня `telegramFromBot` на `telegram.ErrEmptyToken` отдаёт мягкий исход: сервис +поднимается без Telegram. После разведения это состояние по построению +недостижимо — проверка настроек ловит его раньше, — но ветку не убираем: она +получает исход «ошибка настройки, старт роняется» и встаёт рядом с отказом Bot +API. + +Причина: удалённая ветка оставила бы пустой ключ падать в общий случай `err != +nil`, то есть в «недоступность», и обход проверки настроек дал бы тихий подъём — +ровно то, что мы убираем. Ветка, недостижимая по построению, но дающая верный +исход, дешевле ветки, дающей неверный. + +Единая точка `telegram.NewBot` и значение `telegram.ErrEmptyToken` остаются как +есть: они держат инвариант «Bot API только через нашего клиента». + +### Отказ разбора файла настроек говорит своими словами + +Найдено ревью дизайна и чинится этой же работой по решению владельца. + +`toml.DecodeFile` отдаёт отказы двух семейств, и значения несёт **только одно**: + +- `toml.ParseError` — сюда сведены отказы лексера и разбора значения, и его поле + `Message` собирается из разбираемого куска (`Invalid float value: %q`, + `invalid duration: %q`, `%v is out of range`). Незакавыченный токен из криво + собранного шаблона выкладки попадает в текст целиком. Из этого отказа берём + **строку, столбец и последний ключ** — они безопасны, — а `Message` не берём; +- прочие отказы декодера (несовпадение типов, неподдерживаемый тип) собираются + из **имён ключей и имён типов**, значений в них нет. Их текст берём как есть: + выбрасывать его значило бы платить разборчивостью отказа там, где платить не за + что. + +Отвергнутые альтернативы: + +- **выбросить текст обоих семейств** — просто и закрыто наглухо, но за + несовпадение типов (`port = "8080"`) владелец получал бы «файл не + разбирается» без единого намёка, а значения там нет по построению; +- **вычищать значения из текста** — вычищать не с чем: разбор не состоялся, и + значений в настройках ещё нет; +- **брать `Message`, когда последний ключ не секретный** — перечень секретных + ключей живёт в конвенции и разошёлся бы с кодом молча, а расхождение здесь + означает утечку. + +**Спеки это не меняет, и требования под себя не заводит.** Норма уже записана и +сильнее спеки: инвариант «Секрет не покидает конфиг» в `CLAUDE.md` со степенью +`critical`. Работа приводит код в соответствие с записанным, а не заказывает +новое поведение. Форма записи отказа уезжает в конвенцию настроек, раздел +«Секреты», — там её дом. + +**Дом нормы назначен явно, и это выбор, а не умолчание.** Загрузка настроек не +принадлежит ни одной заведённой capability: `intake` сама объявляет, что нормирует +наличие входа, а не приём; `access`, `pipeline` и `storage` к разбору файла +отношения не имеют. Заводить capability подъёма ради одного семейства отказов +дороже выигрыша, а вписывать разбор настроек в `intake` значит переносить туда +чужое. Поэтому дом нормы — **инвариант `CLAUDE.md` плюс конвенция +`docs/conventions/config.md`**, и спеки загрузку настроек не нормируют. +Найдено ревью кода; цена решения в том, что при следующей ревизии семейства +отказов спека не скажет ничего и опорой будут конвенция и проверки. + +### Выключенный вход — уровень `INFO` + +Предупреждение говорит «случилось не то, что ты просил». Выключенный вход — ровно +то, что просил владелец, и на каждом локальном прогоне это давало бы шум, +неотличимый от настоящей недоступности. Недоступность остаётся `WARN`. + +Признак поднятости входа (`IntakeUpGauge`) выставляется во всех случаях, включая +выключенный: наблюдателю нужен ответ «работает ли вход сейчас», а не «почему». + +### Сборка входа получает настройки секцией, а решение о выключенном входе — шов + +`buildTelegram` принимает `config.TelegramConfig` целиком вместо одного токена: +решение «поднимать или нет» читает оба поля, и разносить их по двум аргументам +значит заводить два места, где их сверяют. + +Само решение уезжает в `telegramFromConfig(cfg, newBot, logger)`, где `newBot` — +параметр-функция сборки клиента; `buildTelegram` подставляет туда +`telegram.NewBot`. Иначе главное утверждение выключенного входа — **обращения к +Telegram не уходит ни одного** — проверить нечем: `telegram.NewBot` держит адрес +Bot API внутри, и проверка, судящая по исходу, останется зелёной и тогда, когда +ветка выключенного входа встанет **после** обращения. Тогда прогон с заполненным +ключом ходил бы в живой Telegram боевым токеном, а проверка этого не заметила бы. + +Шов — параметр-функция, а не интерфейс: реализация у него одна, и вводить ради +неё тип значит заводить понятие там, где хватает подписи. Прецедент в проекте +свой и того же рода — `telegram.newBot(token, endpoint, logger)` принимает адрес +отдельно ровно затем, чтобы проверка не ходила в сеть. + +## Risks / Trade-offs + +- **Файл настроек на сервере отстал от кода** → сервис не поднимется вовсе: + признака в файле нет, загрузка кончается отказом. Это главный риск работы, и + снимается он порядком выкладки — сперва шаблон настроек, потом образ. Отказ + громкий, называет ключ и виден в первую же минуту; молчаливая потеря бота + обошлась бы дороже, но порядок соблюсти обязан человек. +- **Локальный файл настроек отстал от кода** → тот же отказ и та же починка: + одна строка `enabled = false`. +- **Проверок настроек стало две вместо одной** → расхождение между ними ловится + только глазами. Сведение в один проход названо Non-Goal и остаётся работой на + потом. +- **Ошибочный `enabled = false` из шаблона выкладки** → работа закрывает одно + русло молчаливой потери бота (потерян ключ доступа) и оставляет второе: + признак, отрендеренный ложным из-за пропущенной переменной, отличим от решения + владельца **только записью журнала** — `INFO` против `WARN`. Признак + поднятости входа тут не помощник: он равен нулю и при выключенном входе, и при + недоступности Telegram, то есть от аварии этот случай не отделяет, а + собственного оповещения у проекта нет вовсе. Сервис поднимается штатно, и + владелец узнаёт о беде от молчащего бота — тем же способом, что и прежде. + Ненаписанный риск читается как несуществующий, поэтому он назван здесь: ключ + `enabled` в шаблоне выкладки критический. +- **Остаточный риск утечки при смене версии библиотеки разбора** → разрез ниже + опирается на то, какие семейства отказов несут значения сегодня. Версия + библиотеки, переложившая значение в другое семейство, вернёт утечку молча. + Держится это проверкой на поломанной строке секретного ключа; она же краснеет + при таком переносе. +- **Ветка, недостижимая по построению**, живёт в коде и её нельзя проверить + через настройки → проверяется напрямую на уровне разбора исхода сборки, как + уже устроены соседние ветки. + +## Migration Plan + +Порядок обязателен, и нарушение его роняет сервис на сервере. + +1. Код и образец настроек едут вместе: `config.dist.toml` получает + `enabled = false` при пустом ключе доступа — это состояние свежей локальной + установки. +2. **Раньше накатки образа** шаблон настроек в `pet-project-server` получает + строку `enabled = true`, и файл на сервере перерисовывается. Правит человек, + отдельно от этой работы; пока правки нет, новый образ на сервер не едет. +3. Только после этого едет образ. + +Откат: вернуть прежний образ. Файл настроек с ключом `enabled` прежний код +разбирает без отказа — лишний ключ TOML разбор не роняет, он просто не читается, +и бот поднимается по непустому токену. + +**Откат при выключенном входе допустим только на образ от 2026-08-13 и новее.** +На более старом состояния «сервис поднят, бот опущен» не существует вовсе: +пустой ключ роняет старт, негодный роняет старт, годный поднимает бота. Откат +туда делают с непустым годным ключом, приняв, что бот поднимется; рецепт ниже на +таком образе ведёт к выходу с кодом 1 до открытия порта. + +**Один случай отката требует и отката настроек** — `enabled = false` при +заполненном ключе доступа, то самое состояние, ради которого два значения и +разводятся. Прежний код признака не видит и поднимает бота, то есть отменяет +решение владельца молча. Если вход был выключен потому, что бот с этим токеном +поднят где-то ещё, два процесса поделят один длинный опрос и часть ответов до +людей не дойдёт — прямо тот вред, который называет запрет «Боевым токеном бота не +запускаться». Откат в этом состоянии начинается с очистки ключа доступа. + +## Open Questions + +Открытых нет: умолчание признака решено владельцем 2026-08-13 — признак +обязателен, умолчания у него нет. diff --git a/openspec/changes/archive/2026-08-13-telegram-enabled-flag/proposal.md b/openspec/changes/archive/2026-08-13-telegram-enabled-flag/proposal.md new file mode 100644 index 0000000..6bc847d --- /dev/null +++ b/openspec/changes/archive/2026-08-13-telegram-enabled-flag/proposal.md @@ -0,0 +1,67 @@ +## Why + +Сегодня пустой токен бота означает сразу две разные вещи: «вход Telegram +выключен намеренно» и «ключа доступа нет». Владелец не может сказать сервису +«бот мне нужен» отдельно от «вот ключ», а сервис не может отличить осознанный +отказ от входа от криво отрендеренного файла настроек — и в обоих случаях +поднимается без бота. + +Цена расхождения падает на выкладку: файл настроек собирает Ansible, и потерянный +при сборке ключ выглядит для сервиса ровно так же, как решение владельца обойтись +одним входом. Бот молча перестаёт отвечать своим отправителям, а узнать об этом +неоткуда. + +## What Changes + +- В настройках входа Telegram появляется отдельный признак включения. Он и + объявляет намерение: нужен ли сервису этот вход вообще. +- Ключ доступа перестаёт нести второе значение. Он читается и проверяется + **только** при включённом входе, а при выключенном не смотрится вовсе. +- Включённый вход без ключа доступа становится ошибкой настройки: сервис + говорит, какого ключа не хватает, и не поднимается. Прежде такой файл давал + тихий подъём без бота. +- Выключенный вход перестаёт быть поводом для предупреждения в журнале: решение + владельца сообщается обычной записью, а предупреждение остаётся за тем, чего + владелец не выбирал, — недоступностью Telegram. +- Отказ разбора файла настроек перестаёт пересказывать библиотеку разбора и + говорит своими словами: где сломалось и на каком ключе, но не что там + написано. Прежде поломанная строка секретного ключа уезжала в журнал вместе со + своим значением. +- **BREAKING** для файла настроек: у секции Telegram появляется новый + **обязательный** ключ. Умолчания у него нет: файл без признака негоден, и + сервис выходит с ошибкой настройки. Решение владельца от 2026-08-13 — намерение + объявляют, а не угадывают по умолчанию, и файл, где его забыли объявить, не + должен работать «как-нибудь». + +Прежние правила подъёма при включённом входе сохраняются целиком: Telegram +отвечает «такого бота нет» — старт кончается отказом; Telegram недоступен или +молчит дольше срока — сервис поднимается одним входом и говорит об этом +предупреждением. Признак поднятости входа наблюдателю виден во всех случаях. + +## Capabilities + +### New Capabilities + +Новых нет: речь о том, с какими входами сервис вправе подняться, а это уже +нормировано. + +### Modified Capabilities + +- `intake`: требование «Недоступный или незаданный вход Telegram не мешает + подъёму» перестаёт выводить намерение из ключа доступа. Оно начинает опираться + на объявленный признак включения, получает два новых отказа старта — признака + в настройках нет и вход включён без ключа — и разводит уровни записей журнала + по тому, выбрал ли владелец это состояние. + +## Impact + +- Настройки: секция `[telegram]` в `config.toml` и в образце + `config.dist.toml`; структура настроек и умолчания в `internal/config`. +- Подъём: разбор случая при сборке входа Telegram (`telegram_build.go`) и вызов + проверки настроек в `main.go`. +- Выкладка: шаблон настроек в `pet-project-server` обязан получить признак + включения **до** накатки нового образа, иначе сервис не поднимется. Правит его + человек, здесь только называем. +- Документы: запрет на боевой токен в `CLAUDE.md`, конвенция настроек + `docs/conventions/config.md`, таблица отказов в `docs/architecture.md`. +- Приём записи из Telegram, белый список и доставка ответов не затрагиваются. diff --git a/openspec/changes/archive/2026-08-13-telegram-enabled-flag/review/code-review.md b/openspec/changes/archive/2026-08-13-telegram-enabled-flag/review/code-review.md new file mode 100644 index 0000000..4d7386b --- /dev/null +++ b/openspec/changes/archive/2026-08-13-telegram-enabled-flag/review/code-review.md @@ -0,0 +1,86 @@ +# Ревью кода — telegram-enabled-flag + +Метка `medium`, режим по графу. Состав: `autotests`, `specs`, `code`, `basics`, +`triage`. Проходы `adversary`, `ops`, `architecture` не запускались — живут с +метки `large`. + +## План с исходом по каждой теме + +| Тема | Дом | Глубина | Кто закрывает | Исход | +| --- | --- | --- | --- | --- | +| requirements | `openspec/specs/intake/spec.md` + дельта | разбор | specs | закрыта, 3 находки | +| autotests | `CLAUDE.md`, «Гейт» | — | autotests | закрыта, 3 находки | +| conventions | `docs/conventions/config.md` | разбор | code | закрыта, 3 находки | +| architecture | `docs/architecture.md`, «Компоненты», «Единые точки» | разбор | basics | закрыта, находок нет | +| security | `docs/security.md` | разбор | basics | закрыта, находок нет | +| operations | `docs/architecture.md`, «Эксплуатация» | разбор | basics | закрыта, 2 находки | + +Тем без отчёта нет, тем без дома нет, своих тем проекта нет. + +## Состояние гейта + +Зелёные: `build`, `vet`, `gofmt`, `tests` (`-race`, флака нет при `-count=1` +трижды), `golangci-lint` (0 issues), `shell`, `dockerfile`, `go-version`, +`migrations`, `openspec`, `vulns`. + +Красные: `docs` и `tasks` — «проект приведён к раскладке версии 3, текущая — 4». +Краснота **унаследована**: проход `autotests` воспроизвёл её в отдельном рабочем +дереве на чистом `903941f` без диффа. Чинится операцией `upgrade` скилла +`av-dev:canon` и к этой работе не относится. + +`vulns`: единственная уязвимость `GO-2026-5932` в `golang.org/x/crypto/openpgp` +недостижима из кода и уже названа в `CLAUDE.md`. + +## Находки и что с ними сделано + +15 сырых находок, после дедупликации по причине — 8 живых. + +| № | Находка | Severity | Исход | +| --- | --- | --- | --- | +| — | `telegramFromConfig` не прогонялся с включённым входом (покрытие `2 0`) | major | починено до остальных проходов: два теста на связку «собрать клиента → разобрать исход» | +| — | Ветка `decodeError` с пустым последним ключом не покрыта (`1 0`) | major | починено: тест на опечатку «незакрытая скобка секции» | +| 1 | Раздел «Эксплуатация» предписывает обратный порядок выкладки — по нему сервис не поднимется вовсе | major | **принята**, `docs/architecture.md` переписан: порядок задаётся по ключу, а не по файлу, плюс два случая отката | +| 2 | Сторож инварианта «секрет не покидает конфиг» зелен по построению | major | **принята**, проверка переписана честно; оракул триажа — мутация `Validate()` на утечку оставляла её зелёной | +| 3 | Шапки `ErrEmptyToken` и `AbsentMessageSender` защищают снятое поведение | minor | **принята**, обе переписаны под новый разрез | +| 4 | Признак поднятости входа не держится ни одной проверкой | minor | **принята**, утверждение о нуле добавлено в тест выключенного входа | +| 5 | Новый абзац конвенции снимает с учёта непроверяемую границу `update_timeout` | minor | **принята**, утверждение сужено до двух ключей | +| 6 | Норма «отказ разбора не несёт значения» живёт вне спек, дом не назначен | minor | **принята как развилка (а)**: дом назначен явно в `design.md` — инвариант плюс конвенция | +| — | Смена версии библиотеки разбора могла бы вернуть утечку | гипотеза | действия не требует: ловится добавленными проверками, подтверждено мутацией | +| — | Третий случай отката: образ старше 2026-08-13 | гипотеза | свёрнуто в находку 1, записано вопросом владельцу | + +Проход `security` находок не дал: починку утечки он проверил по исходникам +библиотеки независимо и признал разрез верным. + +## Сигнал о заниженной метке + +`review-code` подал сигнал: изменение вводит обязательный ключ настроек без +умолчания, уже выложенный файл после этого не грузится, а имя ключа конфига +проект числит необратимым. На метке `large` порядок выкладки и откат закрывал бы +отдельный проход `ops`. Сигнал материализовался находкой 1 — её нашёл `basics` +попутно, а не проход, для неё предназначенный. `review-basics` возражений по +метке не подавал. Метка прогона не пересматривалась: правило запрещает. + +## Границы покрытия + +- **Три прохода не запускались** — `adversary`, `ops`, `architecture`. Уносят с + собой враждебный разбор входов, отдельный разбор выкладки и отката, и + независимый разбор архитектурного решения. +- **Ни один из четырёх проходов не сообщил свой потолок и остаток за срезом.** + Это находка о самом прогоне: без такой строки «находок больше нет» + неотличимо от «больше не поместилось». У `basics` риск выше прочих — одна + квота на три темы. +- **Решения проекта не сверялись**: `docs/adr/` — процессный документ, прогон его + не открывает. Расхождение с `ADR-2026-08-13-telegram-outage-does-not-block-startup`, + который это изменение частично отменяет, ловит не ревью, а сверка документации. +- **Записанные наблюдения не использовались**: `docs/research/` не открывался. +- **Поимённая сверка с руководствами по стилю Go не задавалась никем** — в + частности, для шва-параметра вместо интерфейса. +- **Альтернативной реализации, с которой можно сдиффить решения, у конвейера + нет** — проход независимой реализации снят по стоимости. +- **Живьём проверяемо не всё.** Подъём, отказ старта, маршруты и остановка — + проверены. Приём из Telegram, расшифровка и заливка — нет: боевым токеном + запускаться запрещено, ключи Yandex выдуманы, распознавание подменяется в + коде. Новый разрез при включённом входе с настоящим ботом не проверялся ничем, + кроме подставной сборки клиента. +- Шаблон настроек в `pet-project-server` лежит в чужом репозитории и во вход не + входил ни одному проходу. diff --git a/openspec/changes/archive/2026-08-13-telegram-enabled-flag/review/design-review.md b/openspec/changes/archive/2026-08-13-telegram-enabled-flag/review/design-review.md new file mode 100644 index 0000000..53dfd24 --- /dev/null +++ b/openspec/changes/archive/2026-08-13-telegram-enabled-flag/review/design-review.md @@ -0,0 +1,63 @@ +# Ревью дизайна — telegram-enabled-flag + +Метка `medium`, назначена агентом `review-scope` (размер среднее, сложность +знакомое). Режим по графу. Состав по метке: `specs` (режим «дизайн ДО кода») и +`rubric`. Триажа на этой стадии нет — сток стадии — шаг отработки замечаний. + +## Находки и что с ними сделано + +| Проход | Находка | Severity | Исход | +| --- | --- | --- | --- | +| specs | У выключенного входа нет оракула: «бот не заведён, отказа нет» остаётся верным и когда ветка встала **после** обращения, а обращение ушло боевым токеном в живой Telegram | major | принята. Заведён шов `telegramFromConfig(cfg, newBot, logger)`, шаг 3.3 судит по счётчику вызовов, а не по исходу | +| specs | `ADR-2026-08-13-telegram-outage-does-not-block-startup` утверждает, что старт роняет ровно один исход сборки клиента; после изменения их два | minor | принята. Шаг 4.7: новый ADR и парный статус прежнему | +| specs | `docs/architecture.md` (перечень capability) и `docs/review.md` (рецепт живого прогона) останутся ложными: они учат поднимать сервис пустым токеном | minor | принята. Шаги 4.5 и 4.6 | +| specs | Ошибочный `enabled = false` из шаблона выкладки — оставшееся русло молчаливой потери бота — в рисках не назван | minor | принята. Строка в `Risks / Trade-offs` | +| rubric | Отказ разбора файла настроек может унести секрет в журнал: `toml.ParseError` встраивает разбираемое значение в текст | major | **снята из объёма, ушла в урожай.** Путь существует сегодня (`LoadConfig` заворачивает через `%w`, `main.go:49` печатает целиком) и этой работой не заводится; у починки своя цена — потеря подробности отказа | +| rubric | Задача, принятая из Telegram до выключения входа, завершится, а ответ не уйдёт | major | **снята: ложноположительная.** Уже нормировано `openspec/specs/pipeline/spec.md`, «Недоставленный ответ не роняет шаг», сценарий «Вход отправителя не поднят»: `WARN`, метрика, идентификатор задачи. Проход читал только `intake`; `specs` пришёл к тому же выводу независимо | +| rubric | Откат образа при `enabled = false` и непустом ключе тихо поднимает выключенного бота | minor | принята. Абзац в `Migration Plan` | +| rubric | `docs/conventions/config.md`, раздел «Структура в коде», останется утверждать, что умолчание есть у каждого поля | minor | принята. Шаг 4.4 расширен на второй раздел | + +## Правки, сделанные по урожаю + +Дельта-спеки не менялись ни одной правкой — значит разметка не повторялась и +метка осталась `medium`. Правки легли в `design.md` (шов сборки, два риска, +абзац отката) и в `tasks.md` (шаги 2.2, 3.2, 3.3, 4.4, 4.5, 4.6, 4.7, рубрика в +критерии приёмки). + +## Сознательно не сделано + +Сценарий «Вход выключен» не получил строки `**AND** признак поднятости входа +Telegram равен нулю`. Её держит соседнее требование «Поднятые входы видны +наблюдателю», чей сценарий стоит на премиссе «сервис поднялся без Telegram» и +новое состояние покрывает. Правка изменила бы дельта-спеку и потребовала бы +повторной разметки, не дав сегодня ничего. + +## Границы спеки — что осталось неопределённым + +- **Небулево значение признака** (`enabled = "yes"`) попадает в общий отказ + разбора и имени ключа не называет, хотя оба соседних сценария отказа этого + требуют. +- **Несколько негодных секций разом**: `[auth]` и `[telegram]` проверяются + порознь, и спека не говорит, обязан ли отказ перечислить все ключи. +- **`update_timeout` при выключенном входе** — читается или игнорируется, не + нормировано. Вреда нет, но вопрос стал видимым: сборка получает секцию целиком. +- **Проба готовности при выключенном входе** ни одним требованием не связана с + признаком. Граница существовала и до изменения. + +## Границы покрытия стадии + +- Кода нет по построению: направление `spec → code` недоступно, судилось только + задуманное. +- Рубрика составлена не открывая код и дизайн — иначе она подстроилась бы под + увиденное. +- Решения (`docs/adr/`) и измеренные числа (`docs/research/`) прогон ревью не + открывает: расхождение изменения с записанным решением ловит не он, а сверка + документации. Здесь оно всё же всплыло — проход `specs` наткнулся на ADR через + ссылку из `design.md`, а не обходом каталога. +- Ничего не запускалось: `openspec validate --strict` — единственная выполненная + команда. +- `review-architecture` на предложении не запускался: он живёт с метки `large`. + Вопрос «не появился ли второй способ делать то же самое» на этом изменении не + задавал никто. +- Шаблон настроек в `pet-project-server` лежит в чужом репозитории и во вход не + входил ни одному проходу. diff --git a/openspec/changes/archive/2026-08-13-telegram-enabled-flag/specs/intake/spec.md b/openspec/changes/archive/2026-08-13-telegram-enabled-flag/specs/intake/spec.md new file mode 100644 index 0000000..cf318b2 --- /dev/null +++ b/openspec/changes/archive/2026-08-13-telegram-enabled-flag/specs/intake/spec.md @@ -0,0 +1,113 @@ +## REMOVED Requirements + +### Requirement: Недоступный или незаданный вход Telegram не мешает подъёму + +**Reason**: Требование выводило намерение владельца из ключа доступа: пустой ключ +означал разом и «вход выключен», и «ключа нет». Разведение этих двух значений +меняет и премиссу требования — включённый вход без ключа теперь подъёму мешает, +и прежнее имя стало неверным. + +**Migration**: Заменено требованием «Признак включения решает, поднимается ли +вход Telegram». Прежние правила для включённого входа перенесены в него дословно; +добавлены случай выключенного входа и случай включённого входа без ключа. + +## ADDED Requirements + +### Requirement: Признак включения решает, поднимается ли вход Telegram + +Намерение владельца SHALL объявляться отдельным признаком включения входа +Telegram, а ключ доступа MUST означать только доступ. При выключенном входе +сервис MUST подниматься без Telegram и MUST не смотреть на ключ доступа вовсе. +При включённом входе пустой ключ MUST быть отказом старта: сообщение называет имя +незаполненного ключа и MUST не нести его значения. + +Признак включения MUST быть в настройках задан. Умолчания у него нет: файл, где +признака нет вовсе, негоден, и сервис MUST выходить с ошибкой настройки, назвав +недостающий ключ. Умолчание здесь было бы угаданным намерением, а признак заведён +затем, чтобы намерение объявляли: любое умолчание делает одну из двух ошибок +тихой — либо бот молча пропадает, либо файл без признака молча работает. + +Выключенный вход MUST быть назван в журнале **ровно одной** записью уровня `INFO` +при старте. Это выбор владельца, а не отклонение, и предупреждать о нём не о чем; +предупреждение остаётся за тем, чего владелец не выбирал. + +При включённом входе сервис SHALL подниматься, когда вход поднять не удалось, и +MUST продолжать работу оставшимся входом: приём по HTTP, опрос готовности и +конвейер расшифровки работают в полном объёме. Неподнятый вход MUST быть назван в +журнале **ровно одной** записью уровня `WARN` при старте — с причиной и без +значения ключа. + +Исключение одно, и оно проходит по тому, **ответил ли Telegram**. Ответ «такого +бота нет» — ошибка настройки: бот по этому ключу не появится ни от ожидания, ни +от повтора, и старт MUST кончаться отказом. Сервис, молча потерявший бота после +опечатки в ключе, перестаёт отвечать своим отправителям, и узнать об этом было бы +неоткуда. + +Всё прочее — недоступность: сеть, DNS, авария Bot API, истёкший срок ожидания. +Она MUST не влиять на подъём. Основной вход сервиса — не Telegram, и ронять его +целиком из-за чужой аварии нельзя: перезапуск в такую минуту оставил бы без +работы и приём по HTTP, и панель, и конвейер, которому Telegram не нужен вовсе. + +Ожидание при сборке MUST быть ограничено сроком. Без него недоступность +неотличима от подъёма: обращение к Telegram стоит на пути старта, и молчащий +собеседник останавливал бы его бессрочно — без записи, без порта и без пробы +здоровья. + +Требование нормирует **наличие входа**, а не приём из него. + +#### Scenario: Вход выключен + +- **GIVEN** в настройках сервиса вход Telegram выключен +- **WHEN** сервис запускается +- **THEN** он поднимается и принимает записи по HTTP +- **AND** конвейер расшифровки работает +- **AND** бот не заведён, а в журнале ровно одна запись уровня `INFO` о том, что + вход выключен настройкой + +#### Scenario: Вход выключен, а ключ доступа задан + +- **GIVEN** в настройках сервиса вход Telegram выключен +- **AND** ключ доступа при этом заполнен +- **WHEN** сервис запускается +- **THEN** он поднимается без Telegram, и бот не заводится +- **AND** к Telegram не уходит ни одного обращения + +#### Scenario: Вход включён, а ключа доступа нет + +- **GIVEN** в настройках сервиса вход Telegram включён +- **AND** ключ доступа пуст +- **WHEN** сервис запускается +- **THEN** старт кончается отказом +- **AND** сообщение об отказе называет имя незаполненного ключа + +#### Scenario: Признака включения в настройках нет + +- **GIVEN** в настройках сервиса нет признака включения входа Telegram +- **AND** ключ доступа заполнен и Telegram признаёт по нему бота +- **WHEN** сервис запускается +- **THEN** старт кончается отказом настройки +- **AND** сообщение об отказе называет недостающий ключ + +#### Scenario: Вход включён и ключ годен + +- **GIVEN** в настройках сервиса вход Telegram включён +- **AND** стоит ключ, по которому Telegram признаёт бота +- **WHEN** сервис запускается +- **THEN** он поднимается и работает обоими входами + +#### Scenario: Telegram не отвечает + +- **GIVEN** в настройках сервиса вход Telegram включён и ключ непуст +- **AND** Telegram недоступен либо не отвечает дольше отведённого срока +- **WHEN** сервис запускается +- **THEN** он поднимается и принимает записи по HTTP +- **AND** бот не заведён, а в журнале запись уровня `WARN` с причиной +- **AND** запись не несёт значения ключа + +#### Scenario: Telegram ответил, что такого бота нет + +- **GIVEN** в настройках сервиса вход Telegram включён и ключ непуст +- **AND** Telegram отвечает отказом на этот ключ +- **WHEN** сервис запускается +- **THEN** старт кончается отказом +- **AND** ни журнал, ни текст отказа не несут значения ключа diff --git a/openspec/changes/archive/2026-08-13-telegram-enabled-flag/tasks.md b/openspec/changes/archive/2026-08-13-telegram-enabled-flag/tasks.md new file mode 100644 index 0000000..633921a --- /dev/null +++ b/openspec/changes/archive/2026-08-13-telegram-enabled-flag/tasks.md @@ -0,0 +1,160 @@ +## Критерии приёмки + +Постановка пришла текстом и критериев не назвала. Ниже — **предложенные**; +данными они становятся после ответа на чекпоинте. + +- В секции `[telegram]` файла настроек есть ключ `enabled`, и он один решает, + поднимается ли вход. Ключ доступа второго значения не несёт. +- Файл настроек с `enabled = false` даёт подъём одним входом, к Telegram не + уходит ни одного обращения, а в журнале ровно одна запись уровня `INFO`. +- Файл настроек с `enabled = true` и пустым `bot_token` роняет старт; сообщение + называет имя ключа и не содержит его значения. +- Файл настроек без ключа `enabled` негоден: загрузка кончается отказом, и + сообщение называет недостающий ключ. Умолчания у признака нет. +- Прежние правила при включённом входе сохранены: отказ Bot API роняет старт, + недоступность Telegram даёт подъём с записью уровня `WARN`. +- Признак поднятости входа Telegram выставляется во всех случаях, включая + выключенный. +- `task gate` зелёный. + +Ниже — рубрика ревью дизайна, теми же критериями. Пункты, целиком совпавшие с +перечнем выше, не повторяются. + +- **Таблица режимов полна.** Для каждой комбинации «признак задан или нет × + признак истинен или ложен × ключ доступа пуст, непуст или подсказка» назван + ровно один исход из трёх: подъём с ботом, подъём без бота, отказ старта. +- **Опечатка не выключает вход молча.** `enable`, `Enabled`, ключ в чужой + секции, отсутствующая секция — каждый случай даёт отказ, а не тихий выбор + режима по нулевому значению. +- **Проверка целиком предшествует необратимому.** Приговор о настройках выносится + до открытия порта, до применения шагов схемы и до создания каталогов. +- **Выбранный режим наблюдаем, и наблюдаемость различает основания.** Из журнала + и метрик видно и «работает ли вход сейчас», и «по какому основанию он не + поднят»: выбор владельца, ошибка настройки, недоступность собеседника. +- **Решение о режиме принимается один раз и в одном месте.** Порядок «умолчания + → файл → приговор» зафиксирован; ни один потребитель не пересчитывает + «поднят ли вход» из полей настроек самостоятельно. +- **Виды отказа различимы по сообщению:** файла нет, файл не разбирается, ключ + не задан, ключ задан негодно — по каждому видно, что чинить, и код выхода + ненулевой. +- **Выключенный вход не оставляет хвостов.** Клиент не заводится, сетевого + обращения нет, остановка не ждёт несуществующего собеседника. +- **Обратная совместимость файла названа в обе стороны** — что делает новый код + со старым файлом и старый код с новым, вместе с порядком выкладки и условиями + отката. +- **Отсутствие умолчания объявлено там, где записана конвенция**, а не только + комментарием в коде. +- **Отказ разбора файла настроек не несёт содержимого файла.** Поломанная строка + секретного ключа даёт отказ с номером строки и именем ключа, но без единой + подстроки значения. Несовпадение типов при этом по-прежнему называет ключ и + типы. + +## 1. Настройки + +- [x] 1.1 Добавить поле `Enabled bool` с тегом `toml:"enabled"` в + `config.TelegramConfig`; умолчания в `defaultConfig()` для него не заводить + и объяснить это комментарием +- [x] 1.2 Поднять `MetaData` из `toml.DecodeFile` в `LoadConfig` и отказывать в + загрузке, когда ключ `telegram.enabled` в файле не задан; сообщение + называет ключ. `MetaData` наружу из `LoadConfig` не отдавать +- [x] 1.3 Написать `TelegramConfig.Validate()` по образцу `AuthConfig.Validate()`: + при `Enabled` и пустом `BotToken` вернуть отказ с именем ключа `bot_token` + и без его значения +- [x] 1.4 Позвать `cfg.Telegram.Validate()` в `main.go` рядом с проверкой + секции `[auth]`; отказ роняет процесс через `logger.Error` и `os.Exit(1)` +- [x] 1.5 Проверить `TelegramConfig.Validate()` тестами: включён и ключ есть — + ошибки нет; включён и ключ пуст — ошибка называет `bot_token` и не несёт + значения; выключен и ключ пуст — ошибки нет +- [x] 1.6 Проверить `LoadConfig` тестом на временном файле: секция `[telegram]` + без ключа `enabled` даёт отказ с именем ключа; с ключом — загрузка проходит + и значение доезжает обоими значениями + +## 1а. Отказ разбора не несёт содержимого файла + +- [x] 1а.1 В `LoadConfig` перестать заворачивать отказ `toml.DecodeFile` через + `%w`: разобрать его по семействам и собрать сообщение самому +- [x] 1а.2 `toml.ParseError` (через `errors.As`) — взять путь, строку, столбец и + последний ключ; поле `Message` в сообщение не брать +- [x] 1а.3 Прочие отказы декодера — взять текст как есть: он собран из имён + ключей и типов. Причину разреза записать комментарием, иначе следующая + правка сведёт две ветки в одну +- [x] 1а.4 Проверить тестом на временном файле: строка `bot_token` с оборванной + кавычкой даёт отказ, в тексте которого нет ни одной подстроки значения, + но есть номер строки и имя ключа +- [x] 1а.5 Проверить тестом, что несовпадение типов (строка вместо числа) + по-прежнему называет ключ и типы + +## 2. Сборка входа + +- [x] 2.1 Сменить подпись `buildTelegram` на приём `config.TelegramConfig` + целиком и поправить вызов в `main.go` +- [x] 2.2 Вынести решение в `telegramFromConfig(cfg, newBot, logger)`, где + `newBot` — параметр-функция сборки клиента; `buildTelegram` подставляет + `telegram.NewBot`. Ветка выключенного входа стоит **до** вызова `newBot`: + выставляет признак поднятости в ноль, пишет одну строку уровня `INFO` и + возвращает заглушку отправителя +- [x] 2.3 Свести в `telegramFromBot` ветку `telegram.ErrEmptyToken` с веткой + отказа Bot API: оба исхода — ошибка настройки, старт роняется +- [x] 2.4 Обновить комментарий-разрез над `telegramFromBot`: он описывает три + исхода по прежнему разрезу + +## 3. Проверки поведения + +- [x] 3.1 Заменить тест `TestTelegramFromBotOnEmptyTokenGivesAbsentSender` + проверкой нового исхода: пустой ключ при включённом входе роняет старт, + заглушка не подставляется +- [x] 3.2 Написать тест на выключенный вход через `telegramFromConfig`: старт не + падает, ядро получает заглушку, в журнале ровно одна запись уровня `INFO` +- [x] 3.3 Проверить главное утверждение выключенного входа **счётчиком, а не + исходом**: `telegramFromConfig` с `Enabled = false` и заполненным + (заведомо ненастоящим) ключом зовёт подставную сборку **ноль раз**. Судить + по «бот не заведён, отказа нет» нельзя: эти утверждения остаются верными и + тогда, когда ветка встала после обращения, а обращение ушло в живой + Telegram +- [x] 3.4 Оставшиеся тесты `telegramFromBot` (отказ Bot API, недоступность, + живой бот) прогнать без правок по существу + +## 4. Настройки и документы + +- [x] 4.1 Добавить `enabled` в секцию `[telegram]` образца `config.dist.toml` + со значением `false` и комментарием: зачем поле, что значит каждое + значение, что ключ обязателен и умолчания у него нет +- [x] 4.2 Переписать комментарий к `bot_token` в образце: он больше не отвечает + за включение входа +- [x] 4.3 Поправить запрет «Боевым токеном бота не запускаться» в `CLAUDE.md`: + локальный прогон идёт с `enabled = false`, а не с пустым токеном +- [x] 4.0 Записать в `docs/conventions/config.md`, раздел «Секреты», правило + «отказ загрузки настроек не несёт содержимого файла» с причиной: текст + отказа собирает чужая библиотека, и разбираемый кусок попадает в него + целиком +- [x] 4.4 Поправить `docs/conventions/config.md` в **двух** разделах: «Проверка + и остановка на старте» описывает прежний разрез по пустоте токена, а + «Структура в коде» утверждает, что новое поле требует правки обоих мест, + включая `defaultConfig()`. Записать там форму обязательного поля без + умолчания, иначе следующий такой ключ получит угаданное намерение обратно +- [x] 4.5 Поправить `docs/architecture.md` в **двух** местах: строку перечня + capability («незаданный вход Telegram не мешает подъёму» — после изменения + ложно и ссылается на снятое имя требования) и строку про Telegram в + таблице отказов +- [x] 4.6 Поправить `docs/review.md`: рецепт живого прогона там велит поднимать + сервис с пустым `telegram.bot_token`, а после изменения так он не встанет +- [ ] 4.7 Завести ADR о том, что намерение объявляется признаком, а не выводится + из ключа доступа, и проставить парный статус + `ADR-2026-08-13-telegram-outage-does-not-block-startup`: он утверждает, что + старт роняет ровно один исход сборки клиента, а после изменения их два. + Заводить через скилл `av-dev:doc-sync`, на шаге синка документации + +## 5. Гейт и живой прогон + +- [ ] 5.1 `task gate` зелёный — **не выполнено, и причина не в этой работе**: + шаги `docs` и `tasks` красные оба по одной причине — проект приведён к + раскладке av-dev версии 3, а плагин ждёт версии 4. Проверено на чистом + `HEAD` в отдельном рабочем дереве: там те же два шага и то же + расхождение. Чинится операцией `upgrade` скилла `av-dev:canon`, и это + отдельная работа. Прочие шаги гейта зелёные +- [x] 5.2 Живой прогон: подъём с `enabled = false` — сервис встаёт, в журнале + одна запись `INFO`, признак поднятости входа Telegram равен нулю +- [x] 5.3 Живой прогон: подъём с `enabled = true` и пустым `bot_token` — процесс + выходит с ненулевым кодом, сообщение называет ключ +- [x] 5.4 Живой прогон: подъём с секцией `[telegram]` без ключа `enabled` — + процесс выходит с ненулевым кодом, сообщение называет недостающий ключ diff --git a/openspec/specs/intake/spec.md b/openspec/specs/intake/spec.md index bf2c279..8337b44 100644 --- a/openspec/specs/intake/spec.md +++ b/openspec/specs/intake/spec.md @@ -12,7 +12,6 @@ требованиями по-прежнему не описано — требование, написанное без проверки, это предположение, а не норма. Первая задача, которая трогает поведение приёма из Telegram, дописывает его сюда. - ## Requirements ### Requirement: Приём записи по HTTP @@ -259,64 +258,6 @@ Telegram, дописывает его сюда. - **WHEN** программа спрашивает состояние по неизвестному идентификатору - **THEN** ответ имеет код `404` и сообщение о ненайденной задаче -### Requirement: Недоступный или незаданный вход Telegram не мешает подъёму - -Сервис SHALL подниматься, когда вход Telegram поднять не удалось, и MUST -продолжать работу оставшимся входом: приём по HTTP, опрос готовности и конвейер -расшифровки работают в полном объёме. Неподнятый вход MUST быть назван в журнале -**ровно одной** записью уровня `WARN` при старте — с причиной и без значения -токена. - -Исключение одно, и оно проходит по тому, **ответил ли Telegram**. Ответ «такого -бота нет» — ошибка настройки: бот по этому токену не появится ни от ожидания, ни -от повтора, и старт MUST кончаться отказом. Сервис, молча потерявший бота после -опечатки в токене, перестаёт отвечать своим отправителям, и узнать об этом было -бы неоткуда. - -Всё прочее — недоступность: сеть, DNS, авария Bot API, истёкший срок ожидания. -Она MUST не влиять на подъём. Основной вход сервиса — не Telegram, и ронять его -целиком из-за чужой аварии нельзя: перезапуск в такую минуту оставил бы без -работы и приём по HTTP, и панель, и конвейер, которому Telegram не нужен вовсе. - -Ожидание при сборке MUST быть ограничено сроком. Без него недоступность -неотличима от подъёма: обращение к Telegram стоит на пути старта, и молчащий -собеседник останавливал бы его бессрочно — без записи, без порта и без пробы -здоровья. - -Требование нормирует **наличие входа**, а не приём из него. - -#### Scenario: Токен не задан - -- **GIVEN** в настройках сервиса токен бота пуст -- **WHEN** сервис запускается -- **THEN** он поднимается и принимает записи по HTTP -- **AND** конвейер расшифровки работает -- **AND** бот не заведён, а в журнале ровно одна запись уровня `WARN` о том, что - он не поднят и почему - -#### Scenario: Токен задан и годен - -- **GIVEN** в настройках сервиса стоит токен, по которому Telegram признаёт бота -- **WHEN** сервис запускается -- **THEN** он поднимается и работает обоими входами - -#### Scenario: Telegram не отвечает - -- **GIVEN** в настройках сервиса стоит непустой токен -- **AND** Telegram недоступен либо не отвечает дольше отведённого срока -- **WHEN** сервис запускается -- **THEN** он поднимается и принимает записи по HTTP -- **AND** бот не заведён, а в журнале запись уровня `WARN` с причиной -- **AND** запись не несёт значения токена - -#### Scenario: Telegram ответил, что такого бота нет - -- **GIVEN** в настройках сервиса стоит непустой токен -- **AND** Telegram отвечает отказом на этот токен -- **WHEN** сервис запускается -- **THEN** старт кончается отказом -- **AND** ни журнал, ни текст отказа не несут значения токена - ### Requirement: Поднятые входы видны наблюдателю Сервис SHALL отдавать признак поднятости по каждому входу приёма отдельной @@ -334,3 +275,103 @@ Telegram, дописывает его сюда. - **WHEN** наблюдатель читает метрики - **THEN** признак поднятости входа Telegram равен нулю - **AND** признак поднятости входа HTTP равен единице + +### Requirement: Признак включения решает, поднимается ли вход Telegram + +Намерение владельца SHALL объявляться отдельным признаком включения входа +Telegram, а ключ доступа MUST означать только доступ. При выключенном входе +сервис MUST подниматься без Telegram и MUST не смотреть на ключ доступа вовсе. +При включённом входе пустой ключ MUST быть отказом старта: сообщение называет имя +незаполненного ключа и MUST не нести его значения. + +Признак включения MUST быть в настройках задан. Умолчания у него нет: файл, где +признака нет вовсе, негоден, и сервис MUST выходить с ошибкой настройки, назвав +недостающий ключ. Умолчание здесь было бы угаданным намерением, а признак заведён +затем, чтобы намерение объявляли: любое умолчание делает одну из двух ошибок +тихой — либо бот молча пропадает, либо файл без признака молча работает. + +Выключенный вход MUST быть назван в журнале **ровно одной** записью уровня `INFO` +при старте. Это выбор владельца, а не отклонение, и предупреждать о нём не о чем; +предупреждение остаётся за тем, чего владелец не выбирал. + +При включённом входе сервис SHALL подниматься, когда вход поднять не удалось, и +MUST продолжать работу оставшимся входом: приём по HTTP, опрос готовности и +конвейер расшифровки работают в полном объёме. Неподнятый вход MUST быть назван в +журнале **ровно одной** записью уровня `WARN` при старте — с причиной и без +значения ключа. + +Исключение одно, и оно проходит по тому, **ответил ли Telegram**. Ответ «такого +бота нет» — ошибка настройки: бот по этому ключу не появится ни от ожидания, ни +от повтора, и старт MUST кончаться отказом. Сервис, молча потерявший бота после +опечатки в ключе, перестаёт отвечать своим отправителям, и узнать об этом было бы +неоткуда. + +Всё прочее — недоступность: сеть, DNS, авария Bot API, истёкший срок ожидания. +Она MUST не влиять на подъём. Основной вход сервиса — не Telegram, и ронять его +целиком из-за чужой аварии нельзя: перезапуск в такую минуту оставил бы без +работы и приём по HTTP, и панель, и конвейер, которому Telegram не нужен вовсе. + +Ожидание при сборке MUST быть ограничено сроком. Без него недоступность +неотличима от подъёма: обращение к Telegram стоит на пути старта, и молчащий +собеседник останавливал бы его бессрочно — без записи, без порта и без пробы +здоровья. + +Требование нормирует **наличие входа**, а не приём из него. + +#### Scenario: Вход выключен + +- **GIVEN** в настройках сервиса вход Telegram выключен +- **WHEN** сервис запускается +- **THEN** он поднимается и принимает записи по HTTP +- **AND** конвейер расшифровки работает +- **AND** бот не заведён, а в журнале ровно одна запись уровня `INFO` о том, что + вход выключен настройкой + +#### Scenario: Вход выключен, а ключ доступа задан + +- **GIVEN** в настройках сервиса вход Telegram выключен +- **AND** ключ доступа при этом заполнен +- **WHEN** сервис запускается +- **THEN** он поднимается без Telegram, и бот не заводится +- **AND** к Telegram не уходит ни одного обращения + +#### Scenario: Вход включён, а ключа доступа нет + +- **GIVEN** в настройках сервиса вход Telegram включён +- **AND** ключ доступа пуст +- **WHEN** сервис запускается +- **THEN** старт кончается отказом +- **AND** сообщение об отказе называет имя незаполненного ключа + +#### Scenario: Признака включения в настройках нет + +- **GIVEN** в настройках сервиса нет признака включения входа Telegram +- **AND** ключ доступа заполнен и Telegram признаёт по нему бота +- **WHEN** сервис запускается +- **THEN** старт кончается отказом настройки +- **AND** сообщение об отказе называет недостающий ключ + +#### Scenario: Вход включён и ключ годен + +- **GIVEN** в настройках сервиса вход Telegram включён +- **AND** стоит ключ, по которому Telegram признаёт бота +- **WHEN** сервис запускается +- **THEN** он поднимается и работает обоими входами + +#### Scenario: Telegram не отвечает + +- **GIVEN** в настройках сервиса вход Telegram включён и ключ непуст +- **AND** Telegram недоступен либо не отвечает дольше отведённого срока +- **WHEN** сервис запускается +- **THEN** он поднимается и принимает записи по HTTP +- **AND** бот не заведён, а в журнале запись уровня `WARN` с причиной +- **AND** запись не несёт значения ключа + +#### Scenario: Telegram ответил, что такого бота нет + +- **GIVEN** в настройках сервиса вход Telegram включён и ключ непуст +- **AND** Telegram отвечает отказом на этот ключ +- **WHEN** сервис запускается +- **THEN** старт кончается отказом +- **AND** ни журнал, ни текст отказа не несут значения ключа + diff --git a/telegram_build.go b/telegram_build.go index 1e5a8df..60c1d81 100644 --- a/telegram_build.go +++ b/telegram_build.go @@ -5,6 +5,7 @@ import ( "log/slog" "git.vakhrushev.me/av/transcriber/internal/adapter/telegram" + "git.vakhrushev.me/av/transcriber/internal/config" "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" @@ -14,8 +15,38 @@ import ( // раз** и достаётся обоим — отправителю ответов и транспорту бота. Пока его // строили порознь, два пути одного старта разошлись: один ронял процесс на // негодном токене, другой терпел, и согласовывать их приходилось руками. -func buildTelegram(botToken string, logger *slog.Logger) (*tgbotapi.BotAPI, contract.TelegramMessageSender, error) { - bot, err := telegram.NewBot(botToken, logger) +func buildTelegram(cfg config.TelegramConfig, logger *slog.Logger) (*tgbotapi.BotAPI, contract.TelegramMessageSender, error) { + return telegramFromConfig(cfg, telegram.NewBot, logger) +} + +// telegramFromConfig решает, поднимать ли вход вообще, и делает это **до** +// всякого обращения к Telegram. Намерение объявляет признак включения; ключ +// доступа при выключенном входе не смотрится вовсе. +// +// Сборка клиента приходит параметром, и это не украшение. Главное утверждение +// выключенного входа — «обращения не уходит ни одного», — иначе непроверяемо: +// адрес Bot API живёт внутри `telegram.NewBot`, и проверка, судящая по исходу, +// осталась бы зелёной и тогда, когда ветка выключенного входа встала **после** +// обращения. Прогон с заполненным ключом ушёл бы в живой Telegram боевым +// токеном, а заметить это было бы нечем. +// +// Шов — подпись, а не интерфейс: реализация у него одна, и заводить тип ради +// неё значит заводить понятие там, где хватает функции. +func telegramFromConfig( + cfg config.TelegramConfig, + newBot func(string, *slog.Logger) (*tgbotapi.BotAPI, error), + logger *slog.Logger, +) (*tgbotapi.BotAPI, contract.TelegramMessageSender, error) { + if !cfg.Enabled { + metrics.IntakeUpGauge.WithLabelValues("telegram").Set(0) + // Уровень «к сведению», а не «может стать проблемой»: это выбор + // владельца, а не отклонение. Предупреждение остаётся за тем, чего + // владелец не выбирал, — недоступностью Telegram. + logger.Info("Telegram bot is not started", "reason", "telegram intake is disabled in configuration") + return nil, telegram.NewAbsentMessageSender(), nil + } + + bot, err := newBot(cfg.BotToken, logger) return telegramFromBot(bot, err, logger) } @@ -25,17 +56,24 @@ func buildTelegram(botToken string, logger *slog.Logger) (*tgbotapi.BotAPI, cont // разрез проверять надо, иначе его молча вернут к прежнему виду. // // Разрез проходит по тому, **ответил ли Telegram**, и это решение владельца -// от 2026-08-13: недоступность Telegram на старт сервиса не влияет. +// от 2026-08-13: недоступность Telegram на старт сервиса не влияет. Сюда +// доходит только включённый вход: выключенный отсеян выше, до обращения. // -// - токен не задан — законный отказ от входа: сервис работает оставшимся; -// - Telegram ответил отказом — ошибка настройки: бота по этому токену не -// существует, ждать нечего, и старт роняется. Молча потерянный бот -// перестаёт отвечать отправителям, а узнать об этом было бы неоткуда; +// - Telegram ответил отказом либо ключ доступа пуст — ошибка настройки: бота +// по такому ключу не существует, ждать нечего, и старт роняется. Молча +// потерянный бот перестаёт отвечать отправителям, а узнать об этом было бы +// неоткуда; // - до Telegram не дошли — недоступность: сеть, DNS, авария Bot API, // истёкший срок ожидания. Сервис поднимается без Telegram, потому что // основной вход у него другой, и класть его из-за чужой аварии нельзя. // -// Ядро во всех трёх случаях получает непустого отправителя: необязательная +// Ветка пустого ключа по построению недостижима — его ловит проверка настроек +// раньше, — но исход у неё **тот же**, что у проверки, и потому она оставлена. +// Убрав её, мы отправили бы пустой ключ в общий случай `err != nil`, то есть в +// «недоступность»: обход проверки настроек дал бы тихий подъём без бота — ровно +// то, против чего написано это изменение. +// +// Ядро в обоих мягких случаях получает непустого отправителя: необязательная // зависимость, доехавшая до него нулём, уронила бы первую же задачу из // Telegram. func telegramFromBot( @@ -46,12 +84,7 @@ func telegramFromBot( 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): + case errors.Is(err, telegram.ErrEmptyToken), errors.As(err, &apiErr): // Отказ токена не несёт: его чистит единая точка `telegram.NewBot`. return nil, nil, err diff --git a/telegram_build_test.go b/telegram_build_test.go index 18aa025..9f3e867 100644 --- a/telegram_build_test.go +++ b/telegram_build_test.go @@ -11,9 +11,12 @@ import ( "github.com/stretchr/testify/require" tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" + "github.com/prometheus/client_golang/prometheus/testutil" "git.vakhrushev.me/av/transcriber/internal/adapter/telegram" + "git.vakhrushev.me/av/transcriber/internal/config" "git.vakhrushev.me/av/transcriber/internal/contract" + "git.vakhrushev.me/av/transcriber/internal/metrics" ) // Разрез сборки — то, ради чего написано изменение, — до этих проверок не @@ -27,24 +30,86 @@ func journalLogger() (*slog.Logger, *bytes.Buffer) { return slog.New(slog.NewTextHandler(journal, &slog.HandlerOptions{Level: slog.LevelDebug})), journal } -// Пустой токен — законный отказ от входа: старт продолжается, ядро получает -// заглушку, а владелец узнаёт об этом одной записью. -func TestTelegramFromBotOnEmptyTokenGivesAbsentSender(t *testing.T) { +// Пустой ключ доступа при включённом входе — ошибка настройки, а не режим. +// Прежде он давал мягкий подъём без бота, и это был тот самый второй смысл, +// который изменение разводит с первым: «вход выключен» объявляет признак. +// +// Ветка по построению недостижима — пустой ключ ловит проверка настроек, — но +// исход у неё тот же, и потому она проверяется: убрав её, мы отправили бы +// пустой ключ в общий случай, то есть в «недоступность», и обход проверки дал +// бы тихий подъём без бота. +func TestTelegramFromBotOnEmptyTokenReturnsError(t *testing.T) { logger, journal := journalLogger() bot, sender, err := telegramFromBot(nil, telegram.ErrEmptyToken, logger) - require.NoError(t, err, "пустой токен старт не роняет") + require.ErrorIs(t, err, telegram.ErrEmptyToken, "отказ поднят вызывающему нетронутым") + assert.Nil(t, bot) + assert.Nil(t, sender, "заглушка тут не подставляется: это ошибка, а не режим") + + assert.NotContains(t, journal.String(), "Telegram bot is not started", + "о законном отсутствии входа речи нет: вход заявлен и не обеспечен ключом") +} + +// Выключенный вход — решение владельца: сервис встаёт одним входом, ядро +// получает заглушку, а владелец узнаёт об этом одной записью «к сведению». +func TestTelegramFromConfigDisabledKeepsStarting(t *testing.T) { + logger, journal := journalLogger() + cfg := config.TelegramConfig{Enabled: false, BotToken: ""} + + bot, sender, err := telegramFromConfig(cfg, failingBuild(t), 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.Contains(t, written, "Telegram bot is not started", "о неподнятом входе сказано") + assert.Contains(t, written, "disabled in configuration", "причина названа") + assert.Contains(t, written, "level=INFO", + "уровень «к сведению»: это выбор владельца, а не отклонение") assert.Equal(t, 1, strings.Count(written, "Telegram bot is not started"), "запись ровно одна: вторая была бы записью о том же факте") + + // Ряд метрики заводится первым обращением к нему. Пропади эта строка из + // ветки — на `/metrics` не появится ряда вовсе, и правило наблюдения вида + // «равен нулю» не сработает на отсутствующем ряде: потерянный вход снова + // станет невидимым. + assert.Zero(t, testutil.ToFloat64(metrics.IntakeUpGauge.WithLabelValues("telegram")), + "признак поднятости входа выставлен в ноль") +} + +// Главное утверждение выключенного входа судится **счётчиком обращений**, а не +// исходом. «Бот не заведён, отказа нет» остаётся верным и тогда, когда ветка +// встала после обращения, а обращение ушло в живой Telegram боевым токеном. +func TestTelegramFromConfigDisabledNeverBuildsClient(t *testing.T) { + logger, _ := journalLogger() + // Ключ заполнен — то самое состояние, ради которого два значения и + // разводятся. Значение заведомо ненастоящее. + cfg := config.TelegramConfig{Enabled: false, BotToken: "123456:AA-fake"} + + calls := 0 + build := func(string, *slog.Logger) (*tgbotapi.BotAPI, error) { + calls++ + return nil, nil + } + + _, _, err := telegramFromConfig(cfg, build, logger) + + require.NoError(t, err) + assert.Zero(t, calls, "к Telegram не уходит ни одного обращения") +} + +// failingBuild роняет проверку, если сборку клиента всё-таки позвали. +func failingBuild(t *testing.T) func(string, *slog.Logger) (*tgbotapi.BotAPI, error) { + t.Helper() + + return func(string, *slog.Logger) (*tgbotapi.BotAPI, error) { + t.Fatal("сборка клиента позвана при выключенном входе") + return nil, nil + } } // Telegram ответил, что такого бота нет, — ошибка настройки, а не режим: бот по @@ -99,3 +164,45 @@ func TestTelegramFromBotOnLiveBotGivesRealSender(t *testing.T) { assert.NotContains(t, journal.String(), "Telegram bot is not started") } + +// Включённый вход — основной путь нового кода, и связка «собрать клиента → +// разобрать исход» покрывается только целиком: обе её половины по отдельности +// проверены, а переданное не то поле или потерянный результат видны лишь здесь. +func TestTelegramFromConfigEnabledPassesTokenAndOutcome(t *testing.T) { + logger, _ := journalLogger() + cfg := config.TelegramConfig{Enabled: true, BotToken: "123456:AA-fake"} + live := &tgbotapi.BotAPI{} + + var seen string + build := func(token string, _ *slog.Logger) (*tgbotapi.BotAPI, error) { + seen = token + return live, nil + } + + bot, sender, err := telegramFromConfig(cfg, build, logger) + + require.NoError(t, err) + assert.Equal(t, cfg.BotToken, seen, "в сборку уходит ключ доступа из настроек") + assert.Same(t, live, bot, "собранный клиент доезжает до транспорта") + require.NotNil(t, sender) + _, stub := sender.(*telegram.AbsentMessageSender) + assert.False(t, stub, "это настоящий отправитель, а не заглушка") +} + +// Обратная сторона той же связки: отказ сборки доезжает до разбора исхода, а не +// теряется по дороге. +func TestTelegramFromConfigEnabledCarriesBuildFailure(t *testing.T) { + logger, _ := journalLogger() + cfg := config.TelegramConfig{Enabled: true, BotToken: "123456:AA-fake"} + rejected := &tgbotapi.Error{Code: 401, Message: "Unauthorized"} + + build := func(string, *slog.Logger) (*tgbotapi.BotAPI, error) { + return nil, rejected + } + + bot, sender, err := telegramFromConfig(cfg, build, logger) + + require.ErrorIs(t, err, rejected, "отказ сборки доехал до вызывающего") + assert.Nil(t, bot) + assert.Nil(t, sender, "это ошибка настройки, а не режим") +}