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,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
View File
@@ -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** ни журнал, ни текст отказа не несут значения ключа