- в секции [telegram] заведён обязательный ключ enabled: умолчания у него нет, файл без него негоден; bot_token стал только ключом доступа и при enabled = false не читается вовсе, а пустой при enabled = true роняет старт - выключенный вход даёт подъём одним входом без единого обращения к Telegram и записью INFO вместо прежнего WARN: это выбор владельца, а не отклонение - отказ разбора файла настроек больше не пересказывает toml — её ParseError несёт в тексте разбираемое значение, и оборванная строка секретного ключа уносила его в журнал; теперь называются путь, строка, столбец и последний ключ
135 lines
7.7 KiB
Go
135 lines
7.7 KiB
Go
package telegram
|
||
|
||
import (
|
||
"errors"
|
||
"fmt"
|
||
"log/slog"
|
||
"net/http"
|
||
"net/url"
|
||
"strings"
|
||
"time"
|
||
|
||
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
|
||
)
|
||
|
||
// ErrEmptyToken — ключ доступа пуст при включённом входе, то есть **ошибка
|
||
// настройки**: старт роняется. Отдельным значением, чтобы отличаться от
|
||
// недоступности Telegram, у которой исход обратный — подъём без бота.
|
||
//
|
||
// Отказ от входа Telegram этим значением больше не выражается: намерение
|
||
// объявляет признак включения `telegram.enabled`, и выключенный вход отсеивается
|
||
// до всякого обращения сюда. Пустой ключ ловит проверка настроек ещё раньше,
|
||
// поэтому сюда он доходит только в обход проверки.
|
||
var ErrEmptyToken = errors.New("telegram bot token is empty")
|
||
|
||
// NewBot заводит клиента Bot API — и это **единая точка**, через которую с
|
||
// библиотекой разговаривают оба пакета: адаптер отправки и транспорт бота.
|
||
//
|
||
// Точка нужна ради инварианта «секрет не покидает конфиг». Токен живёт в пути
|
||
// каждого обращения к Bot API (`https://api.telegram.org/bot<TOKEN>/getFile`),
|
||
// а `http.Client` кладёт адрес запроса в `*url.Error` целиком. Библиотека
|
||
// отдаёт этот отказ вызывающему как есть, поэтому чистка на месте употребления
|
||
// закрывает ровно один вызов из пяти: остаются `getFile`, `sendMessage`,
|
||
// `getMe` из конструктора и длинный опрос. Здесь закрыты все.
|
||
func NewBot(token string, logger *slog.Logger) (*tgbotapi.BotAPI, error) {
|
||
return newBot(token, tgbotapi.APIEndpoint, logger)
|
||
}
|
||
|
||
// newBot принимает адрес отдельно — иначе проверка утечки токена ходила бы за
|
||
// подтверждением в живой Telegram, а боевым токеном запускаться запрещено.
|
||
func newBot(token, endpoint string, logger *slog.Logger) (*tgbotapi.BotAPI, error) {
|
||
if token == "" {
|
||
return nil, ErrEmptyToken
|
||
}
|
||
|
||
// Длинный опрос живёт внутри библиотеки и печатает свой отказ пакетным
|
||
// логгером в stderr (`GetUpdatesChan`), минуя и наш `slog`, и чистку выше.
|
||
// Это самый частый путь: опрос идёт непрерывно, а скачивание — только когда
|
||
// кто-то прислал запись. Логгер пакетный, поэтому и подменяется один раз.
|
||
if err := tgbotapi.SetLogger(&redactingLogger{token: token, logger: logger}); err != nil {
|
||
return nil, fmt.Errorf("failed to set telegram logger: %w", err)
|
||
}
|
||
|
||
// Сборка ходит за `getMe` и стоит на пути старта — раньше HTTP-сервера,
|
||
// панели и воркеров. Без срока ожидания молчащий Telegram (соединение
|
||
// принято, ответа нет) вешал бы весь подъём бессрочно: порт не слушается,
|
||
// проба здоровья не отвечает, а в журнале ни строки.
|
||
probe := &safeClient{inner: &http.Client{Timeout: ProbeTimeout}}
|
||
|
||
// Отказ конструктора чистится здесь, а не клиентом: адрес собирается
|
||
// строкой с токеном внутри, и `http.NewRequest` падает на его разборе
|
||
// **до** обращения к клиенту — то есть мимо `safeClient`. Токен с
|
||
// управляющим символом или неверной `%`-последовательностью иначе уезжает
|
||
// в журнал целиком: перенос строки в конце значения ловится так же.
|
||
bot, err := tgbotapi.NewBotAPIWithClient(token, endpoint, probe)
|
||
if err != nil {
|
||
return nil, WithoutURL(err)
|
||
}
|
||
|
||
// Дальше живёт длинный опрос, и срок ему не нужен: он ждёт обновлений
|
||
// столько, сколько задано настройкой, и клиент со сроком рвал бы его.
|
||
bot.Client = &safeClient{inner: &http.Client{}}
|
||
|
||
return bot, nil
|
||
}
|
||
|
||
// ProbeTimeout — сколько ждём Telegram при сборке клиента. Число выбрано
|
||
// решением, а не замером: одно обращение за `getMe` укладывается в доли
|
||
// секунды, а десять секунд — потолок, после которого Telegram считается
|
||
// недоступным и сервис поднимается без него.
|
||
const ProbeTimeout = 10 * time.Second
|
||
|
||
// safeClient — клиент, чей отказ не несёт адреса. Библиотека объявляет
|
||
// зависимость интерфейсом `HTTPClient` и возвращает наш отказ вызывающему
|
||
// нетронутым, поэтому чистка отсюда доходит до каждого вызова Bot API.
|
||
type safeClient struct {
|
||
inner *http.Client
|
||
}
|
||
|
||
func (c *safeClient) Do(req *http.Request) (*http.Response, error) {
|
||
resp, err := c.inner.Do(req)
|
||
if err != nil {
|
||
return nil, WithoutURL(err)
|
||
}
|
||
return resp, nil
|
||
}
|
||
|
||
// WithoutURL снимает с отказа адрес запроса, сохраняя причину. Стандартный
|
||
// клиент кладёт в `*url.Error` полный URL, а в ссылке Telegram стоит токен
|
||
// бота: без этой чистки первый же сбой сети печатает секрет в журнал.
|
||
// Причина остаётся и узнаётся `errors.Is` по-прежнему.
|
||
func WithoutURL(err error) error {
|
||
var urlErr *url.Error
|
||
if errors.As(err, &urlErr) {
|
||
return urlErr.Err
|
||
}
|
||
return err
|
||
}
|
||
|
||
// redactingLogger отдаёт сообщения библиотеки нашему журналу, вычеркнув токен.
|
||
// Здесь чистится текст, а не ошибка: библиотека печатает уже отформатированную
|
||
// строку, и разбирать в ней `*url.Error` нечего. Замена точная — токен известен.
|
||
type redactingLogger struct {
|
||
token string
|
||
logger *slog.Logger
|
||
}
|
||
|
||
const redactedToken = "«токен»"
|
||
|
||
func (l *redactingLogger) Println(v ...any) {
|
||
l.write(strings.TrimSuffix(fmt.Sprintln(v...), "\n"))
|
||
}
|
||
|
||
func (l *redactingLogger) Printf(format string, v ...any) {
|
||
l.write(fmt.Sprintf(format, v...))
|
||
}
|
||
|
||
// write пишет на WARN: это сбой фонового цикла со штатным повтором, а не
|
||
// событие, требующее разбора (docs/conventions/logging.md, «Уровень —
|
||
// это адресат»). Сообщение нейтрально: тем же логгером библиотека печатает и
|
||
// отладку, если её включить, а разделить их она не даёт.
|
||
func (l *redactingLogger) write(message string) {
|
||
l.logger.Warn("Telegram library log",
|
||
"message", strings.ReplaceAll(message, l.token, redactedToken))
|
||
}
|