telegram: сервис поднимается без бота и работает одним входом

- Клиент бота собирается один раз и достаётся отправителю и транспорту;
  разрез прошёл по «ответил ли Telegram»: ответ «такого бота нет» роняет
  старт, недоступность даёт подъём без Telegram (ADR-2026-08-13). Ожидание
  при сборке ограничено сроком — иначе молчащий Telegram вешал подъём.
- Недоставленный ответ не роняет шаг: пишется с job_id и считается метрикой,
  уровень по причине — WARN для неподнятого входа, ERROR для неназванного
  адресата. Заведены transcriber_intake_up и transcriber_undelivered_reply_count.
- Закрыта утечка токена в журнал: отказ разбора адреса рождается раньше
  обращения к клиенту, то есть мимо чистки на его границе.
This commit is contained in:
av
2026-08-13 19:10:08 +03:00
parent 863ba3b42e
commit b733a84d6a
33 changed files with 1579 additions and 91 deletions
+26
View File
@@ -0,0 +1,26 @@
package telegram
import (
"git.vakhrushev.me/av/transcriber/internal/contract"
)
// AbsentMessageSender подставляется вместо отправителя Telegram, когда токен
// бота не задан и клиента заводить не из чего. Он ничего не отправляет и на
// всякий ответ отдаёт `contract.ErrDeliveryChannelDown`.
//
// Заглушка, а не пустой отправитель: необязательная зависимость, доехавшая до
// ядра нулём, роняет процесс на первой же задаче из Telegram, а проверка на
// месте употребления завела бы в ядре знание о том, как собран сервис.
//
// Молчит он намеренно. Записать недоставку заглушке нечем: контракт отправки
// несёт текст, чат и сообщение для ответа, а идентификатора задачи в нём нет.
// Пишет поэтому шаг конвейера, который задачу знает.
type AbsentMessageSender struct{}
func NewAbsentMessageSender() *AbsentMessageSender {
return &AbsentMessageSender{}
}
func (s *AbsentMessageSender) Send(_ string, _ int64, _ *int) error {
return contract.ErrDeliveryChannelDown
}
+37
View File
@@ -0,0 +1,37 @@
package telegram
import (
"log/slog"
"net/http"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"git.vakhrushev.me/av/transcriber/internal/contract"
)
// Заглушка отдаёт «канал не поднят» и молчит: записать недоставку ей нечем —
// идентификатора задачи контракт отправки не несёт, и пишет её шаг конвейера.
func TestAbsentSenderReportsChannelDown(t *testing.T) {
sender := NewAbsentMessageSender()
err := sender.Send("расшифровка записи", 100, nil)
require.ErrorIs(t, err, contract.ErrDeliveryChannelDown)
}
// Непустой годный токен по-прежнему даёт настоящего отправителя: прежний путь
// сохранён, и меняется только то, что клиента теперь отдают готовым.
func TestSenderIsBuiltFromLiveBot(t *testing.T) {
bot, _ := newProbeBot(t, func(w http.ResponseWriter, _ *http.Request) {
if _, err := w.Write([]byte(getMeResponse)); err != nil {
t.Errorf("подставной Telegram не смог ответить: %v", err)
}
})
sender := NewTelegramMessageSender(bot, slog.New(slog.DiscardHandler))
require.NotNil(t, sender)
assert.Same(t, bot, sender.bot, "отправитель говорит с тем же клиентом, что и транспорт")
}
+28 -1
View File
@@ -7,6 +7,7 @@ import (
"net/http"
"net/url"
"strings"
"time"
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
)
@@ -43,9 +44,35 @@ func newBot(token, endpoint string, logger *slog.Logger) (*tgbotapi.BotAPI, erro
return nil, fmt.Errorf("failed to set telegram logger: %w", err)
}
return tgbotapi.NewBotAPIWithClient(token, endpoint, &safeClient{inner: &http.Client{}})
// Сборка ходит за `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.
+32
View File
@@ -63,6 +63,27 @@ func TestBotAPIFailureDoesNotCarryToken(t *testing.T) {
})
}
// Токен, ломающий разбор адреса, — второй путь отказа конструктора, и до
// недавнего он был открыт: `http.NewRequest` падает раньше обращения к клиенту,
// то есть мимо чистки на его границе. Так выглядит перенос строки, приехавший
// с секретом из шаблона выкладки, и невычищенная `%`-последовательность.
func TestBotConstructionFailureOnUnparsableTokenDoesNotCarryToken(t *testing.T) {
broken := map[string]string{
"перенос строки": probeToken + "\n",
"негодная escape-пара": "7654321:AAH%zzSECRETtokenVALUE",
}
for name, token := range broken {
t.Run(name, func(t *testing.T) {
_, err := newBot(token, tgbotapi.APIEndpoint, slog.New(slog.DiscardHandler))
require.Error(t, err)
assert.NotContains(t, err.Error(), token, "токен уехал в отказ: %v", err)
assert.NotContains(t, err.Error(), "api.telegram.org", "адрес остался в отказе: %v", err)
})
}
}
// Отказ конструктора несёт тот же путь: `NewBotAPIWithClient` ходит за `getMe`,
// и контейнер, стартующий раньше сети, печатал бы токен в первую же секунду.
func TestBotConstructionFailureDoesNotCarryToken(t *testing.T) {
@@ -92,10 +113,21 @@ func TestLibraryLoggerRedactsToken(t *testing.T) {
}
// Пустой токен — законный исход подъёма без Telegram, и узнаётся он по смыслу.
// Обратное тоже нормируется: отказ негодного токена не должен читаться как
// отказ от входа, иначе сборка при старте подставит заглушку там, где нужен
// отказ, и молча потеряет бота.
func TestEmptyTokenIsRecognizedByValue(t *testing.T) {
_, err := NewBot("", slog.New(slog.DiscardHandler))
require.ErrorIs(t, err, ErrEmptyToken)
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)
require.NotErrorIs(t, err, ErrEmptyToken)
}
// WithoutURL снимает адрес, но не причину: `errors.Is` по цепочке продолжает
+6 -9
View File
@@ -15,18 +15,15 @@ type TelegramMessageSender struct {
logger *slog.Logger
}
func NewTelegramMessageSender(botToken string, logger *slog.Logger) (*TelegramMessageSender, error) {
// Клиент заводится единой точкой: её отказ не несёт токена, а отказ
// конструктора несёт — `NewBotAPI` зовёт `getMe`.
bot, err := NewBot(botToken, logger)
if err != nil {
return nil, err
}
// NewTelegramMessageSender принимает готового клиента, а не токен. Клиента
// заводит сборка при старте — одного на отправителя и на транспорт бота: пока
// его строили здесь и там порознь, два пути одного старта разошлись в том,
// терпеть ли негодный токен, и согласовывать их приходилось руками.
func NewTelegramMessageSender(bot *tgbotapi.BotAPI, logger *slog.Logger) *TelegramMessageSender {
return &TelegramMessageSender{
bot: bot,
logger: logger,
}, nil
}
}
func (s *TelegramMessageSender) Send(text string, chatId int64, replyToMessageId *int) error {