Отмена доходит до внешнего собеседника, а токен не покидает единой точки
- контекст проложен от воркера и обоих входов до внешних вызовов: 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:
@@ -1,6 +1,7 @@
|
||||
package ffmpeg
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
@@ -15,7 +16,7 @@ func NewFfmpegConverter() *FfmpegConverter {
|
||||
return &FfmpegConverter{}
|
||||
}
|
||||
|
||||
func (c *FfmpegConverter) Convert(src, dest string) error {
|
||||
func (c *FfmpegConverter) Convert(ctx context.Context, src, dest string) error {
|
||||
// Проверяем существование исходного файла
|
||||
if _, err := os.Stat(src); os.IsNotExist(err) {
|
||||
return fmt.Errorf("input file does not exist: %s", src)
|
||||
@@ -26,8 +27,9 @@ func (c *FfmpegConverter) Convert(src, dest string) error {
|
||||
return fmt.Errorf("ffmpeg not found in PATH: %w", err)
|
||||
}
|
||||
|
||||
// Создаем команду ffmpeg для конвертации в OGG
|
||||
cmd := exec.Command(ffmpegExecutable,
|
||||
// Команда заводится с контекстом: отменённый контекст убивает процесс, а не
|
||||
// оставляет его дожёвывать чужую запись после остановки воркера.
|
||||
cmd := exec.CommandContext(ctx, ffmpegExecutable,
|
||||
"-i", src, // входной файл
|
||||
"-c:a", "libvorbis", // кодек Vorbis для OGG
|
||||
"-q:a", "4", // качество аудио (0-10, где 4 - хорошее качество)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package ffmpeg
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
@@ -26,7 +27,7 @@ func NewFfmpegMetaViewer() *FfmpegMetaViewer {
|
||||
return &FfmpegMetaViewer{}
|
||||
}
|
||||
|
||||
func (m *FfmpegMetaViewer) GetInfo(src string) (*contract.AudioInfo, error) {
|
||||
func (m *FfmpegMetaViewer) GetInfo(ctx context.Context, src string) (*contract.AudioInfo, error) {
|
||||
// Проверяем существование исходного файла
|
||||
if _, err := os.Stat(src); os.IsNotExist(err) {
|
||||
return nil, fmt.Errorf("input file does not exist: %s", src)
|
||||
@@ -37,8 +38,9 @@ func (m *FfmpegMetaViewer) GetInfo(src string) (*contract.AudioInfo, error) {
|
||||
return nil, fmt.Errorf("ffprobe not found in PATH: %w", err)
|
||||
}
|
||||
|
||||
// Создаем команду ffprobe для получения метаданных
|
||||
cmd := exec.Command(ffprobeExecutable,
|
||||
// Команда заводится с контекстом: отправитель, закрывший соединение, не
|
||||
// оставляет за собой чтение метаданных чужого файла.
|
||||
cmd := exec.CommandContext(ctx, ffprobeExecutable,
|
||||
"-v", "quiet", // тихий режим (без лишнего вывода)
|
||||
"-print_format", "json", // вывод в формате JSON
|
||||
"-show_format", // показать информацию о формате
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package recognizer
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
|
||||
"git.vakhrushev.me/av/transcriber/internal/entity"
|
||||
@@ -9,14 +10,14 @@ import (
|
||||
|
||||
type MemoryAudioRecognizer struct{}
|
||||
|
||||
func (r *MemoryAudioRecognizer) Recognize(file io.Reader, fileName string) (operationID string, err error) {
|
||||
func (r *MemoryAudioRecognizer) Recognize(ctx context.Context, file io.Reader, fileName string) (operationID string, err error) {
|
||||
return uuid.NewString(), nil
|
||||
}
|
||||
|
||||
func (r *MemoryAudioRecognizer) GetRecognitionText(operationID string) (string, error) {
|
||||
func (r *MemoryAudioRecognizer) GetRecognitionText(ctx context.Context, operationID string) (string, error) {
|
||||
return "Foo bar, Baz.", nil
|
||||
}
|
||||
|
||||
func (r *MemoryAudioRecognizer) CheckRecognitionStatus(operationID string) (*entity.RecognitionResult, error) {
|
||||
func (r *MemoryAudioRecognizer) CheckRecognitionStatus(ctx context.Context, operationID string) (*entity.RecognitionResult, error) {
|
||||
return entity.NewCompletedResult(), nil
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
package yandex
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"time"
|
||||
|
||||
"git.vakhrushev.me/av/transcriber/internal/entity"
|
||||
)
|
||||
@@ -54,16 +56,31 @@ func (s *YandexAudioRecognizerService) Close() error {
|
||||
return s.sttService.Close()
|
||||
}
|
||||
|
||||
func (s *YandexAudioRecognizerService) Recognize(file io.Reader, fileName string) (string, error) {
|
||||
// startRecognitionTimeout — сколько ждём принятия операции, когда нас уже
|
||||
// остановили. Число меньше жёсткого предела остановки: иначе процесс убьют
|
||||
// прежде, чем ответ дойдёт, и защита ничего не даст.
|
||||
const startRecognitionTimeout = 10 * time.Second
|
||||
|
||||
err := s.s3Sevice.uploadFile(file, fileName)
|
||||
func (s *YandexAudioRecognizerService) Recognize(ctx context.Context, file io.Reader, fileName string) (string, error) {
|
||||
|
||||
// Заливка отменяется штатно: она дорога по времени, а повтор её бесплатен —
|
||||
// объект ложится под тем же ключом.
|
||||
err := s.s3Sevice.uploadFile(ctx, file, fileName)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
uri := s.s3Sevice.fileUrl(fileName)
|
||||
|
||||
opId, err := s.sttService.recognizeFileFromS3(uri)
|
||||
// А вот принятие операции от отмены защищено. Окно короткое и дорогое:
|
||||
// SpeechKit может операцию принять и начать считать деньги, а ответ до нас
|
||||
// не доедет — идентификатор потеряется навсегда, и повтор оплатит ту же
|
||||
// запись второй раз. Свой предел вызову оставлен, чтобы остановка не ждала
|
||||
// вечно.
|
||||
startCtx, cancel := protectFromCancel(ctx, startRecognitionTimeout)
|
||||
defer cancel()
|
||||
|
||||
opId, err := s.sttService.recognizeFileFromS3(startCtx, uri)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -71,12 +88,19 @@ func (s *YandexAudioRecognizerService) Recognize(file io.Reader, fileName string
|
||||
return opId, nil
|
||||
}
|
||||
|
||||
func (s *YandexAudioRecognizerService) GetRecognitionText(operationID string) (string, error) {
|
||||
return s.sttService.getRecognitionText(operationID)
|
||||
// protectFromCancel отвязывает вызов от отмены родителя, оставляя ему значения
|
||||
// родителя и собственный предел по времени. Употребляется там, где обрыв стоит
|
||||
// дороже ожидания: у платной операции, чей результат нельзя переспросить.
|
||||
func protectFromCancel(ctx context.Context, timeout time.Duration) (context.Context, context.CancelFunc) {
|
||||
return context.WithTimeout(context.WithoutCancel(ctx), timeout)
|
||||
}
|
||||
|
||||
func (s *YandexAudioRecognizerService) CheckRecognitionStatus(operationID string) (*entity.RecognitionResult, error) {
|
||||
operation, err := s.sttService.checkOperationStatus(operationID)
|
||||
func (s *YandexAudioRecognizerService) GetRecognitionText(ctx context.Context, operationID string) (string, error) {
|
||||
return s.sttService.getRecognitionText(ctx, operationID)
|
||||
}
|
||||
|
||||
func (s *YandexAudioRecognizerService) CheckRecognitionStatus(ctx context.Context, operationID string) (*entity.RecognitionResult, error) {
|
||||
operation, err := s.sttService.checkOperationStatus(ctx, operationID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
package yandex
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// Принятие операции распознавания защищено от отмены: остановка сервиса не
|
||||
// должна обрывать вызов, который уже мог начать стоить денег и чей результат
|
||||
// нельзя переспросить. Проверяется само средство защиты — проводка к нему
|
||||
// оракула не имеет: клиент SpeechKit подставить нечем, а прогон на реальных
|
||||
// ключах запрещён (CLAUDE.md, «Запреты»).
|
||||
func TestProtectedContextSurvivesParentCancel(t *testing.T) {
|
||||
parent, cancel := context.WithCancel(t.Context())
|
||||
|
||||
protected, release := protectFromCancel(parent, time.Minute)
|
||||
defer release()
|
||||
|
||||
cancel()
|
||||
|
||||
require.Error(t, parent.Err(), "родитель отменён — иначе проверка судит не то")
|
||||
assert.NoError(t, protected.Err(), "защищённый вызов пережил отмену родителя")
|
||||
}
|
||||
|
||||
// Защита не бессрочна: у вызова свой предел, иначе остановка ждала бы вечно.
|
||||
func TestProtectedContextKeepsItsOwnDeadline(t *testing.T) {
|
||||
protected, release := protectFromCancel(t.Context(), time.Minute)
|
||||
defer release()
|
||||
|
||||
deadline, ok := protected.Deadline()
|
||||
|
||||
require.True(t, ok, "у защищённого вызова обязан быть свой предел")
|
||||
assert.WithinDuration(t, time.Now().Add(time.Minute), deadline, 5*time.Second)
|
||||
}
|
||||
@@ -67,8 +67,8 @@ func newYandexS3Service(cfg s3Config) (*yandexS3Service, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *yandexS3Service) uploadFile(file io.Reader, fileName string) error {
|
||||
_, err := s.uploader.Upload(context.Background(), &s3.PutObjectInput{
|
||||
func (s *yandexS3Service) uploadFile(ctx context.Context, file io.Reader, fileName string) error {
|
||||
_, err := s.uploader.Upload(ctx, &s3.PutObjectInput{
|
||||
Bucket: aws.String(s.bucketName),
|
||||
Key: aws.String(fileName),
|
||||
Body: file,
|
||||
|
||||
@@ -94,9 +94,7 @@ func (s *speechKitService) Close() error {
|
||||
}
|
||||
|
||||
// recognizeFileFromS3 запускает асинхронное распознавание файла из S3
|
||||
func (s *speechKitService) recognizeFileFromS3(s3URI string) (string, error) {
|
||||
ctx := context.Background()
|
||||
|
||||
func (s *speechKitService) recognizeFileFromS3(ctx context.Context, s3URI string) (string, error) {
|
||||
// Добавляем авторизацию и folder_id в контекст
|
||||
ctx = metadata.AppendToOutgoingContext(ctx, "authorization", "Api-Key "+s.apiKey)
|
||||
ctx = metadata.AppendToOutgoingContext(ctx, "x-folder-id", s.folderID)
|
||||
@@ -137,9 +135,7 @@ func (s *speechKitService) recognizeFileFromS3(s3URI string) (string, error) {
|
||||
}
|
||||
|
||||
// GetRecognitionResult получает результат распознавания по ID операции
|
||||
func (s *speechKitService) getRecognitionText(operationID string) (string, error) {
|
||||
ctx := context.Background()
|
||||
|
||||
func (s *speechKitService) getRecognitionText(ctx context.Context, operationID string) (string, error) {
|
||||
// Добавляем авторизацию и folder_id в контекст
|
||||
ctx = metadata.AppendToOutgoingContext(ctx, "authorization", "Api-Key "+s.apiKey)
|
||||
ctx = metadata.AppendToOutgoingContext(ctx, "x-folder-id", s.folderID)
|
||||
@@ -180,9 +176,7 @@ func (s *speechKitService) getRecognitionText(operationID string) (string, error
|
||||
}
|
||||
|
||||
// checkOperationStatus проверяет статус операции распознавания
|
||||
func (s *speechKitService) checkOperationStatus(operationID string) (*operation.Operation, error) {
|
||||
ctx := context.Background()
|
||||
|
||||
func (s *speechKitService) checkOperationStatus(ctx context.Context, operationID string) (*operation.Operation, error) {
|
||||
ctx = metadata.AppendToOutgoingContext(ctx, "authorization", "Api-Key "+s.apiKey)
|
||||
ctx = metadata.AppendToOutgoingContext(ctx, "x-folder-id", s.folderID)
|
||||
|
||||
|
||||
@@ -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