config: включение Telegram разведено с ключом доступа
- в секции [telegram] заведён обязательный ключ enabled: умолчания у него нет, файл без него негоден; bot_token стал только ключом доступа и при enabled = false не читается вовсе, а пустой при enabled = true роняет старт - выключенный вход даёт подъём одним входом без единого обращения к Telegram и записью INFO вместо прежнего WARN: это выбор владельца, а не отклонение - отказ разбора файла настроек больше не пересказывает toml — её ParseError несёт в тексте разбираемое значение, и оборванная строка секретного ключа уносила его в журнал; теперь называются путь, строка, столбец и последний ключ
This commit is contained in:
@@ -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`.
|
||||
|
||||
+28
-15
@@ -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 бота (в секундах)
|
||||
|
||||
@@ -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;
|
||||
- `−` отказ разбора файла настроек стал беднее на текст библиотеки: место и ключ
|
||||
названы, а что именно в строке не так — нет. Плата принята ради инварианта,
|
||||
помеченного необратимым.
|
||||
@@ -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).
|
||||
|
||||
@@ -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) | |
|
||||
|
||||
+22
-8
@@ -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 не прочитает объект и вернёт отказ операции |
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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) | Размер собранной статики, цена шага сборки, что у трёх кандидатов одинаково |
|
||||
|
||||
@@ -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`; при
|
||||
подъёме версии библиотеки их отказ читается как сигнал перечитать эту записку, а
|
||||
не как случайный шум.
|
||||
+47
-4
@@ -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`, шаг конвертации — дефект завела та
|
||||
|
||||
@@ -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`, проверки поломанного файла настроек.
|
||||
Остаточный риск назван там же: разрез опирается на то, какое семейство отказов
|
||||
несёт значения **в нынешней версии** библиотеки.
|
||||
|
||||
## Что вне модели
|
||||
|
||||
Перечислить явно.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -4,9 +4,10 @@ import (
|
||||
"git.vakhrushev.me/av/transcriber/internal/contract"
|
||||
)
|
||||
|
||||
// AbsentMessageSender подставляется вместо отправителя Telegram, когда токен
|
||||
// бота не задан и клиента заводить не из чего. Он ничего не отправляет и на
|
||||
// всякий ответ отдаёт `contract.ErrDeliveryChannelDown`.
|
||||
// AbsentMessageSender подставляется вместо отправителя Telegram, когда вход
|
||||
// выключен признаком `telegram.enabled` либо Telegram оказался недоступен, и
|
||||
// клиента заводить не из чего. Он ничего не отправляет и на всякий ответ отдаёт
|
||||
// `contract.ErrDeliveryChannelDown`.
|
||||
//
|
||||
// Заглушка, а не пустой отправитель: необязательная зависимость, доехавшая до
|
||||
// ядра нулём, роняет процесс на первой же задаче из Telegram, а проверка на
|
||||
|
||||
@@ -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 — и это **единая точка**, через которую с
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-08-13
|
||||
@@ -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 — признак
|
||||
обязателен, умолчания у него нет.
|
||||
@@ -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, белый список и доставка ответов не затрагиваются.
|
||||
@@ -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` лежит в чужом репозитории и во вход не
|
||||
входил ни одному проходу.
|
||||
@@ -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` лежит в чужом репозитории и во вход не
|
||||
входил ни одному проходу.
|
||||
@@ -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** ни журнал, ни текст отказа не несут значения ключа
|
||||
@@ -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` —
|
||||
процесс выходит с ненулевым кодом, сообщение называет недостающий ключ
|
||||
+100
-59
@@ -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** ни журнал, ни текст отказа не несут значения ключа
|
||||
|
||||
|
||||
+47
-14
@@ -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
|
||||
|
||||
|
||||
+113
-6
@@ -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, "это ошибка настройки, а не режим")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user