Отмена доходит до внешнего собеседника, а токен не покидает единой точки
- контекст проложен от воркера и обоих входов до внешних вызовов: 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, уводя диагностику
This commit is contained in:
@@ -0,0 +1,101 @@
|
||||
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))
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package telegram
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// Токен виден в пути каждого обращения к Bot API, а `http.Client` кладёт путь в
|
||||
// `*url.Error` целиком. Проверки ниже судят по тексту: секрет не должен
|
||||
// встречаться ни в отказе, ни в строке журнала. Утечка необратима — утёкший
|
||||
// токен отзывают руками (CLAUDE.md, «Инварианты», critical).
|
||||
const probeToken = "7654321:AAHsecretBOTtokenVALUE"
|
||||
|
||||
// getMeResponse — ответ, которым подставной Telegram пускает конструктор
|
||||
// дальше: `NewBotAPIWithClient` ходит за `getMe` прежде, чем отдать клиента.
|
||||
const getMeResponse = `{"ok":true,"result":{"id":1,"is_bot":true,"first_name":"probe","username":"probe_bot"}}`
|
||||
|
||||
func newProbeBot(t *testing.T, handler http.HandlerFunc) (*tgbotapi.BotAPI, *httptest.Server) {
|
||||
t.Helper()
|
||||
|
||||
server := httptest.NewServer(handler)
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
bot, err := newBot(probeToken, server.URL+"/bot%s/%s", slog.New(slog.DiscardHandler))
|
||||
require.NoError(t, err)
|
||||
|
||||
return bot, server
|
||||
}
|
||||
|
||||
// Отказ транспорта на любом вызове Bot API не несёт токена: чистка стоит на
|
||||
// границе клиента, а не у места употребления, поэтому закрыты все вызовы разом.
|
||||
func TestBotAPIFailureDoesNotCarryToken(t *testing.T) {
|
||||
bot, server := newProbeBot(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||
if _, err := w.Write([]byte(getMeResponse)); err != nil {
|
||||
t.Errorf("подставной Telegram не смог ответить: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
// Собеседник исчез — так выглядит обрыв сети, DNS-сбой и недоступность
|
||||
// api.telegram.org.
|
||||
server.Close()
|
||||
|
||||
t.Run("getFile", func(t *testing.T) {
|
||||
_, err := bot.GetFile(tgbotapi.FileConfig{FileID: "any"})
|
||||
require.Error(t, err)
|
||||
assert.NotContains(t, err.Error(), probeToken, "токен уехал в отказ: %v", err)
|
||||
})
|
||||
|
||||
t.Run("sendMessage", func(t *testing.T) {
|
||||
_, err := bot.Send(tgbotapi.NewMessage(1, "текст"))
|
||||
require.Error(t, err)
|
||||
assert.NotContains(t, err.Error(), probeToken, "токен уехал в отказ: %v", err)
|
||||
})
|
||||
}
|
||||
|
||||
// Отказ конструктора несёт тот же путь: `NewBotAPIWithClient` ходит за `getMe`,
|
||||
// и контейнер, стартующий раньше сети, печатал бы токен в первую же секунду.
|
||||
func TestBotConstructionFailureDoesNotCarryToken(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
|
||||
server.Close()
|
||||
|
||||
_, err := newBot(probeToken, server.URL+"/bot%s/%s", slog.New(slog.DiscardHandler))
|
||||
|
||||
require.Error(t, err)
|
||||
assert.NotContains(t, err.Error(), probeToken, "токен уехал в отказ конструктора: %v", err)
|
||||
}
|
||||
|
||||
// Длинный опрос печатает свои отказы пакетным логгером самой библиотеки, минуя
|
||||
// наш `slog`. Логгер подменён — значит, и эта строка идёт через вычистку.
|
||||
func TestLibraryLoggerRedactsToken(t *testing.T) {
|
||||
journal := &bytes.Buffer{}
|
||||
logger := slog.New(slog.NewTextHandler(journal, nil))
|
||||
|
||||
redacting := &redactingLogger{token: probeToken, logger: logger}
|
||||
redacting.Println(errors.New(`Post "https://api.telegram.org/bot` + probeToken + `/getUpdates": dial tcp: refused`))
|
||||
redacting.Printf("Failed to get updates from %s", "https://api.telegram.org/bot"+probeToken+"/getUpdates")
|
||||
|
||||
written := journal.String()
|
||||
assert.NotContains(t, written, probeToken, "токен уехал в журнал: %s", written)
|
||||
assert.Equal(t, 2, strings.Count(written, redactedToken), "вместо токена стоит пометка")
|
||||
assert.Contains(t, written, "dial tcp", "причина отказа осталась")
|
||||
}
|
||||
|
||||
// Пустой токен — законный исход подъёма без Telegram, и узнаётся он по смыслу.
|
||||
func TestEmptyTokenIsRecognizedByValue(t *testing.T) {
|
||||
_, err := NewBot("", slog.New(slog.DiscardHandler))
|
||||
|
||||
require.ErrorIs(t, err, ErrEmptyToken)
|
||||
}
|
||||
|
||||
// WithoutURL снимает адрес, но не причину: `errors.Is` по цепочке продолжает
|
||||
// работать, иначе чистка стоила бы узнаваемости отказа.
|
||||
func TestWithoutURLKeepsCause(t *testing.T) {
|
||||
cause := errors.New("dial tcp: connection refused")
|
||||
wrapped := &url.Error{Op: "Post", URL: "https://api.telegram.org/bot" + probeToken + "/getMe", Err: cause}
|
||||
|
||||
cleaned := WithoutURL(wrapped)
|
||||
|
||||
assert.NotContains(t, cleaned.Error(), probeToken)
|
||||
require.ErrorIs(t, cleaned, cause)
|
||||
assert.Equal(t, cause, WithoutURL(cause), "отказ без адреса не трогают")
|
||||
}
|
||||
|
||||
// Стык, которого не сторожил никто: подмена пакетного логгера держится одной
|
||||
// строкой в `NewBot`, а снятие этой строки не роняло ни одной проверки. Оракул
|
||||
// косвенный по необходимости — библиотека не отдаёт установленный логгер
|
||||
// обратно, — поэтому он смотрит на исход: её собственная строка обязана
|
||||
// оказаться в нашем журнале.
|
||||
func TestLibraryLoggerIsActuallyInstalled(t *testing.T) {
|
||||
journal := &bytes.Buffer{}
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
if _, err := w.Write([]byte(getMeResponse)); err != nil {
|
||||
t.Errorf("подставной Telegram не смог ответить: %v", err)
|
||||
}
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
bot, err := newBot(probeToken, server.URL+"/bot%s/%s", slog.New(slog.NewTextHandler(journal, nil)))
|
||||
require.NoError(t, err)
|
||||
|
||||
// Отладку библиотека печатает тем же логгером, что и отказы: включаем её,
|
||||
// чтобы строка появилась без обрыва сети.
|
||||
bot.Debug = true
|
||||
_, err = bot.GetMe()
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Contains(t, journal.String(), "Telegram library log",
|
||||
"строка библиотеки прошла мимо нашего журнала: логгер не подменён")
|
||||
}
|
||||
@@ -16,7 +16,9 @@ type TelegramMessageSender struct {
|
||||
}
|
||||
|
||||
func NewTelegramMessageSender(botToken string, logger *slog.Logger) (*TelegramMessageSender, error) {
|
||||
bot, err := tgbotapi.NewBotAPI(botToken)
|
||||
// Клиент заводится единой точкой: её отказ не несёт токена, а отказ
|
||||
// конструктора несёт — `NewBotAPI` зовёт `getMe`.
|
||||
bot, err := NewBot(botToken, logger)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user