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
+53 -4
View File
@@ -551,19 +551,43 @@ func (s *TranscribeService) failJob(job *entity.TranscribeJob, holder string, jo
return s.send(job, errorMessage)
}
// send отвечает отправителю там, откуда пришла запись, и отказ отправки
// поднимает вверх: он принадлежит шагу.
// send отвечает отправителю там, откуда пришла запись. Отказ отправки поднимает
// вверх: он принадлежит шагу.
//
// Кроме недоставки — её шаг записывает и завершается без отказа. Ответ уходит
// после того, как достигнутое состояние сохранено: работа к этой минуте
// сделана, и объявленный отказ засчитался бы воркеру сбоем и лёг бы владельцу
// записью отказа. Повтор делу не помогает — ни бот, ни адресат от ожидания не
// появятся, — поэтому причина недоставки живёт в журнале, а не в состоянии
// задачи.
//
// Служебные поля завершённой задачи отказ бы при этом не переписал: переход в
// терминальное состояние снимает захват, и повторная запись натыкается на
// «захват потерян». Довод держится на счётчике и журнале, а не на этом.
func (s *TranscribeService) send(job *entity.TranscribeJob, text string) error {
if job.Source != entity.SourceTelegram {
return nil
}
// Адресата у задачи нет: отвечать некуда, и повторять нечего. Уровень здесь
// выше, чем у неподнятого канала, и это не педантизм: пустой чат у задачи
// из Telegram — симптом порчи записи, а самый коварный её источник назван
// инвариантом «колонки очереди правятся в четырёх местах». Утони этот
// сигнал в одном ряду со штатным «бот не настроен» — и обнуление колонки
// заметит только отправитель, переставший получать ответы.
if job.TgChatId == nil {
s.logger.Error("Telegram chat not specified", "job_id", job.Id)
return fmt.Errorf("tg chat id not specified, job id: %s", job.Id)
s.undelivered(job, slog.LevelError, "chat is not specified")
return nil
}
if err := s.tgSender.Send(text, *job.TgChatId, job.TgReplyMessageId); err != nil {
// Канал не поднят: сервис работает без этого входа, и это объявленный
// режим, а не поломка.
if errors.Is(err, contract.ErrDeliveryChannelDown) {
s.undelivered(job, slog.LevelWarn, "delivery channel is down")
return nil
}
s.logger.Error("Failed to sent message to client", "job_id", job.Id)
return fmt.Errorf("failed to sent message to client, job id: %s, err: %w", job.Id, err)
}
@@ -571,6 +595,31 @@ func (s *TranscribeService) send(job *entity.TranscribeJob, text string) error {
return nil
}
// undelivered записывает недоставленный ответ и считает его в метрику. Уровень
// приходит от причины: объявленный режим — «может стать проблемой», порча
// записи — событие для разбора.
//
// Идентификатор задачи обязателен, иначе владелец видит, что ответ не ушёл, но
// не может найти, чей; текста ответа в записи нет — он содержимое чужой записи.
//
// Счётчик нужен потому, что журнал контейнера живёт до ротации, а вопрос «кому
// не ответили за последние сутки» задают позже.
func (s *TranscribeService) undelivered(job *entity.TranscribeJob, level slog.Level, reason string) {
metrics.UndeliveredReplyCounter.WithLabelValues(reason).Inc()
// Уровень выбирается ветвлением, а не передачей контекста: контекст здесь
// брать неоткуда — ответ идёт после сохранения состояния, — а выдуманный
// `context.Background()` соврал бы про отмену и цеплялся бы правилами.
switch level {
case slog.LevelError:
s.logger.Error(undeliveredMessage, "job_id", job.Id, "reason", reason)
default:
s.logger.Warn(undeliveredMessage, "job_id", job.Id, "reason", reason)
}
}
const undeliveredMessage = "Reply was not delivered"
// notify отвечает отправителю там, где поднимать отказ некуда: задача уже
// доведена до конца, и отказ отправки остаётся записью в журнале владельца.
func (s *TranscribeService) notify(job *entity.TranscribeJob, text string) {
+140
View File
@@ -0,0 +1,140 @@
package service
import (
"bytes"
"log/slog"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
"git.vakhrushev.me/av/transcriber/internal/contract"
"git.vakhrushev.me/av/transcriber/internal/entity"
)
// Ответ отправителю уходит после того, как достигнутое состояние сохранено.
// Значит, недоставка не может быть отказом шага: объявленный отказ засчитался
// бы воркеру сбоем, лёг бы владельцу записью отказа и переписал бы служебные
// поля завершённой задачи. Причин недоставки две, исход у них общий.
// downSender изображает неподнятый канал доставки: так ведёт себя заглушка,
// которую ядро получает вместо отправителя Telegram.
type downSender struct {
calls int
}
func (s *downSender) Send(string, int64, *int) error {
s.calls++
return contract.ErrDeliveryChannelDown
}
// journalEnv пересобирает сервис с названным отправителем и своим журналом:
// утверждения судят и состояние задачи, и то, что увидел владелец.
func journalEnv(
t *testing.T,
env *pipelineEnv,
rec contract.AudioRecognizer,
sender contract.TelegramMessageSender,
) (*TranscribeService, *bytes.Buffer) {
t.Helper()
journal := &bytes.Buffer{}
svc := NewTranscribeService(
env.jobRepo,
env.fileRepo,
&okMetaViewer{},
&failingConverter{},
rec,
sender,
slog.New(slog.NewTextHandler(journal, &slog.HandlerOptions{Level: slog.LevelDebug})),
)
return svc, journal
}
// Канал не поднят: задача доводится до конца, шаг отказа не объявляет, а
// владелец узнаёт о недоставке из журнала.
func TestUndeliveredOnDownChannelKeepsJobDone(t *testing.T) {
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
rec := &scriptedRecognizer{result: entity.NewInProgressResult()}
job := transcribingJob(t, env, rec)
rec.result = entity.NewCompletedResult()
rec.text = "расшифровка записи"
sender := &downSender{}
svc, journal := journalEnv(t, env, rec, sender)
// Шаг завершается без отказа — именно это воркер считает в свой счётчик.
require.NoError(t, svc.FindAndRunTranscribeCheckJob(t.Context()))
assert.Equal(t, 1, sender.calls, "ответ до отправителя доехал")
after, err := env.jobRepo.GetByID(job.Id)
require.NoError(t, err)
assert.Equal(t, entity.StateDone, after.State, "задача осталась в достигнутом состоянии")
require.NotNil(t, after.TranscriptionText)
assert.Equal(t, "расшифровка записи", *after.TranscriptionText, "расшифровка сохранена")
assert.Nil(t, after.ErrorText, "отказ задаче не приписан")
written := journal.String()
assert.Contains(t, written, "Reply was not delivered", "недоставка названа")
assert.Contains(t, written, job.Id, "запись несёт идентификатор задачи")
assert.Contains(t, written, "level=WARN", "объявленный режим — «может стать проблемой»")
assert.NotContains(t, written, "расшифровка записи", "текста расшифровки в журнале нет")
}
// Адресат у задачи не назван: исход тот же. Прежде эта ветка объявляла отказ
// шага на уже завершённой работе.
func TestUndeliveredWithoutChatKeepsJobDone(t *testing.T) {
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
rec := &scriptedRecognizer{result: entity.NewInProgressResult()}
job := transcribingJob(t, env, rec)
// Задача из Telegram, у которой чат не назван: такую отдаёт правка в панели.
// Колонка чистится мимо захвата — иначе setup унёс бы задачу у шага.
record, err := env.app.FindRecordById(migrations.JobsCollection, job.Id)
require.NoError(t, err)
record.Set("tg_chat_id", nil)
require.NoError(t, env.app.Save(record))
rec.result = entity.NewCompletedResult()
rec.text = "расшифровка записи"
sender := &downSender{}
svc, journal := journalEnv(t, env, rec, sender)
require.NoError(t, svc.FindAndRunTranscribeCheckJob(t.Context()))
assert.Equal(t, 0, sender.calls, "до отправителя дело не дошло: адресата нет")
after, err := env.jobRepo.GetByID(job.Id)
require.NoError(t, err)
assert.Equal(t, entity.StateDone, after.State)
assert.Nil(t, after.ErrorText, "отказ задаче не приписан")
written := journal.String()
assert.Contains(t, written, "Reply was not delivered")
assert.Contains(t, written, job.Id)
assert.Contains(t, written, "chat is not specified", "причина названа")
assert.Contains(t, written, "level=ERROR",
"порча записи громче штатного «бот не настроен»: иначе сигнал утонет")
}
// Запись, принятая по HTTP, до отправителя не доходит вовсе: недоставки нет, и
// записи о ней в журнале быть не должно — иначе журнал владельца заполнят
// строки о задачах основного входа.
func TestApiJobDoesNotReachSenderAndLogsNothing(t *testing.T) {
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
job, err := env.service.CreateJobFromApi(t.Context(), strings.NewReader("запись"), "voice.ogg")
require.NoError(t, err)
sender := &downSender{}
svc, journal := journalEnv(t, env, &scriptedRecognizer{}, sender)
require.NoError(t, svc.send(job, "расшифровка записи"))
assert.Equal(t, 0, sender.calls, "отправителя не звали")
assert.NotContains(t, journal.String(), "Reply was not delivered", "недоставки не было")
}