удалён вход Telegram, владелец записи стал обязателен в схеме
- убраны клиент бота, транспорт обновлений, отправитель сообщений, сборка входа при старте, секция настроек и зависимость go-telegram-bot-api; из конвейера ушла доставка ответа отправителю — исход виден опросом готовности. Колонки адресата и значение источника остались в схеме: применённые шаги не переписываются - шаг 202608140003 запрещает пустого владельца у аудиозаписи и у файла; существующие строки он не проверяет, и это принято сознательно — искать их надо запросом до выкладки - ревью нашло два пред-существующих дефекта, оба закрыты: пустой второй ответ распознавателя стирал сохранённую расшифровку, а пустая расшифровка перестала быть заметной вместе с убранной доставкой. Попутно поднят golang.org/x/image до v0.45.0 — красный шаг vulns, воспроизводился и на чистом master
This commit is contained in:
@@ -0,0 +1,77 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
)
|
||||
|
||||
// up202608140003 запрещает пустого владельца у аудиозаписи и у её файла.
|
||||
//
|
||||
// Прежде пустое значение допускалось, и цену за это платили записи, принятые
|
||||
// ботом: связи чата Telegram с учётной записью сервис не вёл, и владельца у них
|
||||
// не было вовсе. Вход Telegram убран, заводить ничью запись стало некому, и
|
||||
// обязательность переезжает из приёма в схему — туда, где её держит хранилище, а
|
||||
// не договорённость. Разница не косметическая: пока обязательность жила в
|
||||
// приёме, ничью запись заводили руками в панели, она уходила в конвейер, стоила
|
||||
// денег на распознавание и не доставалась потом никому.
|
||||
//
|
||||
// Существующих строк шаг **не смотрит**, и это проверено прогоном: хранилище
|
||||
// держит обязательность связи проверкой записи при сохранении, а не ограничением
|
||||
// таблицы, поэтому смена признака на базе с ничьей записью проходит зелёным и
|
||||
// такую запись оставляет. Искать ничьи строки надо до выкладки и запросом —
|
||||
// `SELECT count(*) FROM audio_records WHERE owner = ”` и то же по `files`;
|
||||
// прогон самого шага на копии этого не показывает.
|
||||
//
|
||||
// Оставленная ничья запись становится незакрываемой: захват идёт сырым запросом
|
||||
// мимо проверки и выдаёт её воркеру, а всякое сохранение — включая то, которым
|
||||
// ставится признак остановки, — отказывает. Порядок выкладки поэтому начинается
|
||||
// с проверки данных, а не с прогона шага.
|
||||
func up202608140003(app core.App) error {
|
||||
for _, name := range []string{RecordsCollection, FilesCollection} {
|
||||
if err := setOwnerRequired(app, name, true); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// down202608140003 возвращает колонке необязательность. Записей это не касается:
|
||||
// пустых значений среди них нет, а появиться им теперь неоткуда.
|
||||
func down202608140003(app core.App) error {
|
||||
for _, name := range []string{RecordsCollection, FilesCollection} {
|
||||
if err := setOwnerRequired(app, name, false); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// setOwnerRequired правит признак обязательности у колонки владельца одной
|
||||
// коллекции. Колонка ищется по имени и приводится к типу связи: шаг, молча
|
||||
// пропустивший чужой тип, оставил бы схему в состоянии, о котором никто не
|
||||
// узнает.
|
||||
func setOwnerRequired(app core.App, collectionName string, required bool) error {
|
||||
collection, err := app.FindCollectionByNameOrId(collectionName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to find collection %s: %w", collectionName, err)
|
||||
}
|
||||
|
||||
field := collection.Fields.GetByName("owner")
|
||||
if field == nil {
|
||||
return errors.New("collection " + collectionName + " has no owner field")
|
||||
}
|
||||
|
||||
relation, ok := field.(*core.RelationField)
|
||||
if !ok {
|
||||
return errors.New("owner field of collection " + collectionName + " is not a relation")
|
||||
}
|
||||
|
||||
relation.Required = required
|
||||
|
||||
if err := app.Save(collection); err != nil {
|
||||
return fmt.Errorf("failed to change owner requirement in %s: %w", collectionName, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -49,6 +49,7 @@ func init() {
|
||||
pbmigrations.Register(up202608120001, down202608120001, "202608120001_oidc_login.go")
|
||||
pbmigrations.Register(up202608140001, down202608140001, "202608140001_record_owner.go")
|
||||
pbmigrations.Register(up202608140002, down202608140002, "202608140002_record_centric_model.go")
|
||||
pbmigrations.Register(up202608140003, down202608140003, "202608140003_owner_required.go")
|
||||
}
|
||||
|
||||
func ptr[T any](v T) *T { return &v }
|
||||
|
||||
@@ -42,9 +42,7 @@ func newRecordOf(t *testing.T, app core.App, ownerID string) *entity.AudioRecord
|
||||
State: entity.StateUploaded,
|
||||
StateEnteredAt: clock.Now(),
|
||||
Source: entity.SourceApi,
|
||||
}
|
||||
if ownerID != "" {
|
||||
record.OwnerID = &ownerID
|
||||
OwnerID: ownerID,
|
||||
}
|
||||
require.NoError(t, NewAudioRecordRepository(app).Create(record))
|
||||
return record
|
||||
@@ -65,8 +63,7 @@ func TestGuardOwnerDeletion(t *testing.T) {
|
||||
|
||||
after, err := NewAudioRecordRepository(app).Get(record.Id)
|
||||
require.NoError(t, err, "запись на месте")
|
||||
require.NotNil(t, after.OwnerID, "и владелец у неё прежний")
|
||||
assert.Equal(t, account.Id, *after.OwnerID)
|
||||
assert.Equal(t, account.Id, after.OwnerID, "и владелец у неё прежний")
|
||||
}
|
||||
|
||||
// Считаются все коллекции с владельцем, а не одни записи: файл переживает свою
|
||||
@@ -119,19 +116,52 @@ func TestGuardOwnerDeletionLetsEmptyAccountGo(t *testing.T) {
|
||||
require.NoError(t, app.Delete(account), "пустая учётная запись удаляется")
|
||||
}
|
||||
|
||||
// Умолчания у колонки владельца нет: запись, чей владелец не назван, не
|
||||
// достаётся никому по недосмотру схемы.
|
||||
func TestOwnerColumnHasNoDefault(t *testing.T) {
|
||||
// Колонка владельца пустого значения не принимает и умолчания не имеет:
|
||||
// ничьей записи в хранилище не бывает, и завести её нечем — ни приёмом, ни
|
||||
// конвейером, ни рукой в панели.
|
||||
func TestOwnerColumnRefusesEmptyValue(t *testing.T) {
|
||||
app := newTestStorage(t)
|
||||
|
||||
records, err := app.FindCollectionByNameOrId(migrations.RecordsCollection)
|
||||
require.NoError(t, err)
|
||||
for _, name := range []string{migrations.RecordsCollection, migrations.FilesCollection} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
collection, err := app.FindCollectionByNameOrId(name)
|
||||
require.NoError(t, err)
|
||||
|
||||
field := records.Fields.GetByName("owner")
|
||||
require.NotNil(t, field, "колонка владельца заведена")
|
||||
field := collection.Fields.GetByName("owner")
|
||||
require.NotNil(t, field, "колонка владельца заведена")
|
||||
|
||||
relation, ok := field.(*core.RelationField)
|
||||
require.True(t, ok, "владелец — связь с учётной записью, а не строка")
|
||||
assert.False(t, relation.Required, "пустое значение допустимо ради записей бота")
|
||||
assert.False(t, relation.CascadeDelete, "удаление учётной записи не уносит архив следом")
|
||||
relation, ok := field.(*core.RelationField)
|
||||
require.True(t, ok, "владелец — связь с учётной записью, а не строка")
|
||||
assert.True(t, relation.Required, "пустое значение колонка не принимает")
|
||||
assert.False(t, relation.CascadeDelete, "удаление учётной записи не уносит архив следом")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Та же норма со стороны сохранения: схема отвергает запись без владельца, а не
|
||||
// только объявляет колонку обязательной.
|
||||
func TestStorageRefusesRecordWithoutOwner(t *testing.T) {
|
||||
app := newTestStorage(t)
|
||||
|
||||
record := &entity.AudioRecord{
|
||||
State: entity.StateUploaded,
|
||||
StateEnteredAt: clock.Now(),
|
||||
Source: entity.SourceApi,
|
||||
}
|
||||
|
||||
require.Error(t, NewAudioRecordRepository(app).Create(record),
|
||||
"ничья запись в хранилище не ложится")
|
||||
}
|
||||
|
||||
// И файл — наравне с записью: разное правило у них читалось бы как недосмотр.
|
||||
func TestStorageRefusesFileWithoutOwner(t *testing.T) {
|
||||
app := newTestStorage(t)
|
||||
|
||||
repo := NewFileRepository(app)
|
||||
work, err := repo.Stage(".mp3", strings.NewReader("запись"))
|
||||
require.NoError(t, err)
|
||||
defer func() { require.NoError(t, work.Close()) }()
|
||||
|
||||
_, err = repo.Create("sample.mp3", work, contract.FileMeta{Format: "mp3"}, "")
|
||||
require.Error(t, err, "ничей файл в хранилище не ложится")
|
||||
}
|
||||
|
||||
@@ -69,7 +69,7 @@ func updateByRequest(t *testing.T, app core.App, recordID string, body map[strin
|
||||
func haltedRecord(t *testing.T, app core.App) *entity.AudioRecord {
|
||||
t.Helper()
|
||||
|
||||
record := newRecordOf(t, app, "")
|
||||
record := newRecordOf(t, app, newAccount(t, app).Id)
|
||||
record.MoveToState(entity.StateNormalized)
|
||||
record.Attempts = 4
|
||||
record.AcquisitionID = ptrOf("прежний-захват")
|
||||
@@ -143,7 +143,7 @@ func TestPanelResumeIsLogged(t *testing.T) {
|
||||
func TestPanelStateEditClearsGuards(t *testing.T) {
|
||||
app := newPanelStorage(t)
|
||||
|
||||
record := newRecordOf(t, app, "")
|
||||
record := newRecordOf(t, app, newAccount(t, app).Id)
|
||||
record.Attempts = 4
|
||||
record.AcquisitionID = ptrOf("прежний-захват")
|
||||
record.AcquireExpiresAt = ptrOf(clock.Now().Add(8 * time.Hour))
|
||||
@@ -162,7 +162,7 @@ func TestPanelStateEditClearsGuards(t *testing.T) {
|
||||
func TestPanelKeepsGuardsOnUnrelatedEdit(t *testing.T) {
|
||||
app := newPanelStorage(t)
|
||||
|
||||
record := newRecordOf(t, app, "")
|
||||
record := newRecordOf(t, app, newAccount(t, app).Id)
|
||||
record.Attempts = 3
|
||||
record.AcquisitionID = ptrOf("живой-захват")
|
||||
require.NoError(t, NewAudioRecordRepository(app).Save(record, ""))
|
||||
@@ -181,7 +181,7 @@ func TestAcquireCarriesStageDeadline(t *testing.T) {
|
||||
app := newTestStorage(t)
|
||||
repo := NewAudioRecordRepository(app)
|
||||
|
||||
record := newRecordOf(t, app, "")
|
||||
record := newRecordOf(t, app, newAccount(t, app).Id)
|
||||
|
||||
acquired, err := repo.FindAndAcquire(entity.WorkingStages())
|
||||
require.NoError(t, err)
|
||||
@@ -205,7 +205,7 @@ func TestAcquireHandsRecordToExactlyOne(t *testing.T) {
|
||||
app := newTestStorage(t)
|
||||
repo := NewAudioRecordRepository(app)
|
||||
|
||||
newRecordOf(t, app, "")
|
||||
newRecordOf(t, app, newAccount(t, app).Id)
|
||||
|
||||
first, err := repo.FindAndAcquire(entity.WorkingStages())
|
||||
require.NoError(t, err, "первому запись досталась")
|
||||
@@ -223,7 +223,7 @@ func TestRottenAcquisitionIsHandedOutAgain(t *testing.T) {
|
||||
app := newTestStorage(t)
|
||||
repo := NewAudioRecordRepository(app)
|
||||
|
||||
record := newRecordOf(t, app, "")
|
||||
record := newRecordOf(t, app, newAccount(t, app).Id)
|
||||
|
||||
first, err := repo.FindAndAcquire(entity.WorkingStages())
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -50,18 +50,16 @@ func applyToRecord(record *core.Record, r *entity.AudioRecord) {
|
||||
// Владелец кладётся только здесь, при заведении. В applyOwnedByPipeline его
|
||||
// нет намеренно: конвейер владельца не назначает и не меняет, а снимок шага,
|
||||
// записанный поверх, стёр бы его молча.
|
||||
record.Set("owner", derefString(r.OwnerID))
|
||||
record.Set("owner", r.OwnerID)
|
||||
record.Set("source", r.Source)
|
||||
record.Set("title", derefString(r.Title))
|
||||
record.Set("brief", derefString(r.Brief))
|
||||
record.Set("tg_chat_id", derefInt64(r.TgChatId))
|
||||
record.Set("tg_reply_message_id", derefInt(r.TgReplyMessageId))
|
||||
}
|
||||
|
||||
func recordToAudioRecord(record *core.Record) *entity.AudioRecord {
|
||||
return &entity.AudioRecord{
|
||||
Id: record.Id,
|
||||
OwnerID: nilIfEmpty(record.GetString("owner")),
|
||||
OwnerID: record.GetString("owner"),
|
||||
Source: record.GetString("source"),
|
||||
Title: nilIfEmpty(record.GetString("title")),
|
||||
Brief: nilIfEmpty(record.GetString("brief")),
|
||||
@@ -80,8 +78,6 @@ func recordToAudioRecord(record *core.Record) *entity.AudioRecord {
|
||||
LiteraryTextID: nilIfEmpty(record.GetString("literary_text")),
|
||||
StructureID: nilIfEmpty(record.GetString("structure")),
|
||||
RecognitionID: nilIfEmpty(record.GetString("recognition")),
|
||||
TgChatId: nilIfZero64(int64(record.GetInt("tg_chat_id"))),
|
||||
TgReplyMessageId: nilIfZeroInt(record.GetInt("tg_reply_message_id")),
|
||||
CreatedAt: record.GetDateTime("created").Time(),
|
||||
UpdatedAt: record.GetDateTime("updated").Time(),
|
||||
}
|
||||
@@ -94,20 +90,6 @@ func derefString(v *string) string {
|
||||
return *v
|
||||
}
|
||||
|
||||
func derefInt64(v *int64) int64 {
|
||||
if v == nil {
|
||||
return 0
|
||||
}
|
||||
return *v
|
||||
}
|
||||
|
||||
func derefInt(v *int) int {
|
||||
if v == nil {
|
||||
return 0
|
||||
}
|
||||
return *v
|
||||
}
|
||||
|
||||
// dateOrEmpty отдаёт пустое значение вместо нулевой даты: пустая колонка даты в
|
||||
// хранилище это пустая строка, и она же значит «времени нет».
|
||||
func dateOrEmpty(v *time.Time) any {
|
||||
@@ -135,17 +117,3 @@ func timeOrNil(v types.DateTime) *time.Time {
|
||||
t := v.Time()
|
||||
return &t
|
||||
}
|
||||
|
||||
func nilIfZero64(v int64) *int64 {
|
||||
if v == 0 {
|
||||
return nil
|
||||
}
|
||||
return &v
|
||||
}
|
||||
|
||||
func nilIfZeroInt(v int) *int {
|
||||
if v == 0 {
|
||||
return nil
|
||||
}
|
||||
return &v
|
||||
}
|
||||
|
||||
@@ -58,7 +58,7 @@ func (repo *AudioRecordRepository) Create(r *entity.AudioRecord) error {
|
||||
// перевыданный другому — по протуханию срока или после того, как человек снял
|
||||
// признак остановки в панели, — обязан обратить запись первого в отказ; условие
|
||||
// по непустоте признака пропустило бы обоих, и два шага записали бы в одну
|
||||
// запись и оба ответили бы отправителю.
|
||||
// запись по очереди, портя её результат.
|
||||
func (repo *AudioRecordRepository) Save(r *entity.AudioRecord, holder string) error {
|
||||
return repo.app.RunInTransaction(func(txApp core.App) error {
|
||||
record, err := txApp.FindRecordById(migrations.RecordsCollection, r.Id)
|
||||
@@ -89,9 +89,10 @@ func (repo *AudioRecordRepository) Save(r *entity.AudioRecord, holder string) er
|
||||
// по разнице ответов иначе перебирается список заведённых записей, а
|
||||
// идентификатор записи и есть то, что разграничение прячет.
|
||||
//
|
||||
// Пустой ownerID отсекается **до** чтения и не совпадает ни с чем: иначе
|
||||
// вызывающий без учётной записи получил бы ровно множество записей без
|
||||
// владельца, то есть все записи бота.
|
||||
// Пустой ownerID отсекается **до** чтения и не совпадает ни с чем. Правило это
|
||||
// не стало избыточным с обязательностью колонки: схема запрещает **заводить**
|
||||
// ничью запись, а здесь запрещено **спрашивать** ничьим именем — иначе
|
||||
// вызывающий без учётной записи получил бы выборку вместо отказа.
|
||||
func (repo *AudioRecordRepository) GetByID(id, ownerID string) (*entity.AudioRecord, error) {
|
||||
if ownerID == "" {
|
||||
return nil, &contract.JobNotFoundError{Message: "record not found"}
|
||||
|
||||
@@ -26,6 +26,15 @@ func NewTextRepository(app core.App) *TextRepository {
|
||||
// Замена, а не вставка: пара «запись и вид» уникальна, и повтор прерванного шага
|
||||
// иначе завёл бы второй комплект строк — тогда вопрос «какой текст отдавать
|
||||
// человеку» стал бы вопросом порядка записи, а не состояния.
|
||||
//
|
||||
// **Пустое не кладётся поверх непустого**, и это не осторожность, а защита
|
||||
// архива. Повторный опрос той же операции — обычное дело: держатель захвата
|
||||
// умер, сохранение рубежа отказало, человек снял остановку в панели. Провайдер
|
||||
// при этом вправе ответить пустым потоком, отказом это не считается, и
|
||||
// безусловная замена стирала бы сохранённую расшифровку живого человека без
|
||||
// следа и без возврата. Та же защита стоит у сырого ответа провайдера
|
||||
// (`RecognitionRepository.Finish`), и разное правило у двух хранителей одного
|
||||
// результата читалось бы как недосмотр.
|
||||
func (repo *TextRepository) Put(recordID, kind, contents string) (*entity.Text, error) {
|
||||
collection, err := findCollection(repo.app, migrations.TextsCollection)
|
||||
if err != nil {
|
||||
@@ -50,6 +59,11 @@ func (repo *TextRepository) Put(recordID, kind, contents string) (*entity.Text,
|
||||
// запись вместо правды о недоступной базе.
|
||||
return nil, fmt.Errorf("failed to look up text of kind %s for record %s: %w", kind, recordID, err)
|
||||
}
|
||||
// Прежнее непустое содержимое пустым не заменяется: строка остаётся как
|
||||
// есть, и вызывающий получает её обратно.
|
||||
if contents == "" && record.GetString("contents") != "" {
|
||||
return textFromRecord(record), nil
|
||||
}
|
||||
record.Set("contents", contents)
|
||||
|
||||
if err := repo.app.Save(record); err != nil {
|
||||
@@ -106,7 +120,12 @@ func (repo *StructureRepository) Put(recordID string, version int, replicas []en
|
||||
)
|
||||
switch {
|
||||
case err == nil:
|
||||
// Строка есть — заменяем содержимое.
|
||||
// Строка есть — заменяем содержимое. Пустой перечень реплик поверх
|
||||
// непустого не кладётся по тому же доводу, что и у текста: повторный
|
||||
// опрос с пустым ответом провайдера стирал бы разбор живой записи.
|
||||
if len(replicas) == 0 && len(record.GetString("contents")) > len("[]") {
|
||||
return repo.GetByID(record.Id)
|
||||
}
|
||||
case errors.Is(err, sql.ErrNoRows):
|
||||
record = core.NewRecord(collection)
|
||||
record.Set("record", recordID)
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
package telegram
|
||||
|
||||
import (
|
||||
"git.vakhrushev.me/av/transcriber/internal/contract"
|
||||
)
|
||||
|
||||
// AbsentMessageSender подставляется вместо отправителя Telegram, когда вход
|
||||
// выключен признаком `telegram.enabled` либо Telegram оказался недоступен, и
|
||||
// клиента заводить не из чего. Он ничего не отправляет и на всякий ответ отдаёт
|
||||
// `contract.ErrDeliveryChannelDown`.
|
||||
//
|
||||
// Заглушка, а не пустой отправитель: необязательная зависимость, доехавшая до
|
||||
// ядра нулём, роняет процесс на первой же задаче из Telegram, а проверка на
|
||||
// месте употребления завела бы в ядре знание о том, как собран сервис.
|
||||
//
|
||||
// Молчит он намеренно. Записать недоставку заглушке нечем: контракт отправки
|
||||
// несёт текст, чат и сообщение для ответа, а идентификатора задачи в нём нет.
|
||||
// Пишет поэтому шаг конвейера, который задачу знает.
|
||||
type AbsentMessageSender struct{}
|
||||
|
||||
func NewAbsentMessageSender() *AbsentMessageSender {
|
||||
return &AbsentMessageSender{}
|
||||
}
|
||||
|
||||
func (s *AbsentMessageSender) Send(_ string, _ int64, _ *int) error {
|
||||
return contract.ErrDeliveryChannelDown
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
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, "отправитель говорит с тем же клиентом, что и транспорт")
|
||||
}
|
||||
@@ -1,134 +0,0 @@
|
||||
package telegram
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
|
||||
)
|
||||
|
||||
// ErrEmptyToken — ключ доступа пуст при включённом входе, то есть **ошибка
|
||||
// настройки**: старт роняется. Отдельным значением, чтобы отличаться от
|
||||
// недоступности Telegram, у которой исход обратный — подъём без бота.
|
||||
//
|
||||
// Отказ от входа Telegram этим значением больше не выражается: намерение
|
||||
// объявляет признак включения `telegram.enabled`, и выключенный вход отсеивается
|
||||
// до всякого обращения сюда. Пустой ключ ловит проверка настроек ещё раньше,
|
||||
// поэтому сюда он доходит только в обход проверки.
|
||||
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)
|
||||
}
|
||||
|
||||
// Сборка ходит за `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.
|
||||
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))
|
||||
}
|
||||
@@ -1,172 +0,0 @@
|
||||
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)
|
||||
})
|
||||
}
|
||||
|
||||
// Токен, ломающий разбор адреса, — второй путь отказа конструктора, и до
|
||||
// недавнего он был открыт: `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) {
|
||||
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)
|
||||
|
||||
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` по цепочке продолжает
|
||||
// работать, иначе чистка стоила бы узнаваемости отказа.
|
||||
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",
|
||||
"строка библиотеки прошла мимо нашего журнала: логгер не подменён")
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
package telegram
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
|
||||
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
|
||||
)
|
||||
|
||||
const (
|
||||
TextLengthLimit = 4000
|
||||
)
|
||||
|
||||
type TelegramMessageSender struct {
|
||||
bot *tgbotapi.BotAPI
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
// NewTelegramMessageSender принимает готового клиента, а не токен. Клиента
|
||||
// заводит сборка при старте — одного на отправителя и на транспорт бота: пока
|
||||
// его строили здесь и там порознь, два пути одного старта разошлись в том,
|
||||
// терпеть ли негодный токен, и согласовывать их приходилось руками.
|
||||
func NewTelegramMessageSender(bot *tgbotapi.BotAPI, logger *slog.Logger) *TelegramMessageSender {
|
||||
return &TelegramMessageSender{
|
||||
bot: bot,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *TelegramMessageSender) Send(text string, chatId int64, replyToMessageId *int) error {
|
||||
// If message is short enough, send it directly
|
||||
if len([]rune(text)) <= TextLengthLimit {
|
||||
return s.sendSingleMessage(text, chatId, replyToMessageId)
|
||||
}
|
||||
|
||||
// Split long message into parts
|
||||
parts := s.splitMessageByWords(text, TextLengthLimit)
|
||||
|
||||
// Send each part
|
||||
for i, part := range parts {
|
||||
var replyId *int
|
||||
// Only use replyToMessageId for the first part
|
||||
if i == 0 {
|
||||
replyId = replyToMessageId
|
||||
}
|
||||
err := s.sendSingleMessage(part, chatId, replyId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// sendSingleMessage sends a single message
|
||||
func (s *TelegramMessageSender) sendSingleMessage(text string, chatId int64, replyToMessageId *int) error {
|
||||
resultMsg := tgbotapi.NewMessage(chatId, text)
|
||||
if replyToMessageId != nil {
|
||||
resultMsg.ReplyToMessageID = *replyToMessageId
|
||||
}
|
||||
_, err := s.bot.Send(resultMsg)
|
||||
if err != nil {
|
||||
s.logger.Error("Failed to send message to tg bot", "error", err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
package telegram
|
||||
|
||||
// splitMessageByWords splits a message into parts of maxLen UTF-8 characters
|
||||
// splitting by words to avoid cutting words in the middle
|
||||
func (s *TelegramMessageSender) splitMessageByWords(text string, maxLen int) []string {
|
||||
var parts []string
|
||||
|
||||
// If text is already short enough, return as is
|
||||
if len([]rune(text)) <= maxLen {
|
||||
return []string{text}
|
||||
}
|
||||
|
||||
runes := []rune(text)
|
||||
|
||||
for len(runes) > 0 {
|
||||
// Determine the end position for this part
|
||||
end := len(runes)
|
||||
if end > maxLen {
|
||||
end = maxLen
|
||||
}
|
||||
|
||||
// Try to find a good split point (word boundary)
|
||||
splitPoint := end
|
||||
for i := end - 1; i > end-20 && i > 0; i-- { // Look back up to 20 characters
|
||||
// Check if this is a good split point (after a space)
|
||||
if runes[i] == ' ' {
|
||||
splitPoint = i + 1 // Include the space in the previous part
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// If we couldn't find a good split point, just split at maxLen
|
||||
if splitPoint == end && end == maxLen {
|
||||
// Check if we're in the middle of a word
|
||||
if end < len(runes) && runes[end] != ' ' && runes[end-1] != ' ' {
|
||||
// Try to find a split point going forward
|
||||
for i := end; i < len(runes) && i < end+20; i++ {
|
||||
if runes[i] == ' ' {
|
||||
splitPoint = i
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If still no good split point, use the original end
|
||||
if splitPoint > len(runes) {
|
||||
splitPoint = len(runes)
|
||||
}
|
||||
|
||||
// Add this part
|
||||
parts = append(parts, string(runes[:splitPoint]))
|
||||
|
||||
// Move to the next part
|
||||
if splitPoint >= len(runes) {
|
||||
break
|
||||
}
|
||||
runes = runes[splitPoint:]
|
||||
}
|
||||
|
||||
return parts
|
||||
}
|
||||
@@ -1,125 +0,0 @@
|
||||
package telegram
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestTelegramMessageSender_splitMessageByWords(t *testing.T) {
|
||||
sender := &TelegramMessageSender{}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
text string
|
||||
maxLen int
|
||||
expected []string
|
||||
}{
|
||||
{
|
||||
name: "Short text should return as is",
|
||||
text: "Привет мир",
|
||||
maxLen: 25,
|
||||
expected: []string{
|
||||
"Привет мир",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Text exactly at limit",
|
||||
text: "Это тестовый текст который ровно соответствует лимиту",
|
||||
maxLen: 35,
|
||||
expected: []string{
|
||||
"Это тестовый текст который ровно ",
|
||||
"соответствует ",
|
||||
"лимиту",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Text with word boundaries",
|
||||
text: "Это очень длинный текст для проверки работы функции разделения сообщения",
|
||||
maxLen: 25,
|
||||
expected: []string{
|
||||
"Это очень длинный текст ",
|
||||
"для проверки работы ",
|
||||
"функции разделения ",
|
||||
"сообщения",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Text with long words",
|
||||
text: "Этот текст содержит оченьдлинноеслово которое не должно быть разбито",
|
||||
maxLen: 20,
|
||||
expected: []string{
|
||||
"Этот текст содержит ",
|
||||
"оченьдлинноеслово ",
|
||||
"которое не должно ",
|
||||
"быть ",
|
||||
"разбито",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Text with multiple spaces",
|
||||
text: "Этот текст имеет много пробелов",
|
||||
maxLen: 20,
|
||||
expected: []string{
|
||||
"Этот текст ",
|
||||
"имеет много ",
|
||||
"пробелов",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Text with Russian characters and punctuation",
|
||||
text: "Привет! Как дела? Это тестовая строка для проверки работы функции.",
|
||||
maxLen: 25,
|
||||
expected: []string{
|
||||
"Привет! Как дела? Это ",
|
||||
"тестовая строка для ",
|
||||
"проверки работы ",
|
||||
"функции.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Single word longer than maxLen",
|
||||
text: "Некотороедлинноеслово",
|
||||
maxLen: 10,
|
||||
expected: []string{
|
||||
"Некотороед",
|
||||
"линноеслов",
|
||||
"о",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Text with mixed Russian and English",
|
||||
text: "Привет Hello мир World текст для проверки",
|
||||
maxLen: 20,
|
||||
expected: []string{
|
||||
"Привет Hello мир ",
|
||||
"World текст для ",
|
||||
"проверки",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Text with special characters",
|
||||
text: "Тест с символами: @#$%^&*()_+-=[]{}|;':\",./<>?",
|
||||
maxLen: 25,
|
||||
expected: []string{
|
||||
"Тест с символами: ",
|
||||
"@#$%^&*()_+-=[]{}|;':\",./",
|
||||
"<>?",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := sender.splitMessageByWords(tt.text, tt.maxLen)
|
||||
if len(result) != len(tt.expected) {
|
||||
t.Errorf("splitMessageByWords() length = %d, want %d", len(result), len(tt.expected))
|
||||
return
|
||||
}
|
||||
|
||||
for i, expectedPart := range tt.expected {
|
||||
if result[i] != expectedPart {
|
||||
t.Errorf("splitMessageByWords() part %d = %q, want %q", i, result[i], expectedPart)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -28,7 +28,7 @@ const (
|
||||
)
|
||||
|
||||
// Ядро — `internal/service`: оно знает только интерфейсы `internal/contract`, а
|
||||
// ffmpeg, Yandex, Telegram и хранилище подставляются в `main.go`
|
||||
// ffmpeg, Yandex и хранилище подставляются в `main.go`
|
||||
// (docs/architecture.md, «Принципы»).
|
||||
const core = "internal/service"
|
||||
|
||||
@@ -36,7 +36,6 @@ const core = "internal/service"
|
||||
// одном из них: иначе второй начинает зависеть от первого и тащит его целиком.
|
||||
var transports = map[string]bool{
|
||||
"internal/controller/http": true,
|
||||
"internal/controller/tg": true,
|
||||
"internal/controller/worker": true,
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +19,6 @@ type Config struct {
|
||||
Storage StorageConfig `toml:"storage"`
|
||||
Pipeline PipelineConfig `toml:"pipeline"`
|
||||
Yandex YandexConfig `toml:"yandex"`
|
||||
Telegram TelegramConfig `toml:"telegram"`
|
||||
Auth AuthConfig `toml:"auth"`
|
||||
}
|
||||
|
||||
@@ -61,10 +60,9 @@ func (c PipelineConfig) Validate() error {
|
||||
}
|
||||
|
||||
type ServerConfig struct {
|
||||
Port int `toml:"port"`
|
||||
ShutdownTimeout int `toml:"shutdown_timeout"`
|
||||
ForceShutdownTimeout int `toml:"force_shutdown_timeout"`
|
||||
UsersWhiteList []string `toml:"users_while_list"`
|
||||
Port int `toml:"port"`
|
||||
ShutdownTimeout int `toml:"shutdown_timeout"`
|
||||
ForceShutdownTimeout int `toml:"force_shutdown_timeout"`
|
||||
}
|
||||
|
||||
// StorageConfig — единственный каталог данных: под ним лежат и база, и файлы
|
||||
@@ -83,33 +81,6 @@ type YandexConfig struct {
|
||||
ObjStorageEndpoint string `toml:"object_storage_endpoint"`
|
||||
}
|
||||
|
||||
// TelegramConfig — вход Telegram. Признак включения объявляет намерение
|
||||
// владельца, `BotToken` означает только доступ. Пока два значения жили в одном
|
||||
// поле, пустой токен читался разом как «вход выключен» и как «ключ не доехал»,
|
||||
// и сервис поднимался без бота в обоих случаях.
|
||||
type TelegramConfig struct {
|
||||
// Enabled — умолчания у него нет **намеренно**, и потому его нет в
|
||||
// `defaultConfig()`: умолчание было бы угаданным намерением, а признак
|
||||
// заведён затем, чтобы намерение объявляли. Отсутствие ключа в файле ловит
|
||||
// `LoadConfig` — нулевое значение `bool` режима не выбирает.
|
||||
Enabled bool `toml:"enabled"`
|
||||
BotToken string `toml:"bot_token"`
|
||||
UpdateTimeout int `toml:"update_timeout"`
|
||||
}
|
||||
|
||||
// Validate проверяет ключ доступа против объявленного намерения. Пустой ключ
|
||||
// при включённом входе — ошибка настройки: бот по нему не появится, а тихий
|
||||
// подъём без бота оставил бы отправителей без ответов.
|
||||
//
|
||||
// Названо имя ключа, а не значение: значение `bot_token` в журнал попасть не
|
||||
// должно.
|
||||
func (c TelegramConfig) Validate() error {
|
||||
if c.Enabled && c.BotToken == "" {
|
||||
return errors.New("telegram: не заполнен ключ bot_token при enabled = true")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AuthConfig — вход через внешнего провайдера OIDC. Адреса, идентификатор
|
||||
// клиента и секрет приезжают сюда и приводятся к настройкам коллекции
|
||||
// пользователей при каждом подъёме: применённый шаг схемы не переписывается, и
|
||||
@@ -202,11 +173,6 @@ func defaultConfig() *Config {
|
||||
ObjStorageRegion: "ru-central1",
|
||||
ObjStorageEndpoint: "https://storage.yandexcloud.net/",
|
||||
},
|
||||
// Умолчания у `Enabled` здесь нет намеренно — причина у поля.
|
||||
Telegram: TelegramConfig{
|
||||
BotToken: "",
|
||||
UpdateTimeout: 10,
|
||||
},
|
||||
Auth: AuthConfig{
|
||||
SecureCookie: true,
|
||||
},
|
||||
@@ -223,19 +189,10 @@ func LoadConfig(path string) (*Config, error) {
|
||||
config := defaultConfig()
|
||||
|
||||
// Load configuration from file
|
||||
meta, err := toml.DecodeFile(path, &config)
|
||||
if err != nil {
|
||||
if _, err := toml.DecodeFile(path, &config); err != nil {
|
||||
return nil, decodeError(path, err)
|
||||
}
|
||||
|
||||
// Признак включения входа Telegram обязателен: умолчания у него нет, и
|
||||
// отличить «не задан» от «задан ложным» умеет только разбор — нулевое
|
||||
// значение `bool` в структуре у обоих одинаковое. Отсюда и `meta`: наружу
|
||||
// она не отдаётся, приговор выносится здесь.
|
||||
if !meta.IsDefined("telegram", "enabled") {
|
||||
return nil, errors.New("telegram: не задан ключ enabled; он объявляет, нужен ли сервису вход Telegram")
|
||||
}
|
||||
|
||||
return config, nil
|
||||
}
|
||||
|
||||
|
||||
+12
-107
@@ -1,7 +1,6 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -100,68 +99,6 @@ func TestAuthConfigValidateRejectsMalformedURL(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Признак включения объявляет намерение, ключ доступа означает только доступ.
|
||||
// Пока эти два значения жили в одном поле, пустой токен читался разом как
|
||||
// «вход выключен» и как «ключ не доехал».
|
||||
|
||||
func TestTelegramConfigValidateAcceptsEnabledWithToken(t *testing.T) {
|
||||
cfg := TelegramConfig{Enabled: true, BotToken: "123456:AA-fake"}
|
||||
|
||||
if err := cfg.Validate(); err != nil {
|
||||
t.Fatalf("включённый вход с ключом отвергнут: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTelegramConfigValidateRejectsEnabledWithoutToken(t *testing.T) {
|
||||
cfg := TelegramConfig{Enabled: true, BotToken: ""}
|
||||
|
||||
err := cfg.Validate()
|
||||
if err == nil {
|
||||
t.Fatal("включённый вход без ключа доступа пропущен")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "bot_token") {
|
||||
t.Fatalf("имя ключа не названо: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Выключенный вход на ключ доступа не смотрит вовсе: пустой ключ при нём —
|
||||
// обычное состояние локального прогона, а не ошибка настройки.
|
||||
func TestTelegramConfigValidateIgnoresTokenWhenDisabled(t *testing.T) {
|
||||
cfg := TelegramConfig{Enabled: false, BotToken: ""}
|
||||
|
||||
if err := cfg.Validate(); err != nil {
|
||||
t.Fatalf("выключенный вход без ключа отвергнут: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Половина требования «сообщение не несёт значения ключа» на этой проверке
|
||||
// **вакуумна**, и честнее это назвать, чем изображать сторожа.
|
||||
//
|
||||
// `Validate()` отказывает ровно на пустом ключе — значения, которым можно
|
||||
// проговориться, на этом пути не существует. Прежняя редакция сторожа искала
|
||||
// подстроку, которой в сообщении нет ни при каком входе, и потому не могла
|
||||
// упасть вовсе: правка на `%q` от токена оставила бы её зелёной. В проекте это
|
||||
// третий пойманный случай проверки, не способной упасть.
|
||||
//
|
||||
// Настоящий сторож той же нормы живёт там, где непустой ключ в отказ попасть
|
||||
// действительно может, — `TestLoadConfigMalformedSecretLineHidesValue` и
|
||||
// `TestLoadConfigMalformedBeforeAnyKeyHidesValue`. Здесь проверяется то, что
|
||||
// проверяемо: заполненный ключ проходит, пустой отвергается с именем ключа.
|
||||
func TestTelegramConfigValidateNamesKeyWithoutValue(t *testing.T) {
|
||||
filled := TelegramConfig{Enabled: true, BotToken: "123456:AAHfake-secret-token-value"}
|
||||
if err := filled.Validate(); err != nil {
|
||||
t.Fatalf("включённый вход с заполненным ключом отвергнут: %v", err)
|
||||
}
|
||||
|
||||
err := TelegramConfig{Enabled: true, BotToken: ""}.Validate()
|
||||
if err == nil {
|
||||
t.Fatal("включённый вход без ключа доступа пропущен")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "bot_token") {
|
||||
t.Fatalf("имя ключа не названо: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func writeConfig(t *testing.T, body string) string {
|
||||
t.Helper()
|
||||
|
||||
@@ -173,49 +110,17 @@ func writeConfig(t *testing.T, body string) string {
|
||||
}
|
||||
|
||||
const validConfigBody = `
|
||||
[telegram]
|
||||
enabled = false
|
||||
bot_token = ""
|
||||
[storage]
|
||||
data_dir = "data"
|
||||
`
|
||||
|
||||
// Признак обязателен: файл без него негоден. Умолчание было бы угаданным
|
||||
// намерением, а отличить «не задан» от «задан ложным» умеет только разбор —
|
||||
// нулевое значение bool у обоих одинаковое.
|
||||
func TestLoadConfigRejectsMissingTelegramEnabled(t *testing.T) {
|
||||
path := writeConfig(t, "[telegram]\nbot_token = \"123456:AA-fake\"\n")
|
||||
|
||||
_, err := LoadConfig(path)
|
||||
if err == nil {
|
||||
t.Fatal("файл без признака включения принят")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "enabled") {
|
||||
t.Fatalf("имя недостающего ключа не названо: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfigReadsBothValuesOfTelegramEnabled(t *testing.T) {
|
||||
for _, enabled := range []bool{true, false} {
|
||||
t.Run(fmt.Sprintf("%t", enabled), func(t *testing.T) {
|
||||
body := fmt.Sprintf("[telegram]\nenabled = %t\nbot_token = \"123456:AA-fake\"\n", enabled)
|
||||
|
||||
cfg, err := LoadConfig(writeConfig(t, body))
|
||||
if err != nil {
|
||||
t.Fatalf("годный файл отвергнут: %v", err)
|
||||
}
|
||||
if cfg.Telegram.Enabled != enabled {
|
||||
t.Fatalf("признак доехал как %t, а в файле %t", cfg.Telegram.Enabled, enabled)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Инвариант «секрет не покидает конфиг»: текст отказа разбора собирает чужая
|
||||
// библиотека из разбираемого куска файла, и оборванная строка ключа доступа
|
||||
// уехала бы в журнал вместе со значением. Отсюда собственное сообщение.
|
||||
func TestLoadConfigMalformedSecretLineHidesValue(t *testing.T) {
|
||||
const secret = "123456:AAHfake-secret-token-value"
|
||||
const secret = "AQVNfake-secret-api-key-value"
|
||||
// Кавычка не закрыта: разбор оборвётся на значении.
|
||||
path := writeConfig(t, "[telegram]\nenabled = true\nbot_token = \""+secret+"\n")
|
||||
path := writeConfig(t, "[yandex]\nfolder_id = \"b1g\"\nspeech_kit_api_key = \""+secret+"\n")
|
||||
|
||||
_, err := LoadConfig(path)
|
||||
if err == nil {
|
||||
@@ -223,7 +128,7 @@ func TestLoadConfigMalformedSecretLineHidesValue(t *testing.T) {
|
||||
}
|
||||
|
||||
message := err.Error()
|
||||
for _, part := range []string{secret, "AAHfake", "secret-token-value", "123456"} {
|
||||
for _, part := range []string{secret, "AQVNfake", "secret-api-key-value"} {
|
||||
if strings.Contains(message, part) {
|
||||
t.Fatalf("значение ключа доступа уехало в отказ: %v", err)
|
||||
}
|
||||
@@ -232,7 +137,7 @@ func TestLoadConfigMalformedSecretLineHidesValue(t *testing.T) {
|
||||
if !strings.Contains(message, "строке 3") {
|
||||
t.Fatalf("номер строки не назван, чинить нечего: %v", err)
|
||||
}
|
||||
if !strings.Contains(message, "bot_token") {
|
||||
if !strings.Contains(message, "speech_kit_api_key") {
|
||||
t.Fatalf("ключ не назван, чинить нечего: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -256,9 +161,9 @@ func TestLoadConfigTypeMismatchKeepsDiagnostics(t *testing.T) {
|
||||
// уязвимая: именно в ней будущая правка легче всего протащит текст библиотеки
|
||||
// обратно.
|
||||
func TestLoadConfigMalformedBeforeAnyKeyHidesValue(t *testing.T) {
|
||||
const secret = "123456:AAHsecret-token-value"
|
||||
const secret = "AQVNsecret-api-key-value"
|
||||
// У секции не закрыта скобка: разбор оборвётся, не назвав ни одного ключа.
|
||||
path := writeConfig(t, "[telegram\nenabled = true\nbot_token = \""+secret+"\"\n")
|
||||
path := writeConfig(t, "[yandex\nfolder_id = \"b1g\"\nspeech_kit_api_key = \""+secret+"\"\n")
|
||||
|
||||
_, err := LoadConfig(path)
|
||||
if err == nil {
|
||||
@@ -266,7 +171,7 @@ func TestLoadConfigMalformedBeforeAnyKeyHidesValue(t *testing.T) {
|
||||
}
|
||||
|
||||
message := err.Error()
|
||||
for _, part := range []string{secret, "AAHsecret", "secret-token-value"} {
|
||||
for _, part := range []string{secret, "AQVNsecret", "secret-api-key-value"} {
|
||||
if strings.Contains(message, part) {
|
||||
t.Fatalf("значение ключа доступа уехало в отказ: %v", err)
|
||||
}
|
||||
@@ -284,8 +189,8 @@ workers = 7
|
||||
own_work_limit_minutes = 90
|
||||
foreign_work_limit_minutes = 720
|
||||
|
||||
[telegram]
|
||||
enabled = false
|
||||
[storage]
|
||||
data_dir = "data"
|
||||
`)
|
||||
|
||||
cfg, err := LoadConfig(path)
|
||||
@@ -309,7 +214,7 @@ enabled = false
|
||||
// Умолчания есть у всех трёх чисел: файл без секции конвейера годен, и сервис
|
||||
// поднимается с рабочими значениями.
|
||||
func TestPipelineSettingsHaveDefaults(t *testing.T) {
|
||||
path := writeConfig(t, "[telegram]\nenabled = false\n")
|
||||
path := writeConfig(t, "[storage]\ndata_dir = \"data\"\n")
|
||||
|
||||
cfg, err := LoadConfig(path)
|
||||
if err != nil {
|
||||
|
||||
@@ -58,7 +58,3 @@ type AudioRecognizer interface {
|
||||
// обращаясь к нему. По нему архив пересчитывается без единого рубля.
|
||||
Parse(raw []byte) (*entity.RecognitionOutcome, error)
|
||||
}
|
||||
|
||||
type TelegramMessageSender interface {
|
||||
Send(text string, chatId int64, replyToMessageId *int) error
|
||||
}
|
||||
|
||||
@@ -5,15 +5,6 @@ import (
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// ErrDeliveryChannelDown — канал, которым отвечают отправителю, не поднят.
|
||||
// Отдаётся отправителем-заглушкой, которого получает ядро, когда вход не
|
||||
// настроен.
|
||||
//
|
||||
// Значение сентинельное, а не тип: соседям по ряду есть что нести — состояние,
|
||||
// идентификатор задачи, — а этому нечего. Заглушка не знает ни задачи, ни чата,
|
||||
// и запись о недоставке делает шаг, у которого задача под рукой.
|
||||
var ErrDeliveryChannelDown = errors.New("delivery channel is down")
|
||||
|
||||
// ErrOwnerRequired — приём по HTTP дошёл до заведения задачи, а владельца ему не
|
||||
// назвали. Значение сентинельное: нести отказу нечего, а имя учётной записи в
|
||||
// него не кладётся никогда.
|
||||
@@ -29,9 +20,9 @@ func (e *JobNotFoundError) Error() string {
|
||||
}
|
||||
|
||||
// LostAcquisitionError — захват задачи за время работы шага достался другому.
|
||||
// Шаг, получивший его, завершается без записи результата и без ответа
|
||||
// отправителю: иначе два воркера пишут в одну задачу по очереди, а отправитель
|
||||
// получает два ответа на одну запись.
|
||||
// Шаг, получивший его, завершается без записи результата: иначе два воркера
|
||||
// пишут в одну запись по очереди, портя её результат, и счётчик отказов
|
||||
// сбрасывает тот, кто уже не владелец.
|
||||
type LostAcquisitionError struct {
|
||||
JobID string
|
||||
}
|
||||
|
||||
@@ -44,11 +44,11 @@ type FileRepository interface {
|
||||
// файле. Имя задаёт сервис: умолчание хранилища, строящее его из имени
|
||||
// отправителя, не применяется.
|
||||
//
|
||||
// ownerID — владелец записи, которой файл принадлежит; пустой значит «файл
|
||||
// без владельца», и таков всякий файл записи, принятой ботом. Владелец
|
||||
// лежит своей колонкой, а не выводится через запись: файл переживает свою
|
||||
// запись — шаг заводит его до сохранения, и потерянный захват оставляет файл
|
||||
// с владельцем и без ссылки.
|
||||
// ownerID — владелец записи, которой файл принадлежит, и он обязателен:
|
||||
// колонка владельца пустого значения не принимает, пустой отвергается
|
||||
// схемой. Владелец лежит своей колонкой, а не выводится через запись: файл
|
||||
// переживает свою запись — шаг заводит его до сохранения, и потерянный
|
||||
// захват оставляет файл с владельцем и без ссылки.
|
||||
Create(name string, work WorkFile, meta FileMeta, ownerID string) (*entity.File, error)
|
||||
GetByID(id string) (*entity.File, error)
|
||||
// Open отдаёт содержимое хранимого файла потоком.
|
||||
|
||||
@@ -4,17 +4,13 @@ import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
pbrepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase"
|
||||
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
|
||||
"git.vakhrushev.me/av/transcriber/internal/clock"
|
||||
"git.vakhrushev.me/av/transcriber/internal/contract"
|
||||
"git.vakhrushev.me/av/transcriber/internal/entity"
|
||||
)
|
||||
|
||||
@@ -81,33 +77,6 @@ func TestGetTranscribeJobStatus_ForeignJobLooksMissing(t *testing.T) {
|
||||
assert.NotContains(t, foreign.Body.String(), "transcription_text")
|
||||
}
|
||||
|
||||
// Задача, принятая ботом, владельца не имеет и не достаётся по API никому.
|
||||
func TestGetTranscribeJobStatus_OwnerlessJobLooksMissing(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
fileRepo := pbrepo.NewFileRepository(env.app)
|
||||
work, err := fileRepo.Stage(".ogg", strings.NewReader("запись"))
|
||||
require.NoError(t, err)
|
||||
defer func() { require.NoError(t, work.Close()) }()
|
||||
|
||||
// Файл записи из Telegram владельца тоже не имеет.
|
||||
file, err := fileRepo.Create("voice.ogg", work, contract.FileMeta{Format: "ogg"}, "")
|
||||
require.NoError(t, err)
|
||||
|
||||
job := &entity.AudioRecord{
|
||||
State: entity.StateUploaded,
|
||||
StateEnteredAt: clock.Now(),
|
||||
Source: entity.SourceTelegram,
|
||||
OriginalFileID: &file.Id,
|
||||
}
|
||||
require.NoError(t, env.handler.recordRepo.Create(job))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
env.serve(w, httptest.NewRequest("GET", "/api/status/"+job.Id, http.NoBody))
|
||||
|
||||
assert.Equal(t, http.StatusNotFound, w.Code)
|
||||
}
|
||||
|
||||
// Владельцем принятой записи становится предъявитель сессии — и у задачи, и у
|
||||
// её файла.
|
||||
func TestCreateTranscribeJob_OwnerIsSession(t *testing.T) {
|
||||
|
||||
@@ -60,13 +60,6 @@ type stubConverter struct{}
|
||||
|
||||
func (c *stubConverter) Convert(context.Context, string, string) error { return nil }
|
||||
|
||||
// TestTgSender: приём по HTTP в Telegram не отвечает, но сервису отправитель нужен.
|
||||
type TestTgSender struct{}
|
||||
|
||||
func (s *TestTgSender) Send(msg string, chatId int64, replyMsgId *int) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// readableMetaViewer — источник метаданных, который читает любую запись.
|
||||
func readableMetaViewer() *stubMetaViewer {
|
||||
return &stubMetaViewer{seconds: 42}
|
||||
@@ -192,7 +185,6 @@ func setupTestEnv(t *testing.T, metaviewer contract.AudioMetaViewer) *testEnv {
|
||||
metaviewer,
|
||||
&stubConverter{},
|
||||
&recognizer.MemoryAudioRecognizer{},
|
||||
&TestTgSender{},
|
||||
entity.StuckLimits{Own: time.Hour, Foreign: 24 * time.Hour},
|
||||
logger,
|
||||
)
|
||||
@@ -293,7 +285,7 @@ func jobWithFile(t *testing.T, env *testEnv) *entity.AudioRecord {
|
||||
State: entity.StateUploaded,
|
||||
StateEnteredAt: clock.Now(),
|
||||
Source: entity.SourceApi,
|
||||
OwnerID: &env.account.Id,
|
||||
OwnerID: env.account.Id,
|
||||
OriginalFileID: &file.Id,
|
||||
}
|
||||
require.NoError(t, env.handler.recordRepo.Create(record))
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
package tg
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// probeClient подменяет клиента бота и запоминает, кого спрашивали. Через него
|
||||
// проверяется стык: скачивание обязано идти клиентом бота, а не общим
|
||||
// `http.DefaultClient` — чистку отказа от адреса с токеном несёт именно клиент
|
||||
// (`internal/adapter/telegram`). Подмена на общий клиент правил гейта не
|
||||
// нарушает, поэтому сторожить стык может только проверка.
|
||||
type probeClient struct {
|
||||
seen []string
|
||||
download func(w *probeResponse)
|
||||
}
|
||||
|
||||
type probeResponse struct {
|
||||
status int
|
||||
body string
|
||||
}
|
||||
|
||||
func (c *probeClient) Do(req *http.Request) (*http.Response, error) {
|
||||
c.seen = append(c.seen, req.URL.Path)
|
||||
|
||||
switch {
|
||||
case strings.Contains(req.URL.Path, "/getMe"):
|
||||
return jsonResponse(`{"ok":true,"result":{"id":1,"is_bot":true,"username":"probe_bot"}}`), nil
|
||||
case strings.Contains(req.URL.Path, "/getFile"):
|
||||
return jsonResponse(`{"ok":true,"result":{"file_id":"x","file_path":"voice/file_1.ogg"}}`), nil
|
||||
}
|
||||
|
||||
answer := &probeResponse{status: http.StatusOK, body: "аудио"}
|
||||
if c.download != nil {
|
||||
c.download(answer)
|
||||
}
|
||||
return &http.Response{
|
||||
StatusCode: answer.status,
|
||||
Body: io.NopCloser(strings.NewReader(answer.body)),
|
||||
Header: make(http.Header),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func jsonResponse(body string) *http.Response {
|
||||
header := make(http.Header)
|
||||
header.Set("Content-Type", "application/json")
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Body: io.NopCloser(strings.NewReader(body)),
|
||||
Header: header,
|
||||
}
|
||||
}
|
||||
|
||||
func newProbeController(t *testing.T, client *probeClient) *TelegramController {
|
||||
t.Helper()
|
||||
|
||||
// Клиент подставной, поэтому адрес значения не имеет — важно лишь, что
|
||||
// библиотека соберёт из него разбираемый URL.
|
||||
bot, err := tgbotapi.NewBotAPIWithClient(
|
||||
"7654321:AAHsecretBOTtokenVALUE",
|
||||
"http://telegram.probe/bot%s/%s",
|
||||
client,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
return &TelegramController{
|
||||
bot: bot,
|
||||
logger: slog.New(slog.DiscardHandler),
|
||||
}
|
||||
}
|
||||
|
||||
// Скачивание идёт клиентом бота: иначе отказ пойдёт мимо чистки и унесёт токен.
|
||||
func TestDownloadGoesThroughBotClient(t *testing.T) {
|
||||
client := &probeClient{}
|
||||
controller := newProbeController(t, client)
|
||||
|
||||
body, name, err := controller.downloadAudioFile(t.Context(), "file-id")
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() {
|
||||
if err := body.Close(); err != nil {
|
||||
t.Errorf("не удалось закрыть тело: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
assert.Equal(t, "voice/file_1.ogg", name)
|
||||
require.Len(t, client.seen, 3, "клиент бота видел все обращения: getMe, getFile и скачивание")
|
||||
assert.Contains(t, client.seen[2], "voice/file_1.ogg", "скачивание ушло мимо клиента бота")
|
||||
}
|
||||
|
||||
// Отказ выдачи файла — это не запись: тело такого ответа не должно доехать до
|
||||
// хранилища и умереть на `ffprobe`, уведя диагностику к чужой причине.
|
||||
func TestDownloadRejectsNonOKStatus(t *testing.T) {
|
||||
client := &probeClient{download: func(w *probeResponse) {
|
||||
w.status = http.StatusUnauthorized
|
||||
w.body = `{"ok":false,"error_code":401,"description":"Unauthorized"}`
|
||||
}}
|
||||
controller := newProbeController(t, client)
|
||||
|
||||
body, _, err := controller.downloadAudioFile(t.Context(), "file-id")
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, body, "тело отказа наружу не отдают")
|
||||
assert.Contains(t, err.Error(), "401", "код ответа назван — по нему видно, что отказал Telegram")
|
||||
}
|
||||
@@ -1,326 +0,0 @@
|
||||
package tg
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
// Транспорт знает адаптер Telegram ровно ради единой точки чистки отказа:
|
||||
// второй экземпляр той же функции здесь был бы вторым способом делать одно
|
||||
// и то же, а секрет в журнале — необратим. Направление «транспорт не знает
|
||||
// адаптера» правилом не держится и уже нарушено HTTP-поверхностью
|
||||
// (docs/conventions/go-linters.md, «Что остаётся прозой»).
|
||||
"git.vakhrushev.me/av/transcriber/internal/adapter/telegram"
|
||||
"git.vakhrushev.me/av/transcriber/internal/service"
|
||||
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
|
||||
)
|
||||
|
||||
type TelegramController struct {
|
||||
// deps
|
||||
transcribeService *service.TranscribeService
|
||||
logger *slog.Logger
|
||||
// params
|
||||
bot *tgbotapi.BotAPI
|
||||
userWhiteList []string
|
||||
updateTimeout int
|
||||
}
|
||||
|
||||
type TelegramConfig struct {
|
||||
UpdateTimeout int
|
||||
UserWhiteList []string
|
||||
}
|
||||
|
||||
// NewTelegramController принимает готового клиента, а не токен: клиента заводит
|
||||
// единая точка `internal/adapter/telegram`, и только её отказ не несёт секрета.
|
||||
// Токен сюда не приезжает вовсе — значит, и утечь отсюда ему неоткуда.
|
||||
func NewTelegramController(
|
||||
config TelegramConfig,
|
||||
bot *tgbotapi.BotAPI,
|
||||
transcribeService *service.TranscribeService,
|
||||
logger *slog.Logger,
|
||||
) (*TelegramController, error) {
|
||||
if bot == nil {
|
||||
return nil, errors.New("telegram bot is not created")
|
||||
}
|
||||
|
||||
controller := &TelegramController{
|
||||
bot: bot,
|
||||
transcribeService: transcribeService,
|
||||
logger: logger,
|
||||
updateTimeout: config.UpdateTimeout,
|
||||
userWhiteList: config.UserWhiteList,
|
||||
}
|
||||
|
||||
return controller, nil
|
||||
}
|
||||
|
||||
// Start принимает контекст жизни процесса и отдаёт его каждому обработчику:
|
||||
// скачивание записи и разбор её метаданных — работа с внешним собеседником, и
|
||||
// остановка сервиса обязана до неё доходить. Приём обновлений контекстом не
|
||||
// правится: его прекращает Stop.
|
||||
func (c *TelegramController) Start(ctx context.Context) {
|
||||
c.logger.Info("Telegram bot started", "username", c.bot.Self.UserName)
|
||||
|
||||
u := tgbotapi.NewUpdate(0)
|
||||
u.Timeout = c.updateTimeout
|
||||
|
||||
updates := c.bot.GetUpdatesChan(u)
|
||||
|
||||
for update := range updates {
|
||||
if update.Message == nil { // ignore any non-Message updates
|
||||
continue
|
||||
}
|
||||
|
||||
author := update.Message.From.String()
|
||||
c.logger.Info("New incoming message", "author", author)
|
||||
|
||||
if !slices.Contains(c.userWhiteList, author) {
|
||||
c.logger.Info("User is not in white list, reject", "author", author)
|
||||
c.handleForbiddenUser(update.Message)
|
||||
continue
|
||||
}
|
||||
|
||||
// Handle commands
|
||||
if update.Message.IsCommand() {
|
||||
// Extract the command from the Message
|
||||
switch update.Message.Command() {
|
||||
case "start":
|
||||
c.handleStartCommand(update.Message)
|
||||
case "help":
|
||||
c.handleHelpCommand(update.Message)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Handle audio messages and files
|
||||
if update.Message.Audio != nil {
|
||||
c.handleAudioMessage(ctx, update.Message)
|
||||
} else if update.Message.Voice != nil {
|
||||
c.handleVoiceMessage(ctx, update.Message)
|
||||
} else if update.Message.Document != nil {
|
||||
c.handleDocumentMessage(ctx, update.Message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *TelegramController) Stop() {
|
||||
c.bot.StopReceivingUpdates()
|
||||
}
|
||||
|
||||
func (c *TelegramController) send(chattable tgbotapi.Chattable) (tgbotapi.Message, error) {
|
||||
msg, err := c.bot.Send(chattable)
|
||||
if err != nil {
|
||||
c.logger.Error("Failed to send message to tg bot", "error", err)
|
||||
}
|
||||
return msg, err
|
||||
}
|
||||
|
||||
func (c *TelegramController) handleStartCommand(message *tgbotapi.Message) {
|
||||
msg := tgbotapi.NewMessage(message.Chat.ID, "Привет! Я бот для расшифровки аудиосообщений. Отправь мне голосовое сообщение или аудиофайл, и я пришлю тебе текст.")
|
||||
msg.ReplyToMessageID = message.MessageID
|
||||
|
||||
c.send(msg)
|
||||
}
|
||||
|
||||
func (c *TelegramController) handleForbiddenUser(message *tgbotapi.Message) {
|
||||
msg := tgbotapi.NewMessage(message.Chat.ID, "Извини, тебе нельзя пользоваться этим ботом. Обратись к владельцу бота.")
|
||||
msg.ReplyToMessageID = message.MessageID
|
||||
|
||||
c.send(msg)
|
||||
}
|
||||
|
||||
func (c *TelegramController) handleHelpCommand(message *tgbotapi.Message) {
|
||||
helpText := `Я бот для расшифровки аудиосообщений и аудиофайлов.
|
||||
|
||||
Просто отправь мне:
|
||||
- Голосовое сообщение
|
||||
- Аудиофайл (mp3, wav, ogg и др.)
|
||||
|
||||
Я пришлю тебе текст расшифровки.
|
||||
|
||||
Команды:
|
||||
/start - Начало работы с ботом
|
||||
/help - Показать эту справку`
|
||||
|
||||
msg := tgbotapi.NewMessage(message.Chat.ID, helpText)
|
||||
msg.ReplyToMessageID = message.MessageID
|
||||
|
||||
c.send(msg)
|
||||
}
|
||||
|
||||
func (c *TelegramController) handleAudioMessage(ctx context.Context, message *tgbotapi.Message) {
|
||||
// Отправляем сообщение о начале обработки
|
||||
progressMsg := tgbotapi.NewMessage(message.Chat.ID, "Обрабатываю аудиофайл...")
|
||||
progressMsg.ReplyToMessageID = message.MessageID
|
||||
sentProgressMsg, err := c.send(progressMsg)
|
||||
if err != nil {
|
||||
c.logger.Error("Failed to send progress message", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Скачиваем файл
|
||||
fileReader, fileName, err := c.downloadAudioFile(ctx, message.Audio.FileID)
|
||||
if err != nil {
|
||||
c.logger.Error("Failed to download audio file", "error", err)
|
||||
errorMsg := tgbotapi.NewMessage(message.Chat.ID, "Ошибка при скачивании аудиофайла. Попробуйте еще раз.")
|
||||
c.send(errorMsg)
|
||||
return
|
||||
}
|
||||
defer fileReader.Close()
|
||||
|
||||
// Обрабатываем файл
|
||||
record, err := c.transcribeService.CreateJobFromTelegram(ctx, fileReader, fileName, message.Chat.ID, sentProgressMsg.MessageID)
|
||||
if err != nil {
|
||||
c.logger.Error("Failed to create audio record", "error", err)
|
||||
errorMsg := tgbotapi.NewMessage(message.Chat.ID, "Ошибка при создании задачи на расшифровку. Попробуйте еще раз.")
|
||||
c.send(errorMsg)
|
||||
return
|
||||
}
|
||||
|
||||
// Отправляем сообщение об успешном создании задачи
|
||||
successMsg := tgbotapi.NewMessage(message.Chat.ID, fmt.Sprintf("Задача на расшифровку создана. ID задачи: %s", record.Id))
|
||||
successMsg.ReplyToMessageID = message.MessageID
|
||||
c.send(successMsg)
|
||||
}
|
||||
|
||||
func (c *TelegramController) handleVoiceMessage(ctx context.Context, message *tgbotapi.Message) {
|
||||
// Отправляем сообщение о начале обработки
|
||||
progressMsg := tgbotapi.NewMessage(message.Chat.ID, "Обрабатываю голосовое сообщение...")
|
||||
progressMsg.ReplyToMessageID = message.MessageID
|
||||
sentProgressMsg, err := c.send(progressMsg)
|
||||
if err != nil {
|
||||
c.logger.Error("Failed to send progress message", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Скачиваем файл
|
||||
fileReader, fileName, err := c.downloadAudioFile(ctx, message.Voice.FileID)
|
||||
if err != nil {
|
||||
c.logger.Error("Failed to download voice file", "error", err)
|
||||
errorMsg := tgbotapi.NewMessage(message.Chat.ID, "Ошибка при скачивании голосового сообщения. Попробуйте еще раз.")
|
||||
c.send(errorMsg)
|
||||
return
|
||||
}
|
||||
defer fileReader.Close()
|
||||
|
||||
// Обрабатываем файл
|
||||
record, err := c.transcribeService.CreateJobFromTelegram(ctx, fileReader, fileName, message.Chat.ID, sentProgressMsg.MessageID)
|
||||
if err != nil {
|
||||
c.logger.Error("Failed to create audio record", "error", err)
|
||||
errorMsg := tgbotapi.NewMessage(message.Chat.ID, "Ошибка при создании задачи на расшифровку. Попробуйте еще раз.")
|
||||
c.send(errorMsg)
|
||||
return
|
||||
}
|
||||
|
||||
// Отправляем сообщение об успешном создании задачи
|
||||
successMsg := tgbotapi.NewMessage(message.Chat.ID, fmt.Sprintf("Задача на расшифровку создана. ID задачи: %s", record.Id))
|
||||
successMsg.ReplyToMessageID = message.MessageID
|
||||
c.send(successMsg)
|
||||
}
|
||||
|
||||
func (c *TelegramController) handleDocumentMessage(ctx context.Context, message *tgbotapi.Message) {
|
||||
// Проверяем, является ли документ аудиофайлом
|
||||
if !c.isAudioDocument(message.Document) {
|
||||
return
|
||||
}
|
||||
|
||||
// Отправляем сообщение о начале обработки
|
||||
progressMsg := tgbotapi.NewMessage(message.Chat.ID, "Обрабатываю аудиофайл...")
|
||||
progressMsg.ReplyToMessageID = message.MessageID
|
||||
sentProgressMsg, err := c.send(progressMsg)
|
||||
if err != nil {
|
||||
c.logger.Error("Failed to send progress message", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Скачиваем файл
|
||||
fileReader, fileName, err := c.downloadAudioFile(ctx, message.Document.FileID)
|
||||
if err != nil {
|
||||
c.logger.Error("Failed to download document file", "error", err)
|
||||
errorMsg := tgbotapi.NewMessage(message.Chat.ID, "Ошибка при скачивании аудиофайла. Попробуйте еще раз.")
|
||||
c.send(errorMsg)
|
||||
return
|
||||
}
|
||||
defer fileReader.Close()
|
||||
|
||||
// Обрабатываем файл
|
||||
record, err := c.transcribeService.CreateJobFromTelegram(ctx, fileReader, fileName, message.Chat.ID, sentProgressMsg.MessageID)
|
||||
if err != nil {
|
||||
c.logger.Error("Failed to create audio record", "error", err)
|
||||
errorMsg := tgbotapi.NewMessage(message.Chat.ID, "Ошибка при создании задачи на расшифровку. Попробуйте еще раз.")
|
||||
c.send(errorMsg)
|
||||
return
|
||||
}
|
||||
|
||||
// Отправляем сообщение об успешном создании задачи
|
||||
successMsg := tgbotapi.NewMessage(message.Chat.ID, fmt.Sprintf("Задача на расшифровку создана. ID задачи: %s", record.Id))
|
||||
successMsg.ReplyToMessageID = message.MessageID
|
||||
c.send(successMsg)
|
||||
}
|
||||
|
||||
func (c *TelegramController) downloadAudioFile(ctx context.Context, fileID string) (io.ReadCloser, string, error) {
|
||||
// Получаем информацию о файле
|
||||
file, err := c.bot.GetFile(tgbotapi.FileConfig{FileID: fileID})
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("failed to get file info: %w", err)
|
||||
}
|
||||
|
||||
// Скачиваем файл. Запрос заводится с контекстом: скачивание шестичасовой
|
||||
// записи иначе продолжается и после остановки сервиса, а ссылка на файл
|
||||
// несёт токен бота — держать её живой дольше нужного незачем.
|
||||
//
|
||||
// Клиент берётся у бота, а не `http.DefaultClient`: у бота он свой, и его
|
||||
// отказ уже не несёт адреса (`internal/adapter/telegram`, единая точка).
|
||||
fileURL := file.Link(c.bot.Token)
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, fileURL, nil)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("failed to build download request: %w", telegram.WithoutURL(err))
|
||||
}
|
||||
|
||||
resp, err := c.bot.Client.Do(request)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("failed to download file: %w", err)
|
||||
}
|
||||
|
||||
// Отказ выдачи файла — это не запись. Без проверки телом «записи» станет
|
||||
// JSON вида `{"ok":false,…}`: он доедет до хранилища, ляжет рабочей копией
|
||||
// и умрёт на `ffprobe`, а отправитель получит жалобу на свой файл вместо
|
||||
// правды о протухшей ссылке.
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
if err := resp.Body.Close(); err != nil {
|
||||
c.logger.Error("Failed to close download response", "error", err)
|
||||
}
|
||||
return nil, "", fmt.Errorf("failed to download file: unexpected status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Получаем имя файла из URL
|
||||
fileName := file.FilePath
|
||||
if fileName == "" {
|
||||
fileName = "audio.ogg"
|
||||
}
|
||||
|
||||
return resp.Body, fileName, nil
|
||||
}
|
||||
|
||||
func (c *TelegramController) isAudioDocument(document *tgbotapi.Document) bool {
|
||||
// Проверяем MIME-тип документа
|
||||
if document.MimeType != "" {
|
||||
return strings.HasPrefix(document.MimeType, "audio/") || strings.HasPrefix(document.MimeType, "video/")
|
||||
}
|
||||
|
||||
// Проверяем расширение файла
|
||||
audioExtensions := []string{".mp3", ".wav", ".ogg", ".flac", ".m4a", ".aac", ".wma"}
|
||||
filename := document.FileName
|
||||
for _, ext := range audioExtensions {
|
||||
if len(filename) >= len(ext) && strings.ToLower(filename[len(filename)-len(ext):]) == ext {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
@@ -34,8 +34,11 @@ const (
|
||||
)
|
||||
|
||||
const (
|
||||
SourceUnknown = "unknown"
|
||||
SourceApi = "api"
|
||||
SourceUnknown = "unknown"
|
||||
SourceApi = "api"
|
||||
// SourceTelegram — историческое значение. Вход Telegram убран, новых записей
|
||||
// с этим источником не появляется, а константа остаётся: на неё ссылается
|
||||
// применённый шаг схемы `202608140002`, а применённый шаг не переписывается.
|
||||
SourceTelegram = "telegram"
|
||||
)
|
||||
|
||||
@@ -47,10 +50,11 @@ const (
|
||||
// `texts`, и чтение очереди её не тянет.
|
||||
type AudioRecord struct {
|
||||
Id string
|
||||
// OwnerID — учётная запись, от имени которой запись принята. Пуст у записей
|
||||
// из Telegram: связи чата с учётной записью сервис не ведёт. Назначается
|
||||
// один раз, при приёме, и конвейером не меняется.
|
||||
OwnerID *string
|
||||
// OwnerID — учётная запись, от имени которой запись принята. Обязателен:
|
||||
// колонка владельца пустого значения не принимает, и ничьей записи в
|
||||
// хранилище не бывает. Назначается один раз, при приёме, и конвейером не
|
||||
// меняется.
|
||||
OwnerID string
|
||||
Source string
|
||||
|
||||
// Title и Brief читаются вместе со списком, сотней штук разом, и потому
|
||||
@@ -91,9 +95,6 @@ type AudioRecord struct {
|
||||
LiteraryTextID *string
|
||||
RecognitionID *string
|
||||
|
||||
TgChatId *int64
|
||||
TgReplyMessageId *int
|
||||
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
@@ -10,13 +10,11 @@ const OtherFormatLabel = "other"
|
||||
|
||||
// knownFormats — закрытый перечень расширений, которые допускаются меткой.
|
||||
//
|
||||
// Состав: пути, которые выдаёт Telegram (голосовое приходит как
|
||||
// `voice/file_N.oga`, кружок — с `.mp4`), плюс форматы, доезжающие приёмом по
|
||||
// HTTP, плюс собственное умолчание сервиса на случай имени без расширения.
|
||||
// Списку, по которому бот отбирает **документы** (`isAudioDocument`), перечень
|
||||
// намеренно не равен: тот судит по типу содержимого и своим списком пользуется
|
||||
// лишь когда типа нет, а сюда попадает и то, что приходит другими путями.
|
||||
// Сведение двух списков в один уронило бы основной вход сервиса в `other`.
|
||||
// Состав: форматы, доезжающие приёмом по HTTP, плюс собственное умолчание
|
||||
// сервиса на случай имени без расширения. Значения `oga` и `mp4` достались от
|
||||
// убранного входа Telegram — голосовое приходило оттуда как `voice/file_N.oga`,
|
||||
// кружок с `.mp4`, — и остаются: перечень сужает **метку**, а не приём, и
|
||||
// выброшенное из него значение уронило бы прежние записи в `other`.
|
||||
var knownFormats = map[string]struct{}{
|
||||
"mp3": {},
|
||||
"wav": {},
|
||||
|
||||
@@ -49,9 +49,11 @@ var (
|
||||
[]string{"source_format", "target_format", "error"},
|
||||
)
|
||||
|
||||
// Поднят ли вход приёма. Единственный канал наблюдения, автоматизированный
|
||||
// у владельца: потерянный вход иначе виден только строкой журнала при
|
||||
// старте, а проба здоровья отвечает «ok» и без него.
|
||||
// Поднят ли вход приёма. Метка ставится только тому входу, который у сервиса
|
||||
// есть; вход остался один, и метки убранного здесь не появляется — ноль рядом
|
||||
// с ним читался бы как поломка, а вечная единица — как исправность того, чего
|
||||
// нет. Различать поднятый и неподнятый признак снова станет, когда входов
|
||||
// снова станет больше одного.
|
||||
IntakeUpGauge = promauto.NewGaugeVec(
|
||||
prometheus.GaugeOpts{
|
||||
Name: "transcriber_intake_up",
|
||||
@@ -60,17 +62,6 @@ var (
|
||||
[]string{"channel"},
|
||||
)
|
||||
|
||||
// Ответы, которые не удалось доставить отправителю. Работа при этом
|
||||
// сделана, шаг отказа не объявляет, и без счётчика недоставка видна только
|
||||
// в журнале — до его ротации.
|
||||
UndeliveredReplyCounter = promauto.NewCounterVec(
|
||||
prometheus.CounterOpts{
|
||||
Name: "transcriber_undelivered_reply_count",
|
||||
Help: "Count of replies that could not be delivered to the sender",
|
||||
},
|
||||
[]string{"reason"},
|
||||
)
|
||||
|
||||
// Размер файла после конвертации (в байтах)
|
||||
OutputFileSizeHistogram = promauto.NewHistogramVec(
|
||||
prometheus.HistogramOpts{
|
||||
|
||||
@@ -40,7 +40,7 @@ func (r *stubRecordRepo) FindAndAcquire([]entity.Stage) (*contract.AcquiredRecor
|
||||
func serviceWithRepo(repo contract.AudioRecordRepository) *TranscribeService {
|
||||
return NewTranscribeService(
|
||||
Repositories{Records: repo},
|
||||
nil, nil, nil, nil,
|
||||
nil, nil, nil,
|
||||
entity.StuckLimits{},
|
||||
slog.New(slog.DiscardHandler),
|
||||
)
|
||||
|
||||
@@ -55,7 +55,7 @@ func TestFailureIsCountedWithStageLabel(t *testing.T) {
|
||||
|
||||
beforeOk := stageCount(t, entity.StateUploaded, "false")
|
||||
|
||||
record := newTelegramRecord(t, env)
|
||||
record := newRecord(t, env)
|
||||
require.NoError(t, env.service.RunStep(t.Context()))
|
||||
require.Equal(t, entity.StateNormalized, readRecord(t, env, record.Id).State)
|
||||
|
||||
@@ -66,7 +66,7 @@ func TestFailureIsCountedWithStageLabel(t *testing.T) {
|
||||
failing := newPipelineEnv(t, &okMetaViewer{}, &failingStepConverter{})
|
||||
beforeErr := stageCount(t, entity.StateUploaded, "true")
|
||||
|
||||
newTelegramRecord(t, failing)
|
||||
newRecord(t, failing)
|
||||
require.Error(t, failing.service.RunStep(t.Context()))
|
||||
|
||||
assert.InDelta(t, beforeErr+1, stageCount(t, entity.StateUploaded, "true"), 0,
|
||||
|
||||
@@ -12,9 +12,8 @@ import (
|
||||
)
|
||||
|
||||
// Выборка воркера владельцем не сужается: владелец решает, кому запись
|
||||
// показывать, а не кому её считать. Сужение остановило бы расшифровку записей
|
||||
// бота вовсе, а записи остальных поставило бы в зависимость от того, кто первым
|
||||
// завёл учётную запись.
|
||||
// показывать, а не кому её считать. Сужение поставило бы записи одних людей в
|
||||
// зависимость от того, кто первым завёл учётную запись.
|
||||
func TestWorkerTakesRecordsOfEveryOwner(t *testing.T) {
|
||||
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
|
||||
|
||||
@@ -26,8 +25,8 @@ func TestWorkerTakesRecordsOfEveryOwner(t *testing.T) {
|
||||
strings.NewReader("вторая"), "two.mp3", newOwner(t, env.app))
|
||||
require.NoError(t, err)
|
||||
|
||||
// Третья пришла ботом, и владельца у неё нет вовсе.
|
||||
third := newTelegramRecord(t, env)
|
||||
// Третья — ещё одного владельца: воркер не сужается ни одним из них.
|
||||
third := newRecord(t, env)
|
||||
|
||||
// Захваченная запись остаётся за держателем, и следующий вызов берёт
|
||||
// следующую, а не ту же самую.
|
||||
@@ -40,7 +39,7 @@ func TestWorkerTakesRecordsOfEveryOwner(t *testing.T) {
|
||||
|
||||
assert.True(t, taken[first.Id], "запись первого владельца досталась воркеру")
|
||||
assert.True(t, taken[second.Id], "и второго")
|
||||
assert.True(t, taken[third.Id], "и запись без владельца")
|
||||
assert.True(t, taken[third.Id], "и третьего")
|
||||
|
||||
_, err = env.recordRepo.FindAndAcquire(entity.WorkingStages())
|
||||
var missing *contract.JobNotFoundError
|
||||
@@ -65,8 +64,7 @@ func TestAcquireReturnsIdentifierAndHolder(t *testing.T) {
|
||||
|
||||
// Колонки читаются отдельным чтением, и владелец среди них.
|
||||
read := readRecord(t, env, record.Id)
|
||||
require.NotNil(t, read.OwnerID)
|
||||
assert.Equal(t, owner, *read.OwnerID)
|
||||
assert.Equal(t, owner, read.OwnerID)
|
||||
assert.Equal(t, acquired.Holder, *read.AcquisitionID, "признак захвата записан в саму запись")
|
||||
require.NotNil(t, read.AcquireExpiresAt, "срок протухания приехал с рубежом")
|
||||
}
|
||||
@@ -86,12 +84,12 @@ func TestPipelineStepKeepsOwner(t *testing.T) {
|
||||
|
||||
after := readRecord(t, env, record.Id)
|
||||
require.True(t, after.IsHalted(), "шаг записал свой приговор")
|
||||
require.NotNil(t, after.OwnerID, "владелец пережил шаг")
|
||||
assert.Equal(t, owner, *after.OwnerID)
|
||||
assert.Equal(t, owner, after.OwnerID, "владелец пережил шаг")
|
||||
}
|
||||
|
||||
// Приём из веба без владельца записи не заводит. Обязательность держит здесь
|
||||
// код, а не схема: колонка допускает пустое значение ради записей бота.
|
||||
// Приём без владельца записи не заводит. Отказ стоит здесь раньше схемы: он
|
||||
// отвечает понятной ошибкой до того, как запись ляжет в хранилище, а схема
|
||||
// отказала бы уже после укладки файла.
|
||||
func TestCreateJobFromApiRequiresOwner(t *testing.T) {
|
||||
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
|
||||
|
||||
@@ -110,17 +108,11 @@ func TestGetByIDHidesForeignRecords(t *testing.T) {
|
||||
record, err := env.service.CreateJobFromApi(t.Context(), strings.NewReader("запись"), "one.mp3", owner)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Запись без владельца — принятая ботом.
|
||||
orphan := newTelegramRecord(t, env)
|
||||
|
||||
var missing *contract.JobNotFoundError
|
||||
|
||||
_, err = env.recordRepo.GetByID(record.Id, stranger)
|
||||
require.ErrorAs(t, err, &missing, "чужая запись неотличима от несуществующей")
|
||||
|
||||
_, err = env.recordRepo.GetByID(orphan.Id, stranger)
|
||||
require.ErrorAs(t, err, &missing, "ничья запись не достаётся никому")
|
||||
|
||||
_, err = env.recordRepo.GetByID(record.Id, "")
|
||||
require.ErrorAs(t, err, &missing, "пустой владелец не совпадает ни с чем")
|
||||
|
||||
|
||||
@@ -60,32 +60,12 @@ func (m *failingMetaViewer) GetInfo(context.Context, string) (*contract.AudioInf
|
||||
return nil, errors.New("запись не читается")
|
||||
}
|
||||
|
||||
// recordingSender запоминает, что отправлено.
|
||||
type recordingSender struct {
|
||||
mu sync.Mutex
|
||||
messages []string
|
||||
}
|
||||
|
||||
func (s *recordingSender) Send(text string, chatId int64, replyMsgId *int) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.messages = append(s.messages, text)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *recordingSender) sent() []string {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return append([]string(nil), s.messages...)
|
||||
}
|
||||
|
||||
type pipelineEnv struct {
|
||||
app core.App
|
||||
service *TranscribeService
|
||||
repos Repositories
|
||||
recordRepo *pbrepo.AudioRecordRepository
|
||||
fileRepo *pbrepo.FileRepository
|
||||
sender *recordingSender
|
||||
}
|
||||
|
||||
// testLimits — пределы простоя проверок. Числа боевые; проверка застревания
|
||||
@@ -104,6 +84,19 @@ func newPipelineEnvWith(
|
||||
rec contract.AudioRecognizer,
|
||||
) *pipelineEnv {
|
||||
t.Helper()
|
||||
return newPipelineEnvWithLogger(t, metaviewer, converter, rec, slog.New(slog.DiscardHandler))
|
||||
}
|
||||
|
||||
// newPipelineEnvWithLogger — то же с подменённым журналом: проверке, судящей
|
||||
// строку журнала, нужен свой, а не общий.
|
||||
func newPipelineEnvWithLogger(
|
||||
t *testing.T,
|
||||
metaviewer contract.AudioMetaViewer,
|
||||
converter contract.AudioFileConverter,
|
||||
rec contract.AudioRecognizer,
|
||||
logger *slog.Logger,
|
||||
) *pipelineEnv {
|
||||
t.Helper()
|
||||
|
||||
app, err := pbrepo.New(t.TempDir())
|
||||
require.NoError(t, err)
|
||||
@@ -128,9 +121,7 @@ func newPipelineEnvWith(
|
||||
Recognitions: pbrepo.NewRecognitionRepository(app),
|
||||
Events: pbrepo.NewRecordEventRepository(app),
|
||||
}
|
||||
sender := &recordingSender{}
|
||||
|
||||
svc := NewTranscribeService(repos, metaviewer, converter, rec, sender, testLimits, slog.New(slog.DiscardHandler))
|
||||
svc := NewTranscribeService(repos, metaviewer, converter, rec, testLimits, logger)
|
||||
|
||||
return &pipelineEnv{
|
||||
app: app,
|
||||
@@ -138,15 +129,16 @@ func newPipelineEnvWith(
|
||||
repos: repos,
|
||||
recordRepo: recordRepo,
|
||||
fileRepo: fileRepo,
|
||||
sender: sender,
|
||||
}
|
||||
}
|
||||
|
||||
// newTelegramRecord заводит запись — так, как её завёл бы приём из бота.
|
||||
func newTelegramRecord(t *testing.T, env *pipelineEnv) *entity.AudioRecord {
|
||||
// newRecord заводит запись — так, как её заводит приём по HTTP: от имени
|
||||
// вошедшего, потому что ничьей записи в хранилище не бывает.
|
||||
func newRecord(t *testing.T, env *pipelineEnv) *entity.AudioRecord {
|
||||
t.Helper()
|
||||
|
||||
record, err := env.service.CreateJobFromTelegram(t.Context(), strings.NewReader("запись"), "voice.ogg", 100, 1)
|
||||
record, err := env.service.CreateJobFromApi(
|
||||
t.Context(), strings.NewReader("запись"), "voice.ogg", newOwner(t, env.app))
|
||||
require.NoError(t, err)
|
||||
return record
|
||||
}
|
||||
@@ -175,6 +167,17 @@ func enteredStateAt(t *testing.T, env *pipelineEnv, recordID string, moment time
|
||||
require.NoError(t, env.app.Save(record))
|
||||
}
|
||||
|
||||
// setAttempts ставит записи число отказов: так она выглядит, когда шаг отказал
|
||||
// столько раз подряд, что предел исчерпан.
|
||||
func setAttempts(t *testing.T, env *pipelineEnv, recordID string, attempts int) {
|
||||
t.Helper()
|
||||
|
||||
record, err := env.app.FindRecordById(migrations.RecordsCollection, recordID)
|
||||
require.NoError(t, err)
|
||||
record.Set("attempts", attempts)
|
||||
require.NoError(t, env.app.Save(record))
|
||||
}
|
||||
|
||||
// drain крутит конвейер, пока он двигает записи. Паузы опроса снимаются: они
|
||||
// проверяются отдельно, а здесь мешают дойти до конца.
|
||||
func drain(t *testing.T, env *pipelineEnv, recordID string) {
|
||||
@@ -217,7 +220,7 @@ func readRecord(t *testing.T, env *pipelineEnv, id string) *entity.AudioRecord {
|
||||
func TestRecordHaltsAfterAttemptLimit(t *testing.T) {
|
||||
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
|
||||
|
||||
record := newTelegramRecord(t, env)
|
||||
record := newRecord(t, env)
|
||||
|
||||
// Захват без выполнения шага — так это выглядит при гибели процесса: отказа
|
||||
// шаг объявить не успевает, а попытка засчитана.
|
||||
@@ -239,9 +242,6 @@ func TestRecordHaltsAfterAttemptLimit(t *testing.T) {
|
||||
assert.Equal(t, entity.StateUploaded, after.State, "рубеж пережил остановку")
|
||||
assert.Greater(t, after.Attempts, maxAttempts, "число отказов сохранено")
|
||||
|
||||
require.Len(t, env.sender.sent(), 1, "отправитель узнал о неудаче")
|
||||
assert.Contains(t, env.sender.sent()[0], "попытки исчерпаны")
|
||||
|
||||
// И из выборки она исчезла.
|
||||
_, err = env.recordRepo.FindAndAcquire(entity.WorkingStages())
|
||||
var missing *contract.JobNotFoundError
|
||||
@@ -265,7 +265,7 @@ func expireAcquisition(t *testing.T, env *pipelineEnv, recordID string) {
|
||||
func TestHaltedRecordResumesFromItsStage(t *testing.T) {
|
||||
env := newPipelineEnv(t, &okMetaViewer{}, &okConverter{})
|
||||
|
||||
record := newTelegramRecord(t, env)
|
||||
record := newRecord(t, env)
|
||||
|
||||
// Доводим до рубежа приведения и останавливаем на нём.
|
||||
require.NoError(t, env.service.RunStep(t.Context()))
|
||||
@@ -302,7 +302,7 @@ func TestHaltedRecordResumesFromItsStage(t *testing.T) {
|
||||
func TestBothFileLinksSurvivePipeline(t *testing.T) {
|
||||
env := newPipelineEnv(t, &okMetaViewer{}, &okConverter{})
|
||||
|
||||
record := newTelegramRecord(t, env)
|
||||
record := newRecord(t, env)
|
||||
drain(t, env, record.Id)
|
||||
|
||||
after := readRecord(t, env, record.Id)
|
||||
@@ -327,7 +327,7 @@ func TestBothFileLinksSurvivePipeline(t *testing.T) {
|
||||
func TestOutcomeDoesNotDependOnWorkerCount(t *testing.T) {
|
||||
for _, workers := range []int{1, 4} {
|
||||
env := newPipelineEnv(t, &okMetaViewer{}, &okConverter{})
|
||||
record := newTelegramRecord(t, env)
|
||||
record := newRecord(t, env)
|
||||
|
||||
for range 20 {
|
||||
clearDelay(t, env, record.Id)
|
||||
@@ -361,7 +361,7 @@ func TestOutcomeDoesNotDependOnWorkerCount(t *testing.T) {
|
||||
// Записи принимаются и не двигаются, и это режим, а не поломка.
|
||||
func TestZeroWorkersLeaveRecordUntouched(t *testing.T) {
|
||||
env := newPipelineEnv(t, &okMetaViewer{}, &okConverter{})
|
||||
record := newTelegramRecord(t, env)
|
||||
record := newRecord(t, env)
|
||||
|
||||
// Пул нулевого размера ни одного прогона не делает — потому запись остаётся
|
||||
// там, где её оставил приём.
|
||||
@@ -393,7 +393,7 @@ func TestPostponeKeepsStuckCountdown(t *testing.T) {
|
||||
func TestStuckRecordIsHalted(t *testing.T) {
|
||||
env := newPipelineEnv(t, &okMetaViewer{}, &okConverter{})
|
||||
|
||||
record := newTelegramRecord(t, env)
|
||||
record := newRecord(t, env)
|
||||
enteredStateAt(t, env, record.Id, time.Now().Add(-2*testLimits.Own))
|
||||
|
||||
err := env.service.RunStep(t.Context())
|
||||
@@ -406,8 +406,6 @@ func TestStuckRecordIsHalted(t *testing.T) {
|
||||
assert.Equal(t, entity.HaltReasonStuck, *after.HaltReason, "причина названа")
|
||||
assert.Equal(t, entity.StateUploaded, after.State, "рубеж сохранён")
|
||||
|
||||
require.Len(t, env.sender.sent(), 1, "отправитель узнал о неудаче")
|
||||
assert.Contains(t, env.sender.sent()[0], "застряла")
|
||||
}
|
||||
|
||||
// Конечный рубеж под предел простоя не подпадает: стоять в нём запись будет
|
||||
@@ -415,7 +413,7 @@ func TestStuckRecordIsHalted(t *testing.T) {
|
||||
func TestDoneRecordIsNeverStuck(t *testing.T) {
|
||||
env := newPipelineEnv(t, &okMetaViewer{}, &okConverter{})
|
||||
|
||||
record := newTelegramRecord(t, env)
|
||||
record := newRecord(t, env)
|
||||
drain(t, env, record.Id)
|
||||
enteredStateAt(t, env, record.Id, time.Now().Add(-10*24*time.Hour))
|
||||
|
||||
@@ -434,7 +432,7 @@ func TestDoneRecordIsNeverStuck(t *testing.T) {
|
||||
func TestOnlyHolderWritesResult(t *testing.T) {
|
||||
env := newPipelineEnv(t, &okMetaViewer{}, &okConverter{})
|
||||
|
||||
record := newTelegramRecord(t, env)
|
||||
record := newRecord(t, env)
|
||||
|
||||
first, err := env.recordRepo.FindAndAcquire(entity.WorkingStages())
|
||||
require.NoError(t, err)
|
||||
@@ -457,12 +455,12 @@ func TestOnlyHolderWritesResult(t *testing.T) {
|
||||
|
||||
after := readRecord(t, env, record.Id)
|
||||
assert.Equal(t, entity.StateUploaded, after.State, "рубеж не сдвинут потерявшим захват")
|
||||
assert.Empty(t, env.sender.sent(), "и отправителю от него ничего не ушло")
|
||||
}
|
||||
|
||||
// Критерий приёмки 7. Всякий способ вывести запись из работы сообщает
|
||||
// отправителю: причин остановки больше одной, и обязанность у них общая.
|
||||
func TestEveryHaltReasonNotifiesSender(t *testing.T) {
|
||||
// Критерий приёмки 7. Всякий способ вывести запись из работы оставляет причину
|
||||
// остановки: причин больше одной, и обязанность у них общая. Отправитель узнаёт
|
||||
// исход опросом готовности, а владелец сервиса — журналом событий записи.
|
||||
func TestEveryHaltReasonRecordsItsCause(t *testing.T) {
|
||||
reasons := []struct {
|
||||
name string
|
||||
halt func(t *testing.T, env *pipelineEnv, recordID string)
|
||||
@@ -481,18 +479,40 @@ func TestEveryHaltReasonNotifiesSender(t *testing.T) {
|
||||
require.ErrorAs(t, env.service.RunStep(t.Context()), &noop)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "исчерпанные отказы",
|
||||
halt: func(t *testing.T, env *pipelineEnv, recordID string) {
|
||||
setAttempts(t, env, recordID, maxAttempts+1)
|
||||
var noop *contract.NoopJobError
|
||||
require.ErrorAs(t, env.service.RunStep(t.Context()), &noop)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, reason := range reasons {
|
||||
t.Run(reason.name, func(t *testing.T) {
|
||||
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
|
||||
record := newTelegramRecord(t, env)
|
||||
record := newRecord(t, env)
|
||||
|
||||
reason.halt(t, env, record.Id)
|
||||
|
||||
after := readRecord(t, env, record.Id)
|
||||
require.True(t, after.IsHalted(), "запись остановлена")
|
||||
require.Len(t, env.sender.sent(), 1, "отправитель узнал о неудаче")
|
||||
|
||||
// Признак остановки и причина ставятся одним движением, поэтому
|
||||
// вторым утверждением берётся **журнал событий**: он пишется
|
||||
// отдельной строкой, отдельным сохранением, и упасть может сам по
|
||||
// себе. Прежде эту роль играл счёт ответов отправителю; ответы ушли
|
||||
// вместе с входом Telegram, и без замены проверка вывелась бы из
|
||||
// собственной предыдущей строки.
|
||||
events := recordEvents(t, env, record.Id)
|
||||
require.Len(t, events, 1, "остановка оставила строку журнала событий")
|
||||
|
||||
outcome := events[0].GetString("outcome")
|
||||
assert.Contains(t,
|
||||
[]string{entity.EventOutcomeHalted, entity.EventOutcomeFailed}, outcome,
|
||||
"строка журнала называет исход остановкой")
|
||||
assert.NotEmpty(t, events[0].GetString("outcome_text"), "и несёт причину")
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -503,7 +523,7 @@ func TestEveryHaltReasonNotifiesSender(t *testing.T) {
|
||||
func TestRepeatedStoreKeepsSingleText(t *testing.T) {
|
||||
env := newPipelineEnv(t, &okMetaViewer{}, &okConverter{})
|
||||
|
||||
record := newTelegramRecord(t, env)
|
||||
record := newRecord(t, env)
|
||||
|
||||
first, err := env.repos.Texts.Put(record.Id, entity.TextKindTranscript, "первый разбор")
|
||||
require.NoError(t, err)
|
||||
@@ -535,7 +555,7 @@ func TestRetryDelayGrowsAndCaps(t *testing.T) {
|
||||
func TestFailedStepSchedulesRetryWithGrowingDelay(t *testing.T) {
|
||||
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
|
||||
|
||||
record := newTelegramRecord(t, env)
|
||||
record := newRecord(t, env)
|
||||
|
||||
// Ссылка переставляется на запись о файле без содержимого: шаг отказывает на
|
||||
// получении рабочей копии — то есть отказом, а не приговором записи.
|
||||
@@ -544,6 +564,8 @@ func TestFailedStepSchedulesRetryWithGrowingDelay(t *testing.T) {
|
||||
empty := core.NewRecord(files)
|
||||
empty.Set("location", entity.LocationLocal)
|
||||
empty.Set("size", 1)
|
||||
// Владелец обязателен и у файла: схема ничьих не принимает.
|
||||
empty.Set("owner", record.OwnerID)
|
||||
require.NoError(t, env.app.Save(empty))
|
||||
|
||||
stored, err := env.app.FindRecordById(migrations.RecordsCollection, record.Id)
|
||||
@@ -606,7 +628,7 @@ func TestWorkFilesRemovedAfterConversionFailure(t *testing.T) {
|
||||
|
||||
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
|
||||
|
||||
newTelegramRecord(t, env)
|
||||
newRecord(t, env)
|
||||
|
||||
leftovers, err := filepath.Glob(filepath.Join(tempDir, "transcriber-*"))
|
||||
require.NoError(t, err)
|
||||
@@ -624,7 +646,7 @@ func TestWorkFilesRemovedAfterConversionFailure(t *testing.T) {
|
||||
func TestRecordNeverPointsToMissingFile(t *testing.T) {
|
||||
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
|
||||
|
||||
record := newTelegramRecord(t, env)
|
||||
record := newRecord(t, env)
|
||||
|
||||
require.NoError(t, env.service.RunStep(t.Context()))
|
||||
|
||||
@@ -714,7 +736,7 @@ func TestHaltIsCountedAsFailureAndLoggedOnce(t *testing.T) {
|
||||
beforeOk := stageCount(t, entity.StateUploaded, "false")
|
||||
beforeErr := stageCount(t, entity.StateUploaded, "true")
|
||||
|
||||
record := newTelegramRecord(t, env)
|
||||
record := newRecord(t, env)
|
||||
require.NoError(t, env.service.RunStep(t.Context()))
|
||||
|
||||
after := readRecord(t, env, record.Id)
|
||||
@@ -739,7 +761,7 @@ func TestPostponeWritesNoEvent(t *testing.T) {
|
||||
rec.inProgress.Store(5)
|
||||
env := newPipelineEnvWith(t, &okMetaViewer{}, &okConverter{}, rec)
|
||||
|
||||
record := newTelegramRecord(t, env)
|
||||
record := newRecord(t, env)
|
||||
require.NoError(t, env.service.RunStep(t.Context())) // приведение
|
||||
require.NoError(t, env.service.RunStep(t.Context())) // отправка
|
||||
require.Equal(t, entity.StateSubmitted, readRecord(t, env, record.Id).State)
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
@@ -101,7 +103,7 @@ func TestStructureIsBuiltFromStoredPayload(t *testing.T) {
|
||||
rec := &countingRecognizer{}
|
||||
env := newPipelineEnvWith(t, &okMetaViewer{}, &okConverter{}, rec)
|
||||
|
||||
record := newTelegramRecord(t, env)
|
||||
record := newRecord(t, env)
|
||||
drain(t, env, record.Id)
|
||||
|
||||
after := readRecord(t, env, record.Id)
|
||||
@@ -141,7 +143,7 @@ func TestPaidWorkIsNotRepeated(t *testing.T) {
|
||||
rec := &countingRecognizer{}
|
||||
env := newPipelineEnvWith(t, &okMetaViewer{}, &okConverter{}, rec)
|
||||
|
||||
record := newTelegramRecord(t, env)
|
||||
record := newRecord(t, env)
|
||||
|
||||
// Приведение.
|
||||
require.NoError(t, env.service.RunStep(t.Context()))
|
||||
@@ -171,7 +173,7 @@ func TestPollingPostponesWithoutSpendingAttempts(t *testing.T) {
|
||||
rec.inProgress.Store(3)
|
||||
env := newPipelineEnvWith(t, &okMetaViewer{}, &okConverter{}, rec)
|
||||
|
||||
record := newTelegramRecord(t, env)
|
||||
record := newRecord(t, env)
|
||||
|
||||
require.NoError(t, env.service.RunStep(t.Context())) // приведение
|
||||
require.NoError(t, env.service.RunStep(t.Context())) // отправка
|
||||
@@ -195,3 +197,88 @@ func TestPollingPostponesWithoutSpendingAttempts(t *testing.T) {
|
||||
|
||||
assert.Len(t, delays, 3, "все три прогона отложили работу")
|
||||
}
|
||||
|
||||
// emptyRecognizer отдаёт готовую операцию с пустым результатом: так выглядит
|
||||
// оплаченное распознавание, из которого ничего не вышло.
|
||||
type emptyRecognizer struct {
|
||||
countingRecognizer
|
||||
}
|
||||
|
||||
func (r *emptyRecognizer) Fetch(context.Context, string) (*entity.RecognitionOutcome, error) {
|
||||
r.fetches.Add(1)
|
||||
return &entity.RecognitionOutcome{}, nil
|
||||
}
|
||||
|
||||
// Пустой результат виден владельцу сервиса записью журнала «может стать
|
||||
// проблемой». Прежде этот случай был виден ответом отправителю — «на записи нет
|
||||
// текста», — и вместе с убранной доставкой он исчез бы вовсе: запись доходит до
|
||||
// конца молча и от успешной не отличается. Текста в записи нет по инварианту
|
||||
// приватности: пустой ему взяться неоткуда, а проверка судит уровень и
|
||||
// идентификатор.
|
||||
func TestEmptyRecognitionIsNamedInJournal(t *testing.T) {
|
||||
journal := &bytes.Buffer{}
|
||||
|
||||
env := newPipelineEnvWithLogger(t, &okMetaViewer{}, &okConverter{}, &emptyRecognizer{},
|
||||
slog.New(slog.NewTextHandler(journal, &slog.HandlerOptions{Level: slog.LevelDebug})))
|
||||
|
||||
record := newRecord(t, env)
|
||||
drain(t, env, record.Id)
|
||||
|
||||
written := journal.String()
|
||||
assert.Contains(t, written, "Recognition returned empty text", "пустой результат назван")
|
||||
assert.Contains(t, written, "level=WARN", "уровень — «может стать проблемой»")
|
||||
assert.Contains(t, written, record.Id, "запись названа идентификатором")
|
||||
}
|
||||
|
||||
// exhaustibleRecognizer отдаёт полный результат один раз, а на всяком следующем
|
||||
// обращении — пустой: так выглядит провайдер, чей поток закрылся на первом же
|
||||
// ответе. Отказом это не считается ни у него, ни у нас.
|
||||
type exhaustibleRecognizer struct {
|
||||
countingRecognizer
|
||||
served atomic.Bool
|
||||
}
|
||||
|
||||
func (r *exhaustibleRecognizer) Fetch(context.Context, string) (*entity.RecognitionOutcome, error) {
|
||||
r.fetches.Add(1)
|
||||
if r.served.Swap(true) {
|
||||
return &entity.RecognitionOutcome{}, nil
|
||||
}
|
||||
return r.Parse(countingPayload(t0Replicas))
|
||||
}
|
||||
|
||||
// Повторный опрос той же операции — обычное дело: держатель захвата умер,
|
||||
// сохранение рубежа отказало, человек снял остановку в панели. Пустой ответ
|
||||
// провайдера при этом не должен стирать уже сохранённую расшифровку: сервис
|
||||
// объявлен архивом, а восстановления у стёртого текста нет.
|
||||
func TestEmptySecondAnswerKeepsArchivedText(t *testing.T) {
|
||||
rec := &exhaustibleRecognizer{}
|
||||
env := newPipelineEnvWith(t, &okMetaViewer{}, &okConverter{}, rec)
|
||||
|
||||
record := newRecord(t, env)
|
||||
drain(t, env, record.Id)
|
||||
|
||||
stored := readRecord(t, env, record.Id)
|
||||
require.NotNil(t, stored.TranscriptTextID, "расшифровка сохранена первым ответом")
|
||||
|
||||
before, err := env.repos.Texts.GetByID(*stored.TranscriptTextID)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, before.Contents)
|
||||
|
||||
// Второй опрос той же записи: рубеж возвращается на отправленный, и шаг
|
||||
// забирает результат заново — теперь пустой.
|
||||
back := readRecord(t, env, record.Id)
|
||||
back.MoveToState(entity.StateSubmitted)
|
||||
require.NoError(t, env.recordRepo.Save(back, ""))
|
||||
clearDelay(t, env, record.Id)
|
||||
require.NoError(t, env.service.RunStep(t.Context()))
|
||||
|
||||
// Проверка обязана дойти до второго ответа: иначе она зеленела бы, ничего не
|
||||
// проверив, — а класс «проверка, не способная упасть» в этом проекте ловили
|
||||
// уже трижды.
|
||||
require.EqualValues(t, 2, rec.fetches.Load(), "результат забирали дважды")
|
||||
|
||||
after, err := env.repos.Texts.GetByID(*stored.TranscriptTextID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, before.Contents, after.Contents,
|
||||
"пустой ответ провайдера не стирает сохранённую расшифровку")
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ func TestShutdownDuringConversionKeepsRecordRetryable(t *testing.T) {
|
||||
|
||||
converter := &killedConverter{cancel: cancel}
|
||||
env := newPipelineEnv(t, &okMetaViewer{}, converter)
|
||||
record := newTelegramRecord(t, env)
|
||||
record := newRecord(t, env)
|
||||
|
||||
err := env.service.RunStep(ctx)
|
||||
|
||||
@@ -51,14 +51,13 @@ func TestShutdownDuringConversionKeepsRecordRetryable(t *testing.T) {
|
||||
assert.Nil(t, after.AcquisitionID, "захват снят: запись возьмёт следующий прогон")
|
||||
assert.Equal(t, 0, after.Attempts, "остановка отказа не тратит")
|
||||
assert.Nil(t, after.ErrorText)
|
||||
assert.Empty(t, env.sender.sent(), "отправителю о несуществующем сбое не сообщают")
|
||||
}
|
||||
|
||||
// Запись, которую шаг не успел взять, потому что нас уже остановили, остаётся
|
||||
// нетронутой: захват не случился, отказ не потрачен.
|
||||
func TestShutdownBeforeStepLeavesRecordUntouched(t *testing.T) {
|
||||
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
|
||||
record := newTelegramRecord(t, env)
|
||||
record := newRecord(t, env)
|
||||
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
cancel()
|
||||
|
||||
+45
-139
@@ -66,7 +66,6 @@ type TranscribeService struct {
|
||||
metaviewer contract.AudioMetaViewer
|
||||
converter contract.AudioFileConverter
|
||||
recognizer contract.AudioRecognizer
|
||||
tgSender contract.TelegramMessageSender
|
||||
limits entity.StuckLimits
|
||||
logger *slog.Logger
|
||||
}
|
||||
@@ -76,7 +75,6 @@ func NewTranscribeService(
|
||||
metaviewer contract.AudioMetaViewer,
|
||||
converter contract.AudioFileConverter,
|
||||
recognizer contract.AudioRecognizer,
|
||||
tgSender contract.TelegramMessageSender,
|
||||
limits entity.StuckLimits,
|
||||
logger *slog.Logger,
|
||||
) *TranscribeService {
|
||||
@@ -88,7 +86,6 @@ func NewTranscribeService(
|
||||
metaviewer: metaviewer,
|
||||
converter: converter,
|
||||
recognizer: recognizer,
|
||||
tgSender: tgSender,
|
||||
limits: limits,
|
||||
logger: logger,
|
||||
}
|
||||
@@ -134,25 +131,14 @@ func (s *TranscribeService) stepFor(state string) (string, step, bool) {
|
||||
}
|
||||
}
|
||||
|
||||
func (s *TranscribeService) CreateJobFromTelegram(ctx context.Context, file io.Reader, fileName string, chatId int64, replyMsgId int) (*entity.AudioRecord, error) {
|
||||
record := &entity.AudioRecord{
|
||||
State: entity.StateUploaded,
|
||||
Source: entity.SourceTelegram,
|
||||
TgChatId: &chatId,
|
||||
TgReplyMessageId: &replyMsgId,
|
||||
}
|
||||
|
||||
return s.createRecord(ctx, record, file, fileName)
|
||||
}
|
||||
|
||||
// CreateJobFromApi заводит запись от имени вошедшего. Владелец обязателен:
|
||||
// пустой отвергается здесь, потому что колонка владельца допускает пустое
|
||||
// значение ради записей бота, и приём по HTTP — то место, где обязательность
|
||||
// держится.
|
||||
// CreateJobFromApi заводит запись от имени вошедшего. Владелец обязателен, и
|
||||
// обязательность эту держит схема хранилища: колонка владельца пустого значения
|
||||
// не принимает. Отказ стоит и здесь, раньше схемы, потому что отвечает
|
||||
// отправителю понятной ошибкой до того, как запись ляжет в хранилище: схема
|
||||
// отказала бы уже после укладки файла, а уборки файлов сервис не умеет.
|
||||
//
|
||||
// Отказ этот — последний рубеж, а не первый: предъявителя без учётной записи
|
||||
// пользователя транспорт отвергает раньше, до чтения тела. Здесь он остаётся на
|
||||
// случай нового вызывающего, который такой проверки не поставит.
|
||||
// Первый рубеж при этом ещё раньше: предъявителя без учётной записи пользователя
|
||||
// транспорт отвергает до чтения тела.
|
||||
func (s *TranscribeService) CreateJobFromApi(ctx context.Context, file io.Reader, fileName, ownerID string) (*entity.AudioRecord, error) {
|
||||
if ownerID == "" {
|
||||
s.logger.Error("Refusing to create record without owner")
|
||||
@@ -162,7 +148,7 @@ func (s *TranscribeService) CreateJobFromApi(ctx context.Context, file io.Reader
|
||||
record := &entity.AudioRecord{
|
||||
State: entity.StateUploaded,
|
||||
Source: entity.SourceApi,
|
||||
OwnerID: &ownerID,
|
||||
OwnerID: ownerID,
|
||||
}
|
||||
|
||||
return s.createRecord(ctx, record, file, fileName)
|
||||
@@ -212,7 +198,7 @@ func (s *TranscribeService) createRecord(ctx context.Context, r *entity.AudioRec
|
||||
DurationMs: int64(info.Seconds) * 1000,
|
||||
}
|
||||
|
||||
fileRecord, err := s.repos.Files.Create(storageFileName, work, meta, ownerOf(r))
|
||||
fileRecord, err := s.repos.Files.Create(storageFileName, work, meta, r.OwnerID)
|
||||
if err != nil {
|
||||
s.logger.Error("Failed to create file record", "error", err, "file_ext", ext)
|
||||
return nil, err
|
||||
@@ -253,8 +239,8 @@ func (s *TranscribeService) createRecord(ctx context.Context, r *entity.AudioRec
|
||||
//
|
||||
// Контекст доходит до шага, а через него — до внешнего собеседника: остановка
|
||||
// сервиса убивает `ffmpeg` и обрывает запрос к распознаванию. Прерванный шаг
|
||||
// приговора не выносит: запись остаётся пригодной к повтору, отказа не тратит и
|
||||
// отправителю о несуществующем сбое не сообщает.
|
||||
// приговора не выносит: запись остаётся пригодной к повтору и отказа не
|
||||
// тратит.
|
||||
func (s *TranscribeService) RunStep(ctx context.Context) error {
|
||||
// Нас уже остановили — запись не забираем: захват стоил бы ей отказа, а
|
||||
// работы всё равно не будет. Исход «шаг не сделал ничего» — это
|
||||
@@ -275,8 +261,7 @@ func (s *TranscribeService) RunStep(ctx context.Context) error {
|
||||
// таблицы. Молчать нельзя: запись выпала бы из работы без единого следа.
|
||||
s.logger.Error("No step declared for state", "record_id", record.Id, "state", record.State)
|
||||
s.halt(record, holder, record.State, entity.HaltReasonStepFailed,
|
||||
fmt.Sprintf("no step for state %s", record.State),
|
||||
"сервис не знает, что делать с этой записью")
|
||||
fmt.Sprintf("no step for state %s", record.State))
|
||||
return &contract.NoopJobError{State: record.State}
|
||||
}
|
||||
|
||||
@@ -347,8 +332,7 @@ func (s *TranscribeService) acquire() (*entity.AudioRecord, string, error) {
|
||||
s.logger.Error("Record exhausted its attempts",
|
||||
"record_id", record.Id, "state", record.State, "attempts", record.Attempts)
|
||||
s.halt(record, acquired.Holder, record.State, entity.HaltReasonAttempts,
|
||||
fmt.Sprintf("attempts exhausted: %d", record.Attempts),
|
||||
"попытки исчерпаны")
|
||||
fmt.Sprintf("attempts exhausted: %d", record.Attempts))
|
||||
return nil, "", &contract.NoopJobError{State: record.State}
|
||||
}
|
||||
|
||||
@@ -360,8 +344,7 @@ func (s *TranscribeService) acquire() (*entity.AudioRecord, string, error) {
|
||||
"record_id", record.Id, "state", record.State,
|
||||
"state_entered_at", record.StateEnteredAt)
|
||||
s.halt(record, acquired.Holder, record.State, entity.HaltReasonStuck,
|
||||
fmt.Sprintf("stuck in %s", record.State),
|
||||
"обработка застряла")
|
||||
fmt.Sprintf("stuck in %s", record.State))
|
||||
return nil, "", &contract.NoopJobError{State: record.State}
|
||||
}
|
||||
|
||||
@@ -391,7 +374,7 @@ func (s *TranscribeService) normalize(ctx context.Context, r *entity.AudioRecord
|
||||
|
||||
if r.OriginalFileID == nil {
|
||||
s.logger.Error("Record has no original file", "record_id", r.Id)
|
||||
return s.failStep(r, holder, stepNormalize, errors.New("record has no original file"), "у записи нет файла")
|
||||
return s.failStep(r, holder, stepNormalize, errors.New("record has no original file"))
|
||||
}
|
||||
|
||||
srcFile, err := s.repos.Files.GetByID(*r.OriginalFileID)
|
||||
@@ -441,7 +424,7 @@ func (s *TranscribeService) normalize(ctx context.Context, r *entity.AudioRecord
|
||||
|
||||
s.logger.Error("File conversion failed",
|
||||
"error", err, "record_id", r.Id, "duration", conversionDuration)
|
||||
return s.failStep(r, holder, stepNormalize, err, "сбой конвертации файла")
|
||||
return s.failStep(r, holder, stepNormalize, err)
|
||||
}
|
||||
|
||||
destSize, err := dest.Size()
|
||||
@@ -457,7 +440,7 @@ func (s *TranscribeService) normalize(ctx context.Context, r *entity.AudioRecord
|
||||
|
||||
destFileName := fmt.Sprintf("%s%s", uuid.NewString(), ".ogg")
|
||||
destMeta := contract.FileMeta{Format: "ogg", DurationMs: srcFile.DurationMs}
|
||||
destFileRecord, err := s.repos.Files.Create(destFileName, dest, destMeta, ownerOf(r))
|
||||
destFileRecord, err := s.repos.Files.Create(destFileName, dest, destMeta, r.OwnerID)
|
||||
if err != nil {
|
||||
s.logger.Error("Failed to create normalized file record", "error", err, "record_id", r.Id)
|
||||
return outcomeDone, err
|
||||
@@ -489,7 +472,7 @@ func (s *TranscribeService) submit(ctx context.Context, r *entity.AudioRecord, h
|
||||
|
||||
if r.NormalizedFileID == nil {
|
||||
s.logger.Error("Record has no normalized file", "record_id", r.Id)
|
||||
return s.failStep(r, holder, stepSubmit, errors.New("record has no normalized file"), "у записи нет приведённого файла")
|
||||
return s.failStep(r, holder, stepSubmit, errors.New("record has no normalized file"))
|
||||
}
|
||||
|
||||
fileRecord, err := s.repos.Files.GetByID(*r.NormalizedFileID)
|
||||
@@ -646,7 +629,7 @@ func (s *TranscribeService) uploadSource(
|
||||
func (s *TranscribeService) poll(ctx context.Context, r *entity.AudioRecord, holder string) (stepOutcome, error) {
|
||||
if r.RecognitionID == nil {
|
||||
s.logger.Error("Record has no recognition attempt", "record_id", r.Id)
|
||||
return s.failStep(r, holder, stepPoll, errors.New("record has no recognition attempt"), "сведений о распознавании нет")
|
||||
return s.failStep(r, holder, stepPoll, errors.New("record has no recognition attempt"))
|
||||
}
|
||||
|
||||
attempt, err := s.repos.Recognitions.GetByID(*r.RecognitionID)
|
||||
@@ -656,7 +639,7 @@ func (s *TranscribeService) poll(ctx context.Context, r *entity.AudioRecord, hol
|
||||
}
|
||||
if attempt.ExternalID == "" {
|
||||
s.logger.Error("Recognition attempt has no operation id", "record_id", r.Id)
|
||||
return s.failStep(r, holder, stepPoll, errors.New("recognition attempt has no operation id"), "сведений о распознавании нет")
|
||||
return s.failStep(r, holder, stepPoll, errors.New("recognition attempt has no operation id"))
|
||||
}
|
||||
|
||||
// Опрос идёт раз в несколько секунд всё время распознавания: часовая запись
|
||||
@@ -689,7 +672,7 @@ func (s *TranscribeService) poll(ctx context.Context, r *entity.AudioRecord, hol
|
||||
errorText := result.GetError()
|
||||
s.logger.Error("Operation failed",
|
||||
"record_id", r.Id, "operation_id", attempt.ExternalID, "error_message", errorText)
|
||||
return s.failStep(r, holder, stepPoll, errors.New(errorText), "сбой при распознавании файла")
|
||||
return s.failStep(r, holder, stepPoll, errors.New(errorText))
|
||||
}
|
||||
|
||||
outcome, err := s.recognizer.Fetch(ctx, attempt.ExternalID)
|
||||
@@ -708,6 +691,17 @@ func (s *TranscribeService) poll(ctx context.Context, r *entity.AudioRecord, hol
|
||||
"text_length", len(outcome.PlainText),
|
||||
"replicas", len(outcome.Replicas))
|
||||
|
||||
// Пустой результат — оплаченная наружу операция, из которой ничего не
|
||||
// вышло. Прежде этот случай был виден ответом отправителю («на записи нет
|
||||
// текста»), и вместе с доставкой видимость исчезла бы вовсе: запись дошла бы
|
||||
// до конца молча и от успешной не отличалась. Уровень — «может стать
|
||||
// проблемой»: конвенция журнала называет пустой текст распознавания
|
||||
// поимённым примером. Текста в записи нет по инварианту приватности, только
|
||||
// длина.
|
||||
if len(outcome.PlainText) == 0 && len(outcome.Replicas) == 0 {
|
||||
s.logger.Warn("Recognition returned empty text", "record_id", r.Id, "operation_id", attempt.ExternalID)
|
||||
}
|
||||
|
||||
// Сырой ответ сохраняется целиком: результат операции у провайдера не
|
||||
// переспрашивается, и когда мы научимся размечать говорящих, архив
|
||||
// пересчитается из сохранённого без единого рубля.
|
||||
@@ -749,55 +743,42 @@ func (s *TranscribeService) storeOutcome(r *entity.AudioRecord, outcome *entity.
|
||||
return nil
|
||||
}
|
||||
|
||||
// finish отвечает отправителю и доводит запись до конечного рубежа. Доставка —
|
||||
// хвост последнего шага, а не отдельный узел конвейера.
|
||||
// finish доводит запись до конечного рубежа. Наружу шаг не обращается: доставки
|
||||
// ответа отправителю у сервиса нет, и свой исход отправитель узнаёт опросом
|
||||
// готовности.
|
||||
func (s *TranscribeService) finish(ctx context.Context, r *entity.AudioRecord, holder string) (stepOutcome, error) {
|
||||
text := "Ой, кажется, на аудиозаписи нет текста."
|
||||
if r.TranscriptTextID != nil {
|
||||
stored, err := s.repos.Texts.GetByID(*r.TranscriptTextID)
|
||||
if err != nil {
|
||||
s.logger.Error("Failed to read transcript", "error", err, "record_id", r.Id)
|
||||
return outcomeDone, err
|
||||
}
|
||||
if stored.Contents != "" {
|
||||
text = stored.Contents
|
||||
}
|
||||
}
|
||||
|
||||
r.MoveToState(entity.StateDone)
|
||||
if err := s.repos.Records.Save(r, holder); err != nil {
|
||||
s.logger.Error("Failed to save record", "error", err, "record_id", r.Id)
|
||||
return outcomeDone, err
|
||||
}
|
||||
|
||||
return outcomeDone, s.send(r, text)
|
||||
return outcomeDone, nil
|
||||
}
|
||||
|
||||
// failStep останавливает запись приговором шага и сообщает отправителю.
|
||||
// failStep останавливает запись приговором шага.
|
||||
//
|
||||
// Исход возвращается **явно**: остановка отказом шага не является — шаг
|
||||
// рассудил об этой записи окончательно, — но и работой она не была, и
|
||||
// засчитывать её успехом нельзя.
|
||||
func (s *TranscribeService) failStep(r *entity.AudioRecord, holder, step string, stepErr error, humanText string) (stepOutcome, error) {
|
||||
s.halt(r, holder, step, entity.HaltReasonStepFailed, stepErr.Error(),
|
||||
fmt.Sprintf("При обработке записи произошла ошибка: %s", humanText))
|
||||
func (s *TranscribeService) failStep(r *entity.AudioRecord, holder, step string, stepErr error) (stepOutcome, error) {
|
||||
s.halt(r, holder, step, entity.HaltReasonStepFailed, stepErr.Error())
|
||||
return outcomeHalted, nil
|
||||
}
|
||||
|
||||
// halt ставит признак остановки, считает её отказом, пишет строку журнала
|
||||
// событий и сообщает отправителю.
|
||||
// halt ставит признак остановки, считает её отказом и пишет строку журнала
|
||||
// событий.
|
||||
//
|
||||
// Сообщение уходит при **любой** причине остановки: инвариант проекта «Принятая
|
||||
// запись не теряется молча» допускает два исхода — запись пригодна к повтору
|
||||
// либо об отказе сказано, — а остановленная запись захвату не выдаётся, значит
|
||||
// первый исход исключён.
|
||||
// Отправителю отсюда ничего не уходит: инвариант проекта «Принятая запись не
|
||||
// теряется молча» держится теперь опросом готовности — остановка видна там
|
||||
// признаком — и журналом владельца, где у неё стоит причина.
|
||||
//
|
||||
// Счётчик растит **сама остановка**, а не воркер, и это не стилистика.
|
||||
// Остановка по сторожам наступает в захвате, до всякого шага, и воркер о ней
|
||||
// узнаёт признаком «работы нет» — а считать его в метрику запрещено инвариантом
|
||||
// «`NoopJobError` — не ошибка». Значит единственное место, где известны и факт
|
||||
// остановки, и рубеж, — здесь.
|
||||
func (s *TranscribeService) halt(r *entity.AudioRecord, holder, step, reason, errText, humanText string) {
|
||||
func (s *TranscribeService) halt(r *entity.AudioRecord, holder, step, reason, errText string) {
|
||||
// Рубеж читается до остановки: она его не двигает, но читать состояние после
|
||||
// мутации — привычка, из-за которой метка счётчика уже однажды разъехалась.
|
||||
stage := r.State
|
||||
@@ -823,8 +804,6 @@ func (s *TranscribeService) halt(r *entity.AudioRecord, holder, step, reason, er
|
||||
outcome = entity.EventOutcomeFailed
|
||||
}
|
||||
s.appendEvent(r, entity.EventOriginPipeline, step, outcome, reason, 0)
|
||||
|
||||
s.notify(r, humanText+"\nПожалуйста, попробуйте еще раз.")
|
||||
}
|
||||
|
||||
// scheduleRetry снимает захват с отказавшей записи и ставит нарастающую паузу.
|
||||
@@ -885,69 +864,6 @@ func (s *TranscribeService) appendEvent(r *entity.AudioRecord, origin, step, out
|
||||
}
|
||||
}
|
||||
|
||||
// send отвечает отправителю там, откуда пришла запись. Отказ отправки поднимает
|
||||
// вверх: он принадлежит шагу.
|
||||
//
|
||||
// Кроме недоставки — её шаг записывает и завершается без отказа. Ответ уходит
|
||||
// после того, как достигнутый рубеж сохранён: работа к этой минуте сделана, и
|
||||
// объявленный отказ засчитался бы воркеру сбоем и лёг бы владельцу записью
|
||||
// отказа. Повтор делу не помогает — ни бот, ни адресат от ожидания не появятся,
|
||||
// — поэтому причина недоставки живёт в журнале, а не в рубеже записи.
|
||||
func (s *TranscribeService) send(r *entity.AudioRecord, text string) error {
|
||||
if r.Source != entity.SourceTelegram {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Адресата у записи нет: отвечать некуда, и повторять нечего. Уровень здесь
|
||||
// выше, чем у неподнятого канала, и это не педантизм: пустой чат у записи
|
||||
// из Telegram — симптом порчи записи.
|
||||
if r.TgChatId == nil {
|
||||
s.undelivered(r, slog.LevelError, "chat is not specified")
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := s.tgSender.Send(text, *r.TgChatId, r.TgReplyMessageId); err != nil {
|
||||
// Канал не поднят: сервис работает без этого входа, и это объявленный
|
||||
// режим, а не поломка.
|
||||
if errors.Is(err, contract.ErrDeliveryChannelDown) {
|
||||
s.undelivered(r, slog.LevelWarn, "delivery channel is down")
|
||||
return nil
|
||||
}
|
||||
|
||||
s.logger.Error("Failed to sent message to client", "record_id", r.Id)
|
||||
return fmt.Errorf("failed to sent message to client, record id: %s, err: %w", r.Id, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// undelivered записывает недоставленный ответ и считает его в метрику. Уровень
|
||||
// приходит от причины: объявленный режим — «может стать проблемой», порча
|
||||
// записи — событие для разбора.
|
||||
//
|
||||
// Идентификатор записи обязателен, иначе владелец видит, что ответ не ушёл, но
|
||||
// не может найти, чей; текста ответа в записи нет — он содержимое чужой записи.
|
||||
func (s *TranscribeService) undelivered(r *entity.AudioRecord, level slog.Level, reason string) {
|
||||
metrics.UndeliveredReplyCounter.WithLabelValues(reason).Inc()
|
||||
|
||||
switch level {
|
||||
case slog.LevelError:
|
||||
s.logger.Error(undeliveredMessage, "record_id", r.Id, "reason", reason)
|
||||
default:
|
||||
s.logger.Warn(undeliveredMessage, "record_id", r.Id, "reason", reason)
|
||||
}
|
||||
}
|
||||
|
||||
const undeliveredMessage = "Reply was not delivered"
|
||||
|
||||
// notify отвечает отправителю там, где поднимать отказ некуда: запись уже
|
||||
// доведена до своего исхода, и отказ отправки остаётся записью в журнале.
|
||||
func (s *TranscribeService) notify(r *entity.AudioRecord, text string) {
|
||||
if err := s.send(r, text); err != nil {
|
||||
s.logger.Error("Failed to notify sender", "error", err, "record_id", r.Id)
|
||||
}
|
||||
}
|
||||
|
||||
// closeWork убирает рабочую копию. Отказ уборки не роняет шаг, но и не
|
||||
// проглатывается: забытая копия это шестичасовая запись во временном каталоге.
|
||||
func (s *TranscribeService) closeWork(work contract.WorkFile) {
|
||||
@@ -956,16 +872,6 @@ func (s *TranscribeService) closeWork(work contract.WorkFile) {
|
||||
}
|
||||
}
|
||||
|
||||
// ownerOf — владелец записи строкой; пустая значит «владельца нет», и таковы
|
||||
// записи, принятые ботом. Файл наследует владельца своей записи: правило
|
||||
// просмотра коллекции файлов сужено этой колонкой.
|
||||
func ownerOf(r *entity.AudioRecord) string {
|
||||
if r.OwnerID == nil {
|
||||
return ""
|
||||
}
|
||||
return *r.OwnerID
|
||||
}
|
||||
|
||||
// formatOf приводит расширение к виду колонки формата: без точки, в нижнем
|
||||
// регистре. Наружу оно выходит только приведённым к перечню известных форматов —
|
||||
// это делает метка метрики.
|
||||
|
||||
@@ -1,154 +0,0 @@
|
||||
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,
|
||||
sender contract.TelegramMessageSender,
|
||||
) (*TranscribeService, *bytes.Buffer) {
|
||||
t.Helper()
|
||||
|
||||
journal := &bytes.Buffer{}
|
||||
svc := NewTranscribeService(
|
||||
env.repos,
|
||||
&okMetaViewer{},
|
||||
&okConverter{},
|
||||
env.service.recognizer,
|
||||
sender,
|
||||
testLimits,
|
||||
slog.New(slog.NewTextHandler(journal, &slog.HandlerOptions{Level: slog.LevelDebug})),
|
||||
)
|
||||
|
||||
return svc, journal
|
||||
}
|
||||
|
||||
// transcribedRecord доводит запись до рубежа, с которого уходит ответ.
|
||||
func transcribedRecord(t *testing.T, env *pipelineEnv, svc *TranscribeService) *entity.AudioRecord {
|
||||
t.Helper()
|
||||
|
||||
record := newTelegramRecord(t, env)
|
||||
for range 5 {
|
||||
clearDelay(t, env, record.Id)
|
||||
require.NoError(t, svc.RunStep(t.Context()))
|
||||
if readRecord(t, env, record.Id).State == entity.StateTranscribed {
|
||||
return readRecord(t, env, record.Id)
|
||||
}
|
||||
}
|
||||
t.Fatal("запись не дошла до рубежа расшифровки")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Канал не поднят: запись доводится до конца, шаг отказа не объявляет, а
|
||||
// владелец узнаёт о недоставке из журнала.
|
||||
func TestUndeliveredOnDownChannelKeepsRecordDone(t *testing.T) {
|
||||
env := newPipelineEnv(t, &okMetaViewer{}, &okConverter{})
|
||||
|
||||
sender := &downSender{}
|
||||
svc, journal := journalEnv(t, env, sender)
|
||||
|
||||
record := transcribedRecord(t, env, svc)
|
||||
|
||||
// Шаг завершается без отказа — именно это воркер считает в свой счётчик.
|
||||
clearDelay(t, env, record.Id)
|
||||
require.NoError(t, svc.RunStep(t.Context()))
|
||||
|
||||
assert.Equal(t, 1, sender.calls, "ответ до отправителя доехал")
|
||||
|
||||
after := readRecord(t, env, record.Id)
|
||||
assert.Equal(t, entity.StateDone, after.State, "запись осталась на достигнутом рубеже")
|
||||
assert.False(t, after.IsHalted(), "отказ записи не приписан")
|
||||
require.NotNil(t, after.TranscriptTextID, "расшифровка сохранена")
|
||||
|
||||
text, err := env.repos.Texts.GetByID(*after.TranscriptTextID)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, text.Contents)
|
||||
|
||||
written := journal.String()
|
||||
assert.Contains(t, written, "Reply was not delivered", "недоставка названа")
|
||||
assert.Contains(t, written, record.Id, "запись несёт идентификатор")
|
||||
assert.Contains(t, written, "level=WARN", "объявленный режим — «может стать проблемой»")
|
||||
assert.NotContains(t, written, text.Contents, "текста расшифровки в журнале нет")
|
||||
}
|
||||
|
||||
// Адресат у записи не назван: исход тот же. Прежде эта ветка объявляла отказ
|
||||
// шага на уже завершённой работе.
|
||||
func TestUndeliveredWithoutChatKeepsRecordDone(t *testing.T) {
|
||||
env := newPipelineEnv(t, &okMetaViewer{}, &okConverter{})
|
||||
|
||||
sender := &downSender{}
|
||||
svc, journal := journalEnv(t, env, sender)
|
||||
|
||||
record := transcribedRecord(t, env, svc)
|
||||
|
||||
// Запись из Telegram, у которой чат не назван: такую отдаёт правка в панели.
|
||||
// Колонка чистится мимо захвата — иначе подготовка унесла бы запись у шага.
|
||||
stored, err := env.app.FindRecordById(migrations.RecordsCollection, record.Id)
|
||||
require.NoError(t, err)
|
||||
stored.Set("tg_chat_id", nil)
|
||||
require.NoError(t, env.app.Save(stored))
|
||||
|
||||
clearDelay(t, env, record.Id)
|
||||
require.NoError(t, svc.RunStep(t.Context()))
|
||||
|
||||
assert.Equal(t, 0, sender.calls, "до отправителя дело не дошло: адресата нет")
|
||||
|
||||
after := readRecord(t, env, record.Id)
|
||||
assert.Equal(t, entity.StateDone, after.State)
|
||||
assert.False(t, after.IsHalted(), "отказ записи не приписан")
|
||||
|
||||
written := journal.String()
|
||||
assert.Contains(t, written, "Reply was not delivered")
|
||||
assert.Contains(t, written, record.Id)
|
||||
assert.Contains(t, written, "chat is not specified", "причина названа")
|
||||
assert.Contains(t, written, "level=ERROR",
|
||||
"порча записи громче штатного «бот не настроен»: иначе сигнал утонет")
|
||||
}
|
||||
|
||||
// Запись, принятая по HTTP, до отправителя не доходит вовсе: недоставки нет, и
|
||||
// записи о ней в журнале быть не должно — иначе журнал владельца заполнят
|
||||
// строки о записях основного входа.
|
||||
func TestApiRecordDoesNotReachSenderAndLogsNothing(t *testing.T) {
|
||||
env := newPipelineEnv(t, &okMetaViewer{}, &okConverter{})
|
||||
|
||||
record, err := env.service.CreateJobFromApi(t.Context(), strings.NewReader("запись"), "voice.ogg", newOwner(t, env.app))
|
||||
require.NoError(t, err)
|
||||
|
||||
sender := &downSender{}
|
||||
svc, journal := journalEnv(t, env, sender)
|
||||
|
||||
require.NoError(t, svc.send(record, "расшифровка записи"))
|
||||
|
||||
assert.Equal(t, 0, sender.calls, "отправителя не звали")
|
||||
assert.NotContains(t, journal.String(), "Reply was not delivered", "недоставки не было")
|
||||
}
|
||||
Reference in New Issue
Block a user