config: включение Telegram разведено с ключом доступа

- в секции [telegram] заведён обязательный ключ enabled: умолчания у него нет,
  файл без него негоден; bot_token стал только ключом доступа и при
  enabled = false не читается вовсе, а пустой при enabled = true роняет старт
- выключенный вход даёт подъём одним входом без единого обращения к Telegram и
  записью INFO вместо прежнего WARN: это выбор владельца, а не отклонение
- отказ разбора файла настроек больше не пересказывает toml — её ParseError
  несёт в тексте разбираемое значение, и оборванная строка секретного ключа
  уносила его в журнал; теперь называются путь, строка, столбец и последний ключ
This commit is contained in:
av
2026-08-13 21:46:14 +03:00
parent 903941f587
commit cd57b68215
27 changed files with 1536 additions and 123 deletions
@@ -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`
процесс выходит с ненулевым кодом, сообщение называет недостающий ключ