- контекст проложен от воркера и обоих входов до внешних вызовов: ffmpeg и ffprobe заводятся через exec.CommandContext, SpeechKit и Object Storage принимают ctx вместо context.Background, скачивание записи идёт запросом с контекстом. Прежде остановка сервиса не доходила до чужой работы вовсе - прерванный шаг приговора не выносит: убитый по контексту ffmpeg отдаёт «signal: killed», от настоящего отказа неотличимо ни типом, ни errors.Is, и различает их только ctx.Err(). Задача остаётся на повтор, попытку не тратит и отправителю о несуществующем сбое не сообщает; воркер не считает остановку отказом, а задача не забирается вовсе, если нас уже остановили - клиента Bot API заводит единая точка internal/adapter/telegram: токен стоит в пути каждого обращения, а http.Client кладёт адрес в *url.Error целиком. Чистка на месте употребления закрывала один вызов из пяти — теперь свой Do чистит отказ, подменённый логгер вычищает токен из строк самой библиотеки, а транспорт бота токена не получает вовсе - принятие операции распознавания защищено от отмены своим пределом: SpeechKit мог её принять и начать считать деньги, а потерянный идентификатор заставил бы повтор оплатить ту же запись второй раз - приём по HTTP доводит запись до задачи независимо от отправителя: на контексте запроса один обрыв соединения терял полностью загруженную запись - ответ Telegram с не-2xx кодом больше не становится записью: прежде тело отказа доезжало до хранилища и умирало на ffprobe, уводя диагностику
102 lines
5.1 KiB
Go
102 lines
5.1 KiB
Go
package telegram
|
||
|
||
import (
|
||
"errors"
|
||
"fmt"
|
||
"log/slog"
|
||
"net/http"
|
||
"net/url"
|
||
"strings"
|
||
|
||
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
|
||
)
|
||
|
||
// ErrEmptyToken — токен бота не задан. Отдельным значением, потому что подъём
|
||
// без Telegram — законный исход: сервис продолжает работать с HTTP API.
|
||
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)
|
||
}
|
||
|
||
return tgbotapi.NewBotAPIWithClient(token, endpoint, &safeClient{inner: &http.Client{}})
|
||
}
|
||
|
||
// 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))
|
||
}
|