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/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)) }