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
+65 -2
View File
@@ -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)
}