удалён вход Telegram, владелец записи стал обязателен в схеме
- убраны клиент бота, транспорт обновлений, отправитель сообщений, сборка входа при старте, секция настроек и зависимость go-telegram-bot-api; из конвейера ушла доставка ответа отправителю — исход виден опросом готовности. Колонки адресата и значение источника остались в схеме: применённые шаги не переписываются - шаг 202608140003 запрещает пустого владельца у аудиозаписи и у файла; существующие строки он не проверяет, и это принято сознательно — искать их надо запросом до выкладки - ревью нашло два пред-существующих дефекта, оба закрыты: пустой второй ответ распознавателя стирал сохранённую расшифровку, а пустая расшифровка перестала быть заметной вместе с убранной доставкой. Попутно поднят golang.org/x/image до v0.45.0 — красный шаг vulns, воспроизводился и на чистом master
This commit is contained in:
@@ -154,8 +154,6 @@ linters:
|
||||
- (*os.File).Close
|
||||
- (io.ReadCloser).Close
|
||||
- os.Remove
|
||||
# Метод сам логирует ошибку отправки, вызывающему она не нужна
|
||||
- (*git.vakhrushev.me/av/transcriber/internal/controller/tg.TelegramController).send
|
||||
|
||||
exclusions:
|
||||
rules:
|
||||
|
||||
@@ -9,11 +9,12 @@
|
||||
|
||||
## Что это
|
||||
|
||||
Сервис расшифровки аудио в текст. Принимает запись двумя входами — Telegram-бот и
|
||||
HTTP API, — конвертирует её `ffmpeg` в ogg, отдаёт на отложенное распознавание
|
||||
Yandex SpeechKit и возвращает текст туда, откуда пришла запись. Состояние задач,
|
||||
метаданные и сами файлы лежат во встроенной PocketBase, и она же даёт владельцу
|
||||
панель администратора.
|
||||
Сервис расшифровки аудио в текст. Принимает запись одним входом — HTTP API, —
|
||||
конвертирует её `ffmpeg` в ogg, отдаёт на отложенное распознавание Yandex
|
||||
SpeechKit и отдаёт текст тому, кто запись загрузил, по опросу готовности.
|
||||
Состояние записей, метаданные и сами файлы лежат во встроенной PocketBase, и она
|
||||
же даёт владельцу панель администратора. Вход Telegram убран 2026-08-14 —
|
||||
временно, до задачи, которая свяжет чат с учётной записью.
|
||||
|
||||
Чего **не** делает: сам речь не распознаёт и своих моделей не держит, текст
|
||||
руками не правит и в форматы документов не экспортирует, учётных записей не
|
||||
@@ -25,7 +26,7 @@ Yandex SpeechKit и возвращает текст туда, откуда пр
|
||||
## Стек
|
||||
|
||||
Go 1.26 (сборке CGO не нужен; детектору гонок в гейте — нужен), встроенная PocketBase — хранилище, файлы записей и
|
||||
панель администратора, — `go-telegram-bot-api`, `aws-sdk-go-v2` для Object
|
||||
панель администратора, — `aws-sdk-go-v2` для Object
|
||||
Storage, gRPC-клиент Yandex SpeechKit v3, Prometheus, `slog`. Сборка —
|
||||
Taskfile, образ — Docker, выкладка — Ansible из `pet-project-server`.
|
||||
|
||||
@@ -33,7 +34,7 @@ Taskfile, образ — Docker, выкладка — Ansible из `pet-project-
|
||||
|
||||
Что нарушать нельзя.
|
||||
|
||||
- **Секрет не покидает конфиг.** Токен бота, ключ SpeechKit, пара ключей Object
|
||||
- **Секрет не покидает конфиг.** Ключ SpeechKit, пара ключей Object
|
||||
Storage и секрет клиента OIDC не попадают в git, в лог, в ответ пользователю и
|
||||
в колонку `error_text`. Нарушение необратимо: утёкший ключ отзывают и меняют
|
||||
вручную во всех местах выкладки. **critical**
|
||||
@@ -53,14 +54,13 @@ Taskfile, образ — Docker, выкладка — Ansible из `pet-project-
|
||||
приведённым к перечню известных форматов. Границу держит спека `intake`,
|
||||
цена — [adr/ADR-2026-08-11-known-format-label.md](docs/adr/ADR-2026-08-11-known-format-label.md),
|
||||
остаток — [docs/security.md](docs/security.md).
|
||||
- **Бот отвечает только тем, кто в белом списке.** Бот проверяет отправителя до
|
||||
любой работы, включая скачивание файла. Нарушение обратимо правкой конфига, но
|
||||
чужие записи к тому моменту уже обработаны за наши деньги. **critical**
|
||||
- **Принятая запись не теряется молча.** Отказ на любом шаге либо оставляет
|
||||
задачу пригодной к повтору, либо переводит её в `failed` и сообщает
|
||||
пользователю. Молчаливый выход из шага без записи в лог и без смены состояния
|
||||
запрещён. Обратимо повторной отправкой, но пользователь об этом не узнает.
|
||||
**major**
|
||||
запись пригодной к повтору, либо ставит на неё признак остановки с причиной —
|
||||
и тогда причина видна её владельцу опросом готовности, а владельцу сервиса
|
||||
журналом. Молчаливый выход из шага без записи в лог и без смены состояния
|
||||
запрещён. Обязанность сменила направление 2026-08-14 вместе с убранным входом
|
||||
Telegram: прежде об отказе сообщали, теперь отказ доступен спросившему, и
|
||||
отправитель, который не спрашивает, о нём не узнаёт. **major**
|
||||
- **`NoopJobError` — не ошибка.** Значение «задач в этом состоянии нет» не
|
||||
логируется, не считается в метрику и не поднимает уровень. Нарушение даёт
|
||||
запись раз в секунду на каждый воркер. **major**
|
||||
@@ -89,12 +89,21 @@ Taskfile, образ — Docker, выкладка — Ansible из `pet-project-
|
||||
Признак захвата уникален для каждого захвата, и запись результата условна по
|
||||
нему, а не по занятости записи. Шаг, чей захват за время работы достался
|
||||
другому — по протуханию срока или после того, как человек снял признак
|
||||
остановки в панели, — завершается без записи и без ответа отправителю. Условие
|
||||
остановки в панели, — завершается без записи результата. Условие
|
||||
по непустоте признака пропустило бы обоих: два воркера писали бы в одну запись
|
||||
по очереди, а отправитель получал бы два ответа. **major**
|
||||
- **Остановленная запись сообщает отправителю, какой бы ни была причина.**
|
||||
Причин три — приговор шага, исчерпанные отказы, застревание. Остановленная
|
||||
запись захвату не выдаётся, значит исход «пригодна к повтору» исключён.
|
||||
по очереди, портя её результат. **major**
|
||||
- **У записи есть владелец, и колонка пустого значения не принимает.** Ничья
|
||||
запись не заводится ничем — ни приёмом, ни конвейером, ни рукой в панели, — и
|
||||
держит это схема хранилища, а не договорённость. Пока обязательность жила в
|
||||
одном приёме, ничью запись заводили в панели, она уходила в конвейер, стоила
|
||||
денег на распознавание и не доставалась потом никому. Правило со стороны
|
||||
спрашивающего при этом остаётся: пустой владелец не совпадает ни с одной
|
||||
записью, потому что схема запрещает **заводить** ничью, а это правило —
|
||||
**спрашивать** ничьим именем. **major**
|
||||
- **Остановленная запись несёт причину, какой бы та ни была.** Причин три —
|
||||
приговор шага, исчерпанные отказы, застревание, — и каждая записывается в саму
|
||||
запись и в её журнал событий. Остановленная запись захвату не выдаётся, значит
|
||||
исход «пригодна к повтору» исключён, и другого следа у неё не будет.
|
||||
Обязанность, записанная у одной причины, у остальных читалась бы как снятая.
|
||||
**major**
|
||||
|
||||
@@ -200,16 +209,12 @@ task gate # весь набор проверок разом
|
||||
база (`data/data.db`), и записи живых людей
|
||||
(`data/storage/<коллекция>/<запись>/`). Локальный каталог данных — свой, его
|
||||
ронять и пересоздавать можно свободно.
|
||||
- **Боевым токеном бота не запускаться.** Второй процесс с тем же токеном
|
||||
перехватывает обновления у работающего, и пользователь теряет ответы. Запускай
|
||||
с `telegram.enabled = false`: сервис поднимается без Telegram, к нему не уходит
|
||||
ни одного обращения, и работает он одним входом, по HTTP. Пустого
|
||||
`bot_token` для этого мало и больше не значит ничего: включён вход или нет,
|
||||
решает отдельный признак `telegram.enabled`, а пустой ключ при `enabled = true`
|
||||
роняет старт. Выключенного входа
|
||||
для подъёма тоже мало: секции `[auth]` и `[yandex]` проверяются на старте, но
|
||||
наружу при этом не ходят, так что годятся выдуманные непустые значения;
|
||||
подробности строками в `config.example.toml`.
|
||||
- **Локальный запуск не ходит наружу.** Секции `[auth]` и `[yandex]`
|
||||
проверяются на старте, но наружу при этом не обращаются, так что годятся
|
||||
выдуманные непустые значения — адреса `[auth]` должны лишь разбираться как
|
||||
ссылки. Расшифровка при выдуманных ключах не работает: её подменяют
|
||||
`internal/adapter/recognizer/memory.go`. Подробности строками в
|
||||
`config.example.toml`.
|
||||
- **Yandex Cloud за деньги.** Распознавание и хранение в Object Storage
|
||||
оплачиваются по факту. Прогон на реальных ключах ради проверки кода запрещён —
|
||||
подставляй `internal/adapter/recognizer/memory.go`.
|
||||
@@ -249,7 +254,7 @@ task gate # весь набор проверок разом
|
||||
|
||||
- Документация, комментарии, сообщения коммитов — русский.
|
||||
- Код и идентификаторы — английский.
|
||||
- Текст, который видит пользователь Telegram, — русский.
|
||||
- Текст, который видит пользователь сервиса, — русский.
|
||||
- **Точного числа накопленного в документах нет.** «Три capability», «пять
|
||||
прогонов ревью», «две типизированные ошибки» расходятся с действительностью на
|
||||
первой же задаче, которая прибавит четвёртую, — и расходятся молча: машина
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
# Transcriber Service
|
||||
|
||||
Сервис расшифровки аудиозаписей. Два входа — Telegram-бот и HTTP API.
|
||||
Сервис расшифровки аудиозаписей. Вход один — HTTP API.
|
||||
|
||||
## Возможности
|
||||
|
||||
- Приём аудио из Telegram: голосовые сообщения, аудиофайлы и документы с аудио
|
||||
- Приём аудиофайлов через HTTP API
|
||||
- Конвертация в ogg через ffmpeg
|
||||
- Распознавание речи через Yandex SpeechKit
|
||||
@@ -15,7 +14,6 @@
|
||||
|
||||
- **Язык**: Go 1.26, CGO не нужен
|
||||
- **Веб-фреймворк**: gin-gonic/gin
|
||||
- **Telegram**: go-telegram-bot-api
|
||||
- **Распознавание**: Yandex SpeechKit + Yandex Object Storage (S3)
|
||||
- **Конвертация**: ffmpeg
|
||||
- **Хранилище, файлы и панель**: встроенная PocketBase
|
||||
@@ -41,12 +39,11 @@
|
||||
Сервер запустится на порту из `[server] port`, по умолчанию 8080. Нужен
|
||||
установленный `ffmpeg`.
|
||||
|
||||
### Белый список Telegram
|
||||
### Кого пускают
|
||||
|
||||
Бот отвечает только тем, кто перечислен в конфиге. Кого и по какому признаку он
|
||||
пускает — [docs/security.md](docs/security.md), «Что разграничивает доступ»;
|
||||
известные прорехи образца конфига, включая недостающий ключ белого списка, —
|
||||
[docs/conventions/config.md](docs/conventions/config.md).
|
||||
Приём и опрос закрыты сессией OIDC. Что её выдаёт и чем она предъявляется —
|
||||
[docs/security.md](docs/security.md), «Что разграничивает доступ»; известные
|
||||
прорехи образца конфига — [docs/conventions/config.md](docs/conventions/config.md).
|
||||
|
||||
## Деплой
|
||||
|
||||
@@ -94,13 +91,11 @@ transcriber/
|
||||
│ ├── service/ # Конвейер расшифровки
|
||||
│ ├── controller/
|
||||
│ │ ├── http/ # HTTP-обработчики
|
||||
│ │ ├── tg/ # Telegram-бот
|
||||
│ │ └── worker/ # Фоновые воркеры
|
||||
│ └── adapter/
|
||||
│ ├── converter/ffmpeg/ # Конвертация аудио
|
||||
│ ├── metaviewer/ffmpeg/ # Длительность аудио
|
||||
│ ├── recognizer/yandex/ # SpeechKit + Object Storage
|
||||
│ ├── telegram/ # Отправка сообщений
|
||||
│ └── repo/pocketbase/ # Репозитории, схема коллекций, правила панели
|
||||
└── data/ # Каталог данных: база и файлы записей вместе
|
||||
├── data.db # База хранилища (создаётся автоматически)
|
||||
@@ -109,7 +104,7 @@ transcriber/
|
||||
|
||||
## Хранилище
|
||||
|
||||
Две коллекции, `files` и `transcribe_jobs`. Поля, ключи, правило времени и
|
||||
Коллекции хранилища — аудиозапись и её приложения. Поля, ключи, правило времени и
|
||||
идентификаторов, а также механика захвата задачи воркером —
|
||||
[docs/database.md](docs/database.md). Панель владельца — по адресу `/_/` того же
|
||||
порта; пароль от неё задаёт сам владелец по приглашению, которое сервис печатает
|
||||
|
||||
@@ -85,41 +85,3 @@ redirect_url = "https://transcriber.example.com/auth/callback"
|
||||
# Признак `Secure` у куки сессии. Умолчание true; false только для локального
|
||||
# запуска по http://localhost, где браузер такую куку не сохранит
|
||||
secure_cookie = true
|
||||
|
||||
# Telegram Bot Configuration
|
||||
[telegram]
|
||||
# Нужен ли сервису вход Telegram. Ключ **обязателен**: умолчания у него нет, и
|
||||
# файл без него негоден — сервис выходит с ошибкой настройки, назвав недостающий
|
||||
# ключ. Умолчание было бы угаданным намерением, а признак заведён затем, чтобы
|
||||
# намерение объявляли: любое умолчание делает одну из двух ошибок тихой — либо
|
||||
# бот молча пропадает, либо файл без признака молча работает.
|
||||
#
|
||||
# false — сервис поднимается без Telegram и работает одним входом, по HTTP. Бот
|
||||
# не заводится, к Telegram не уходит ни одного обращения, записи из Telegram не
|
||||
# принимаются, а ответы на задачи, принятые оттуда прежде, не уходят —
|
||||
# недоставка видна записью журнала, расшифровка достаётся из панели и по HTTP.
|
||||
# О выключенном входе сервис говорит одной записью журнала «к сведению»: это
|
||||
# выбор владельца, а не отклонение.
|
||||
#
|
||||
# true — сервис поднимает бота. Пустой bot_token при этом роняет старт: бота по
|
||||
# пустому ключу не существует. Старт роняет и ответ Telegram «такого бота нет» —
|
||||
# это опечатка в ключе, ждать тут нечего. А вот недоступность Telegram (сеть,
|
||||
# DNS, авария Bot API) подъёму не мешает: сервис встаёт без бота и предупреждает
|
||||
# записью журнала, потому что основной вход у него другой.
|
||||
#
|
||||
# Локальный прогон идёт с false — боевым токеном запускаться запрещено: второй
|
||||
# процесс с тем же токеном перехватывает обновления у работающего. Выключенного
|
||||
# входа для подъёма мало: секции [auth] и [yandex] проверяются на старте и
|
||||
# роняют процесс на пустых ключах. Наружу при старте не ходит ни одна из них,
|
||||
# поэтому для локального прогона годятся выдуманные непустые значения — адреса
|
||||
# [auth] должны лишь разбираться как ссылки. Расшифровка при выдуманных ключах
|
||||
# не работает: её подменяют в коде.
|
||||
enabled = false
|
||||
|
||||
# Токен Telegram бота (получить у @BotFather в Telegram). Только ключ доступа:
|
||||
# включением входа он больше не заведует, этим занят enabled выше. При
|
||||
# enabled = false не читается вовсе.
|
||||
bot_token = ""
|
||||
|
||||
# Таймаут обновлений Telegram бота (в секундах)
|
||||
update_timeout = 10
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
- **Дата:** 2026-08-13
|
||||
- **Источник:** openspec/changes/archive/2026-08-13-telegram-enabled-flag/design.md
|
||||
- **Статус:** устарело — вход Telegram убран решением [ADR-2026-08-15-telegram-intake-removed-temporarily](ADR-2026-08-15-telegram-intake-removed-temporarily.md); довод устоял и понадобится возврату входа
|
||||
|
||||
## Решение
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
- **Дата:** 2026-08-13
|
||||
- **Источник:** openspec/changes/archive/2026-08-13-start-without-telegram-token/design.md
|
||||
- **Статус:** устарело — вход Telegram убран решением [ADR-2026-08-15-telegram-intake-removed-temporarily](ADR-2026-08-15-telegram-intake-removed-temporarily.md); довод устоял и понадобится возврату входа
|
||||
|
||||
## Решение
|
||||
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
# Обязательность владельца держит схема, а не приём
|
||||
|
||||
- **Дата:** 2026-08-15
|
||||
- **Источник:** openspec/changes/archive/2026-08-15-remove-telegram-intake/design.md,
|
||||
разделы «Схема теряет только необязательность владельца» и «Владелец записи
|
||||
перестаёт быть необязательным и в модели»
|
||||
|
||||
## Решение
|
||||
|
||||
Колонка владельца у аудиозаписи и у файла перестала принимать пустое значение —
|
||||
шагом схемы `202608140003`. Ничья запись не заводится ничем: ни приёмом, ни
|
||||
конвейером, ни рукой в панели. Поле владельца в модели стало обычной строкой
|
||||
вместо ссылки, которой позволено отсутствовать.
|
||||
|
||||
Существующие строки шаг **не проверяет**, и это принято сознательно: искать ничьи
|
||||
строки надо запросом до выкладки.
|
||||
|
||||
## Почему
|
||||
|
||||
Цитата источника:
|
||||
|
||||
> **Держать обязательность одним приёмом, схему не трогать.** Так было задумано
|
||||
> сперва, и это оставляло дыру: ничью запись заводили руками в панели, она
|
||||
> уходила в конвейер, стоила денег на распознавание и не доставалась потом
|
||||
> никому. Решение владельца от 2026-08-14 — обязательность держит схема.
|
||||
|
||||
Прежнее решение было обратным и записано спекой `storage`: «Колонка MUST
|
||||
допускать пустое значение… Обязательность для приёма по HTTP держит сама
|
||||
capability `intake`, а не схема». Цену за него платили записи входа Telegram — у
|
||||
них владельца не было по построению. Вход убран
|
||||
([ADR-2026-08-15-telegram-intake-removed-temporarily](ADR-2026-08-15-telegram-intake-removed-temporarily.md)),
|
||||
исключение исчезло вместе с ним, и владелец сервиса подтвердил, что записей без
|
||||
владельца в боевой базе нет.
|
||||
|
||||
Про непроверку существующих строк цитата источника:
|
||||
|
||||
> **Проверяется это запросом, а не прогоном шага**, и разница выяснилась ревью с
|
||||
> оракулом: хранилище держит обязательность связи проверкой записи при
|
||||
> сохранении, а не ограничением таблицы. Смена признака на базе с ничьей записью
|
||||
> проходит зелёным и такую запись оставляет… Заставить шаг считать строки самому
|
||||
> владелец решил не делать: безопасность держится ручной проверкой, и она названа
|
||||
> первым шагом плана перехода.
|
||||
|
||||
Правило «пустой владелец не совпадает ни с одной записью» при этом осталось и
|
||||
избыточным не стало: схема запрещает **заводить** ничью запись, а правило —
|
||||
**спрашивать** ничьим именем.
|
||||
|
||||
## Последствия
|
||||
|
||||
- `+` значения «владельца нет» не существует ни на одном уровне: ни в схеме, ни в
|
||||
модели, ни в отборе.
|
||||
- `+` дыра «ничью запись заводят руками в панели» закрыта тем же механизмом, что
|
||||
и приём, — одним, а не двумя.
|
||||
- `−` откат шага возвращает необязательность, но операционно недостижим: команд
|
||||
библиотеки сервис не подключает, и это верно для всех шагов схемы проекта.
|
||||
- `−` ничья запись, если её проглядят перед выкладкой, становится незакрываемой:
|
||||
захват выдаёт её воркеру, а всякое сохранение — включая то, которым ставится
|
||||
признак остановки, — отказывает. Следа не остаётся ни в метрике, ни в журнале
|
||||
событий, только строка в логе контейнера.
|
||||
@@ -0,0 +1,40 @@
|
||||
# Метка убранного входа не выставляется вовсе, а не обнуляется
|
||||
|
||||
- **Дата:** 2026-08-15
|
||||
- **Источник:** openspec/changes/archive/2026-08-15-remove-telegram-intake/design.md,
|
||||
раздел «Метка убранного входа не выставляется вовсе»
|
||||
|
||||
## Решение
|
||||
|
||||
Признак поднятого входа остался, а метки убранного входа в метриках нет вовсе —
|
||||
ни со значением единицы, ни со значением нуля. Ряд `transcriber_intake_up` с
|
||||
меткой `telegram` не появляется после выкладки.
|
||||
|
||||
## Почему
|
||||
|
||||
Цитата источника:
|
||||
|
||||
> Признак поднятого входа остаётся, метка `telegram` у него больше не появляется.
|
||||
> Ноль вместо неё читается как «вход есть, но не поднялся», то есть как поломка;
|
||||
> владелец, у которого на этот признак стоит отбор, увидел бы аварию на ровном
|
||||
> месте.
|
||||
|
||||
Отвергнут очевидный подход — оставить ряд со значением нуля. Он выглядит
|
||||
бережнее (отбор не ломается), но говорит неправду: значение нуля у этого признака
|
||||
означает именно неподнятый вход, а не отсутствующий.
|
||||
|
||||
С единственным оставшимся входом проверяемым осталось только **множество меток**:
|
||||
значение нуля у него недостижимо, потому что страница метрик отдаётся тем же
|
||||
сервером, что и приём, — чтобы прочитать признак, надо дотянуться до входа, о
|
||||
котором он сообщает. Различать поднятый и неподнятый вход признак станет снова,
|
||||
когда входов у сервиса станет больше одного.
|
||||
|
||||
## Последствия
|
||||
|
||||
- `+` наблюдатель не видит вечного нуля, который читался бы как незакрытая
|
||||
авария.
|
||||
- `−` отбор вида `transcriber_intake_up == 0` по убранному входу перестаёт
|
||||
срабатывать молча: исчезновение ряда ловится `absent()`, а не сравнением.
|
||||
Владельцу, если такой отбор был заведён, править его руками.
|
||||
- `−` требование «различать поднятый и неподнятый» стало непроверяемым до
|
||||
возвращения второго входа, и это сказано в самом требовании прямо.
|
||||
@@ -0,0 +1,63 @@
|
||||
# Вход Telegram убран целиком, а не выключен признаком
|
||||
|
||||
- **Дата:** 2026-08-15
|
||||
- **Источник:** openspec/changes/archive/2026-08-15-remove-telegram-intake/design.md,
|
||||
разделы «Context» и «Формы решения, между которыми выбирали»
|
||||
|
||||
## Решение
|
||||
|
||||
Вход Telegram убран из сервиса целиком: клиент, транспорт обновлений, отправитель
|
||||
сообщений, сборка входа при старте, список допущенных людей, секция настроек и
|
||||
зависимость. Убран **временно** — возврат заводится новым изменением вместе со
|
||||
связью чата с учётной записью.
|
||||
|
||||
Хранилище при этом не тронуто: колонки `tg_chat_id`, `tg_reply_message_id` и
|
||||
значение `telegram` перечня источников остаются в схеме вместе с записями,
|
||||
которые их заполнили.
|
||||
|
||||
## Почему
|
||||
|
||||
Цитата источника:
|
||||
|
||||
> Сервис принимает записи двумя входами, и входы расходятся в главном: у записи,
|
||||
> пришедшей из приложения, есть владелец, а у записи, пришедшей от бота, владельца
|
||||
> нет и быть не может — связи чата с учётной записью сервис не ведёт. Пока такие
|
||||
> записи заводятся, правило «каждая запись принадлежит человеку» действует
|
||||
> наполовину.
|
||||
|
||||
Отвергнуты две формы решения, обе с названной ценой:
|
||||
|
||||
> **Выключить вход признаком, код оставить.** Признак `telegram.enabled` заведён
|
||||
> 2026-08-13 и обязателен, а приём по HTTP владельца уже требует: одна правка
|
||||
> ключа в боевом файле даёт «новых записей без владельца не заводится» ценой ноля
|
||||
> строк кода и мгновенным возвратом. Отвергнуто по причине из раздела «Why»:
|
||||
> двойная модель остаётся в коде, и оговорку про бота продолжает платить каждая
|
||||
> следующая задача.
|
||||
>
|
||||
> **Сузить бота до исходящего канала.** Приём убрать, отправку оставить с одним
|
||||
> адресатом — чатом владельца строкой настроек. Отвергнуто потому, что заводит
|
||||
> понятие «канал уведомления владельца», которое тут же переделает задача
|
||||
> `ntfy-delivery`.
|
||||
|
||||
Решениями, которые это изменение отменяет, были
|
||||
[ADR-2026-08-13-telegram-intent-declared-not-inferred](ADR-2026-08-13-telegram-intent-declared-not-inferred.md)
|
||||
и
|
||||
[ADR-2026-08-13-telegram-outage-does-not-block-startup](ADR-2026-08-13-telegram-outage-does-not-block-startup.md):
|
||||
оба нормировали подъём входа, которого больше нет. Доводы их при этом устояли и
|
||||
понадобятся возврату — оба продолжают отвечать на вопрос «что делать с входом,
|
||||
чей внешний собеседник недоступен».
|
||||
|
||||
## Последствия
|
||||
|
||||
- `+` модель одна: оговорка про запись без владельца ушла из спек приёма,
|
||||
доступа, конвейера и хранилища.
|
||||
- `+` зависимость `go-telegram-bot-api` ушла из манифеста вместе с двумя путями
|
||||
утечки токена, которые проект закрывал двумя задачами.
|
||||
- `−` у сервиса не осталось входа, которым человек может воспользоваться:
|
||||
приложения нет, личных ключей для программ нет, и до этих задач запись кладут
|
||||
собранным руками запросом с сессией из браузера. Владелец окно принял.
|
||||
- `−` записи, застрявшие в конвейере на минуту выкладки, доходят до текста, и
|
||||
ответа в чат по ним не уходит. Смягчения нет: чат и есть убираемый вход.
|
||||
- `−` бот у Telegram остаётся зарегистрированным и на вид живым, а ключ доступа —
|
||||
в настройках выкладки под возврат входа (решение владельца от 2026-08-14).
|
||||
Отправитель голосового не получит ни ответа, ни отказа.
|
||||
+5
-2
@@ -35,12 +35,15 @@
|
||||
|
||||
| Дата | Запись | Статус |
|
||||
| --- | --- | --- |
|
||||
| 2026-08-15 | [Вход Telegram убран целиком, а не выключен признаком](ADR-2026-08-15-telegram-intake-removed-temporarily.md) | |
|
||||
| 2026-08-15 | [Обязательность владельца держит схема, а не приём](ADR-2026-08-15-owner-required-by-schema.md) | |
|
||||
| 2026-08-15 | [Метка убранного входа не выставляется вовсе, а не обнуляется](ADR-2026-08-15-removed-intake-has-no-metric-label.md) | |
|
||||
| 2026-08-14 | [Предел простоя остаётся часом, хотя он короче самой работы](ADR-2026-08-14-stuck-limit-stays-an-hour.md) | |
|
||||
| 2026-08-14 | [Ответ распознавателя хранится дословно, двоичной формой и вложением](ADR-2026-08-14-provider-payload-stored-verbatim.md) | |
|
||||
| 2026-08-14 | [Остановка записи — признак, а не рубеж](ADR-2026-08-14-halt-is-a-flag-not-a-stage.md) | |
|
||||
| 2026-08-14 | [Учётная запись с записями не удаляется, и это осознанный тупик](ADR-2026-08-14-account-with-records-is-not-deleted.md) | |
|
||||
| 2026-08-13 | [Намерение объявляется признаком, а не выводится из ключа доступа](ADR-2026-08-13-telegram-intent-declared-not-inferred.md) | |
|
||||
| 2026-08-13 | [Недоступность Telegram подъёму сервиса не мешает](ADR-2026-08-13-telegram-outage-does-not-block-startup.md) | |
|
||||
| 2026-08-13 | [Намерение объявляется признаком, а не выводится из ключа доступа](ADR-2026-08-13-telegram-intent-declared-not-inferred.md) | устарело: вход убран [ADR-2026-08-15-telegram-intake-removed-temporarily](ADR-2026-08-15-telegram-intake-removed-temporarily.md) |
|
||||
| 2026-08-13 | [Недоступность Telegram подъёму сервиса не мешает](ADR-2026-08-13-telegram-outage-does-not-block-startup.md) | устарело: вход убран [ADR-2026-08-15-telegram-intake-removed-temporarily](ADR-2026-08-15-telegram-intake-removed-temporarily.md) |
|
||||
| 2026-08-12 | [Файл записи закрыт защищённым полем и отдаётся вошедшему по токену файла](ADR-2026-08-12-protected-file-behind-session.md) | |
|
||||
| 2026-08-12 | [Сессия живёт семь суток и не продлевает саму себя](ADR-2026-08-12-session-without-refresh.md) | |
|
||||
| 2026-08-12 | [Кого пускать в сервис, решает правило провайдера, а не сервис](ADR-2026-08-12-access-delegated-to-provider.md) | |
|
||||
|
||||
+37
-57
@@ -17,16 +17,17 @@
|
||||
- [intake](../openspec/specs/intake/spec.md) — **приём по HTTP плюс наличие
|
||||
входов**: приём и опрос за сессией, имя отправителя не доходит ни до
|
||||
хранилища, ни до журнала, метка метрики несёт только известное расширение, а
|
||||
выключенный вход Telegram не мешает подъёму. Задачи
|
||||
наблюдатель видит единственный поднятый вход. Задачи
|
||||
`http-handler-tests-never-green` и `no-user-filename-in-log` 2026-08-11,
|
||||
`pocketbase-storage` и `oidc-login` 2026-08-12,
|
||||
`local-run-without-telegram-token` 2026-08-13. Приём из Telegram по существу —
|
||||
кто допущен и как забирается запись — здесь по-прежнему не описан;
|
||||
`local-run-without-telegram-token` 2026-08-13, `remove-telegram-intake`
|
||||
2026-08-14;
|
||||
- [pipeline](../openspec/specs/pipeline/spec.md) — пустой прогон воркера, захват
|
||||
задачи и срок его протухания, число попыток, состояние «мертва», пауза перед
|
||||
повтором и недоставленный ответ отправителю: задачи
|
||||
`errors-as-instead-of-typecast` 2026-08-11, `pocketbase-storage` 2026-08-12 и
|
||||
`local-run-without-telegram-token` 2026-08-13. Переходы состояний и отмена
|
||||
задачи и срок его протухания, число попыток, остановка признаком, пауза перед
|
||||
повтором и молчание конвейера наружу: задачи
|
||||
`errors-as-instead-of-typecast` 2026-08-11, `pocketbase-storage` 2026-08-12,
|
||||
`local-run-without-telegram-token` 2026-08-13 и `remove-telegram-intake`
|
||||
2026-08-14. Переходы состояний и отмена
|
||||
контекста посреди шага остаются
|
||||
долгом; что именно не описано, перечисляет раздел `Purpose` самой спеки;
|
||||
- [storage](../openspec/specs/storage/spec.md) — где живут запись, её метаданные
|
||||
@@ -40,17 +41,17 @@
|
||||
- [access](../openspec/specs/access/spec.md) — кто пришёл в сервис и пускают ли
|
||||
его дальше: вход через внешнего провайдера OIDC, чем предъявляется сессия, что
|
||||
её прекращает и какие адреса остаются открытыми. Задача `oidc-login`
|
||||
2026-08-12. Здесь же разграничение записей по владельцу: запись из веба
|
||||
принадлежит тому, кто её принёс, чужая неотличима от несуществующей, а запись
|
||||
из Telegram владельца не имеет и по API не достаётся никому. Задача
|
||||
`record-ownership` 2026-08-14.
|
||||
2026-08-12. Здесь же разграничение записей по владельцу: принятая запись
|
||||
принадлежит тому, кто её принёс, чужая неотличима от несуществующей, а ничьей
|
||||
записи не бывает вовсе — колонка владельца пустого значения не принимает.
|
||||
Задачи `record-ownership` и `remove-telegram-intake` 2026-08-14.
|
||||
|
||||
Поведение прочих узлов, включая приём из Telegram, по-прежнему живёт только в
|
||||
коде. Задача, которая его трогает, дописывает спеку своей capability.
|
||||
Поведение узла, которого нет в перечне выше, по-прежнему живёт только в коде.
|
||||
Задача, которая его трогает, дописывает спеку своей capability.
|
||||
|
||||
## Принципы
|
||||
|
||||
- **Один процесс.** Бот, HTTP-сервер и фоновые воркеры живут в одном бинарнике и
|
||||
- **Один процесс.** HTTP-сервер и фоновые воркеры живут в одном бинарнике и
|
||||
делят одну базу. Отдельного воркер-процесса нет намеренно.
|
||||
- **Очередь таблицей.** Состояние задачи лежит коллекцией хранилища; неделимость
|
||||
захвата и порядок выборки нормирует
|
||||
@@ -64,7 +65,7 @@
|
||||
[pipeline](../openspec/specs/pipeline/spec.md), «Брошенная задача возвращается
|
||||
в работу»; здесь это принцип письма шага, а не описание поведения.
|
||||
- **Ядро зависит от интерфейсов.** `internal/service` знает только
|
||||
`internal/contract`; ffmpeg, Yandex, Telegram и хранилище подставляются в
|
||||
`internal/contract`; ffmpeg, Yandex и хранилище подставляются в
|
||||
`main.go`. Правило механизировано тестами-сканерами `internal/archrules`, и
|
||||
они же держат обратные направления: транспорты не знают друг о друге, адаптер
|
||||
не знает ни ядра, ни транспортов.
|
||||
@@ -77,17 +78,15 @@
|
||||
|
||||
Каждый — строкой со ссылкой на capability, а не пересказом её требований.
|
||||
|
||||
<!-- канон: поведение → openspec/specs/intake, pipeline, storage; ещё НЕ переехало: приём из Telegram, деление длинного текста по словам -->
|
||||
<!-- канон: поведение → openspec/specs/intake, pipeline, storage; ещё НЕ переехало: приведение записи к рабочему формату -->
|
||||
|
||||
| Компонент | Где | Что делает |
|
||||
| --- | --- | --- |
|
||||
| Telegram-бот | `internal/controller/tg` | Принимает голосовые, аудиофайлы и документы с аудио, скачивает их, заводит задачу |
|
||||
| HTTP API | `internal/controller/http` | Приём файла и опрос статуса задачи |
|
||||
| Воркеры | `internal/controller/worker` | Пул одинаковых потоков: каждый берёт любую пригодную запись и опрашивает базу. Число — настройкой, ноль законен |
|
||||
| Сервис расшифровки | `internal/service` | Конвейер: приём, приведение, отправка, опрос, завершение. Шаг выбирается по рубежу записи |
|
||||
| Конвертер и метаданные | `internal/adapter/{converter,metaviewer}/ffmpeg` | `ffmpeg` в ogg/vorbis, `ffprobe` для длительности |
|
||||
| Распознаватель | `internal/adapter/recognizer/yandex` | Заливка в Object Storage и отложенное распознавание SpeechKit; разбор ответа в реплики со временем |
|
||||
| Отправитель Telegram | `internal/adapter/telegram` | Отправка текста, деление длинного по словам |
|
||||
| Репозитории | `internal/adapter/repo/pocketbase` | Записи, файлы, тексты, структура, попытки распознавания и журнал событий — коллекциями хранилища; захват — сырым запросом |
|
||||
| Шаги схемы | `internal/adapter/repo/pocketbase/migrations` | Файл на шаг, имя файла — имя шага; там же имена коллекций |
|
||||
| Панель владельца | `internal/adapter/repo/pocketbase`, `panel.go` | Панель хранилища; правила правки записи нормирует [storage](../openspec/specs/storage/spec.md), «Владелец видит записи в панели» |
|
||||
@@ -102,12 +101,6 @@
|
||||
|
||||
## Внешние границы и форматы
|
||||
|
||||
- **Telegram Bot API.** Вход — обновления длинным опросом, выход — сообщения.
|
||||
Файл скачивается по ссылке `file.Link(token)` запросом с контекстом, клиентом
|
||||
самого бота. Клиента заводит единая точка `internal/adapter/telegram`: токен
|
||||
стоит в пути каждого обращения, и снятие адреса с отказа живёт там —
|
||||
[conventions/logging.md](conventions/logging.md), «Безопасность: что не
|
||||
логируем». Telegram не отдаёт файлы больше 20 МиБ — это потолок приёма из бота.
|
||||
- **Yandex Object Storage.** S3-совместимый, клиент `aws-sdk-go-v2` с
|
||||
`UsePathStyle`. Ключ объекта — имя файла, то есть UUID с расширением.
|
||||
- **Yandex SpeechKit v3.** gRPC, `stt.api.cloud.yandex.net:443`, модель
|
||||
@@ -121,26 +114,12 @@
|
||||
- **Где работает, что рядом, кто перезапускает:** один контейнер на личном
|
||||
сервере, разворачивает и перезапускает Ansible из `pet-project-server`. Рядом —
|
||||
обратный прокси, который публикует HTTP-порт наружу.
|
||||
- **Порядок выкладки задаётся по ключу, а не по файлу целиком.** Общего правила
|
||||
«сперва образ» или «сперва конфиг» нет: два ключа секции Telegram требуют
|
||||
противоположного, и оба правила действуют одновременно.
|
||||
- **Признак включения `telegram.enabled` едет в конфиг раньше образа.** Он
|
||||
обязателен с 2026-08-13, умолчания у него нет, и образ, который его ждёт,
|
||||
без него выходит с кодом 1 **до** открытия порта — вместе с HTTP, панелью и
|
||||
конвейером. Прежний образ лишний ключ TOML просто не читает, поэтому ранняя
|
||||
правка конфига безопасна, а поздняя роняет сервис.
|
||||
- **Пустой ключ доступа `telegram.bot_token` едет позже образа.** Образы
|
||||
старше 2026-08-13 роняли старт на пустом ключе, тоже до открытия порта.
|
||||
- **Откат при выключенном входе** допустим только на образ от 2026-08-13 и
|
||||
новее. На более старом состояния «сервис поднят, бот опущен» не существует
|
||||
вовсе: пустой ключ роняет старт, негодный роняет старт, годный поднимает
|
||||
бота. Откат туда делают с непустым годным ключом, приняв, что бот поднимется.
|
||||
- **Откат образа при `enabled = false` и заполненном ключе** отменяет решение
|
||||
владельца молча: прежний образ признака не видит и поднимает бота. Если вход
|
||||
был выключен потому, что бот с этим токеном поднят где-то ещё, два процесса
|
||||
поделят один длинный опрос и часть ответов до людей не дойдёт.
|
||||
|
||||
Ревью кода воспроизвело порядок на прежней версии, живой прогон — на нынешней.
|
||||
- **Порядок выкладки: конфиг после образа.** Прежде здесь стояло правило,
|
||||
разное для двух ключей секции Telegram; с убранным входом оно потеряло предмет
|
||||
целиком. Оставшиеся ключи, которых новый образ ждёт, в конфиге уже есть.
|
||||
Секцию `[telegram]` и ключ `server.users_while_list` человек убирает из боевого
|
||||
файла после выкладки: незнакомые ключи разбор настроек не судит, и файл с ними
|
||||
сервис поднимает молча.
|
||||
- **Откат образа через шаг схемы `202608140002` не работает и не говорит об
|
||||
этом.** Шаг удаляет прежнюю коллекцию задач, а библиотека накатывает только
|
||||
те шаги, которые знает сам бинарь: прежний образ шагов новее не видит,
|
||||
@@ -160,16 +139,15 @@
|
||||
|
||||
| Зависимость | Падает | Отвечает медленно | Молчит | Отдаёт мусор |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| Telegram Bot API | Сервис поднимается без Telegram и работает по HTTP; старт роняют только ошибки настройки — ответ «такого бота нет» и включённый вход с пустым ключом доступа. Норму держит [intake](../openspec/specs/intake/spec.md), «Признак включения решает, поднимается ли вход Telegram» | На старте — ждём не дольше срока, дальше поднимаемся без Telegram. У поднятого сервиса скачивание файла висит бесконечно: там срока нет | То же, что «отвечает медленно»: на старте — подъём без Telegram по истечении срока, у поднятого — длинный опрос пуст и новые задачи не заводятся | Файл скачался битым, отказ вылезет на конвертации |
|
||||
| Yandex SpeechKit | Шаг возвращает ошибку, запись остаётся на повтор | Захват держится час, запись не двигается; по истечении предела простоя она останавливается с причиной «застряла», не теряя идентификатора операции | Операция вечно `in progress`, повтор каждые 5 секунд — до предела простоя в сутки | Пустой текст — запись завершается заглушкой «на записи нет текста» |
|
||||
| Yandex SpeechKit | Шаг возвращает ошибку, запись остаётся на повтор | Захват держится час, запись не двигается; по истечении предела простоя она останавливается с причиной «застряла», не теряя идентификатора операции | Операция вечно `in progress`, повтор каждые 5 секунд — до предела простоя в сутки | Пустой текст — запись доходит до конечного рубежа без расшифровки, и в журнале стоит запись «может стать проблемой» с идентификатором записи; опрос готовности отдаёт рубеж `done` без поля текста |
|
||||
| ↳ *остановка сервиса* | Принятие операции от отмены защищено своим пределом в 10 секунд: операцию там могли принять и начать считать деньги, а потерянный идентификатор заставил бы повтор оплатить ту же запись второй раз. Заливка в Object Storage отменяется штатно — её повтор бесплатен, объект ложится под тем же ключом | — | — | — |
|
||||
| Yandex Object Storage | Заливка падает, запись остаётся на рубеже `normalized` | То же, что падение: висит до конца захвата | — | SpeechKit не прочитает объект и вернёт отказ операции |
|
||||
| ffmpeg, ffprobe | Запись останавливается признаком с текстом «сбой конвертации файла» — рубеж при этом сохраняется, и снятие признака продолжает с него. Остановка сервиса — исход другой: процесс убивают контекстом, запись остаётся на повтор и отказа не тратит | Конвейер стоит: вызов синхронный | — | Выходной файл пуст, отказ вылезет на распознавании |
|
||||
| Хранилище (файл на диске) | Приложение не стартует либо шаг падает на каждом запросе | Блокировка записи держит воркеры | — | — |
|
||||
| Диск | Запись файла падает, задача не заводится | — | — | — |
|
||||
|
||||
- **Кто заметит отказ и когда:** пользователь Telegram — сразу, по молчанию бота
|
||||
или по сообщению об ошибке. Владелец — по метрике
|
||||
- **Кто заметит отказ и когда:** тот, кто загрузил запись, — опросом готовности:
|
||||
остановленная запись отдаёт признак остановки. Владелец — по метрике
|
||||
`transcriber_worker_job_count` с меткой `error="true"`, и метка `stage`
|
||||
называет рубеж, с которого запись взята: с появлением пула одинаковых воркеров
|
||||
имя потока перестало что-либо значить, а разрез по шагу — единственное, чем
|
||||
@@ -178,15 +156,15 @@
|
||||
- **Журнал событий записи** — второй канал наблюдения, `record_events`. Пишется
|
||||
на смену рубежа, на остановку и на снятие остановки; читает его человек в
|
||||
панели, ни один шаг конвейера на него не смотрит. Экрана у него пока нет.
|
||||
- **Характер потока:** непрерывный, но разреженный. Бот держит длинный опрос,
|
||||
воркеры опрашивают базу вхолостую с паузой из
|
||||
- **Характер потока:** непрерывный, но разреженный. Воркеры опрашивают базу
|
||||
вхолостую с паузой из
|
||||
[database.md](database.md), «Настройки с числовым значением».
|
||||
|
||||
## Единые точки проекта
|
||||
|
||||
| Что | Где |
|
||||
| --- | --- |
|
||||
| Приём аудио и заведение записи | `TranscribeService.createRecord` — через него идут оба входа |
|
||||
| Приём аудио и заведение записи | `TranscribeService.createRecord` — единственный путь, которым запись появляется в хранилище |
|
||||
| Правка записи владельцем | панель хранилища; правка запросом проходит правила перехода (`pocketbase.BindPanelRules`), а шаг конвейера пишет только свои поля и правку владельца не стирает |
|
||||
| Захват записи воркером | `AudioRecordRepository.FindAndAcquire` — один запрос с `RETURNING`, отдаёт идентификатор и признак захвата |
|
||||
| Объявление рубежа | `internal/entity/stage.go` — выбор шага, отбор захвата, срок протухания и предел простоя выводятся отсюда |
|
||||
@@ -194,7 +172,7 @@
|
||||
| Рабочая копия файла на диске | `FileRepository.Localize`, `Stage`, `StageEmpty` — они же дают единственный способ её убрать (`WorkFile.Close`); зовёт его шаг |
|
||||
| Переход записи на рубеж | `entity.AudioRecord.MoveToState` — чистит служебные поля прошлого рубежа и ставит время входа |
|
||||
| Откладывание работы | `entity.AudioRecord.Postpone` — ставит паузу и снимает захват, рубежа не трогая |
|
||||
| Остановка и перезапуск | `entity.AudioRecord.Halt` и `Resume`; ответ отправителю — `TranscribeService.halt`, одно место на все причины |
|
||||
| Остановка и перезапуск | `entity.AudioRecord.Halt` и `Resume`; запись причины и события — `TranscribeService.halt`, одно место на все причины |
|
||||
| Разбор конфигурации | `internal/config.LoadConfig` |
|
||||
| Чтение времени | `internal/clock` — `Now` даёт метку в UTC, `Start` — начало измерения длительности; `time.Now` вне пакета запрещён правилом линтера |
|
||||
| Метрики | `internal/metrics`, префикс имени `transcriber_` |
|
||||
@@ -224,7 +202,8 @@
|
||||
[access](../openspec/specs/access/spec.md), решения —
|
||||
[ADR-2026-08-12-session-without-refresh](adr/ADR-2026-08-12-session-without-refresh.md)
|
||||
и [ADR-2026-08-12-oidc-exchange-via-own-route](adr/ADR-2026-08-12-oidc-exchange-via-own-route.md).
|
||||
**Не решено одно:** как связываются пользователь Telegram и пользователь веба.
|
||||
**Не решено одно:** как связать чат Telegram с учётной записью — от этого
|
||||
зависит возвращение убранного входа.
|
||||
Панель администратора при этом Authelia не закрывает: у неё свой пароль
|
||||
суперпользователя.
|
||||
- **Приложение.** Экранов нет вовсе, есть только API. Решено делать SPA,
|
||||
@@ -240,8 +219,8 @@
|
||||
Push с VAPID отвергнут. Появляется внешняя зависимость, которой сегодня нет, и
|
||||
текст расшифровки начинает уходить на сторону — сдвиг периметра
|
||||
[security.md](security.md).
|
||||
- **Долгие записи.** Потолок сегодня неизвестен и не замерялся: 20 МиБ на приём
|
||||
из Telegram — точно, ограничения `deferred-general` по длине — нет. Расчётные
|
||||
- **Долгие записи.** Потолок сегодня неизвестен и не замерялся: ограничения
|
||||
`deferred-general` по длине не выяснены. Расчётные
|
||||
шесть часов нормирует [storage](../openspec/specs/storage/spec.md), «Файл
|
||||
записи живёт в хранилище»; откуда взято число —
|
||||
[research/pocketbase-defaults.md](research/pocketbase-defaults.md), «Чего эта
|
||||
@@ -265,8 +244,9 @@
|
||||
- **Формат для распознавания.** Конвертер отдаёт ogg/vorbis (`libvorbis`), а
|
||||
SpeechKit получает `ContainerAudio_OGG_OPUS`. Расхождение не разобрано: то ли
|
||||
сервис определяет содержимое сам, то ли часть записей теряется на этом.
|
||||
- **Видео.** Дорожку из видеофайла бот принимает по MIME-типу `video/`, но
|
||||
конвертер этот случай не проверялся.
|
||||
- **Видео.** Дорожка из видеофайла к приёму допускается — расширение он берёт из
|
||||
имени и о годности содержимого спрашивает источник метаданных, — но конвертер
|
||||
на этом случае не проверялся.
|
||||
- **Очередь.** Модель очереди сделана задачей `pocketbase-storage` 2026-08-12
|
||||
([ADR](adr/ADR-2026-08-11-queue-as-pocketbase-collection.md)), перестроена
|
||||
вокруг аудиозаписи задачей `record-centric-model` 2026-08-14 и нормирована
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
**Взято из проекта jellybit.** Расхождения с сегодняшним кодом названы по месту.
|
||||
Главные: комментариями снабжена половина полей; единого места проверки на старте
|
||||
нет: у секций `[auth]` и `[telegram]` свой `Validate()` в `main.go`, а пустые
|
||||
нет: у секций `[auth]` и `[pipeline]` свой `Validate()` в `main.go`, а пустые
|
||||
ключи `[yandex]` ловит конструктор распознавателя.
|
||||
|
||||
**Механизировано:** запрет `os.Getenv` — `forbidigo` в `.golangci.yml`
|
||||
@@ -46,7 +46,6 @@
|
||||
port = <N> # порт HTTP-сервера
|
||||
shutdown_timeout = <N> # ждать мягкой остановки сервера, секунды
|
||||
force_shutdown_timeout = <N> # ждать остановки воркеров, секунды
|
||||
users_while_list = ["<@name>"] # кому отвечает бот; строка автора Telegram
|
||||
```
|
||||
|
||||
Значения намеренно заменены плейсхолдерами: предмет конвенции — форма
|
||||
@@ -58,9 +57,6 @@ users_while_list = ["<@name>"] # кому отвечает бот; стр
|
||||
|
||||
Секретные поля оставляем пустыми — значение приходит из выкладки (см. «Секреты»).
|
||||
|
||||
*Расхождение:* секции `[server]` в `config.example.toml` не хватает поля
|
||||
`users_while_list`, из-за чего бот на свежем конфиге отвечает отказом всем.
|
||||
|
||||
*Расхождение:* адреса провайдера в секции `[auth]` образца заполнены примерами
|
||||
вида `https://auth.example.com/...`, а не оставлены пустыми: пустой адрес не
|
||||
говорит, какой формы значение здесь ждут. Пустым оставлен только
|
||||
@@ -91,7 +87,7 @@ Ansible из `pet-project-server`). Приложение просто читае
|
||||
секретов в коде нет. Источник истины секрета — внешнее хранилище выкладки, не
|
||||
репозиторий и не окружение.
|
||||
|
||||
- Секретные поля transcriber: `telegram.bot_token`, `yandex.speech_kit_api_key`,
|
||||
- Секретные поля transcriber: `yandex.speech_kit_api_key`,
|
||||
`yandex.object_storage_access_key_id`,
|
||||
`yandex.object_storage_secret_access_key`, `auth.client_secret`.
|
||||
- Отрендеренный `config.toml` (с секретами) **не коммитится**; права `0600`,
|
||||
@@ -130,16 +126,8 @@ Ansible из `pet-project-server`). Приложение просто читае
|
||||
TOML. Пустые ключи Yandex ловятся в конструкторе распознавателя, и там процесс
|
||||
выходит с кодом 1. Единого места проверки нет.
|
||||
|
||||
Под это расхождение больше не подпадают два ключа секции `[telegram]` — признак
|
||||
включения и ключ доступа, — и проверок у них две. Третий ключ секции,
|
||||
`update_timeout`, границ по-прежнему не проверяет никто, и ноль в нём обращает
|
||||
длинный опрос в непрерывный. Обязательность признака включения судит загрузчик — только разбор отличает
|
||||
«ключ не задан» от «ключ задан ложным», потому что нулевое значение `bool` у
|
||||
обоих одинаковое. Заполненность ключа доступа судит `TelegramConfig.Validate()` из
|
||||
`main.go`, рядом с проверкой `[auth]`: пустой `bot_token` при `enabled = true` —
|
||||
ошибка настройки и отказ старта. Непустой негодный по-прежнему судится при сборке
|
||||
клиента, до подъёма сервера. Нормирует это `openspec/specs/intake`, «Признак
|
||||
включения решает, поднимается ли вход Telegram».
|
||||
Два ключа секции `[telegram]`, стоявшие здесь исключением, ушли вместе с самим
|
||||
входом 2026-08-14: секции больше нет, и своей проверки у неё тоже.
|
||||
|
||||
Секция `[auth]` — первая, у которой проверка своя и стоит на старте:
|
||||
`AuthConfig.Validate()` зовётся из `main.go` сразу после загрузки и роняет
|
||||
@@ -165,5 +153,6 @@ TOML. Пустые ключи Yandex ловятся в конструкторе
|
||||
комментария у самого поля), ни по нулевому значению типа; присутствие ключа
|
||||
судит **разбор** — `MetaData.IsDefined` из `toml.DecodeFile`, — потому что
|
||||
значение отличить «не задано» от «задано нулём» не позволяет. В
|
||||
`config.example.toml` у поля стоит значение свежей установки. Первое такое
|
||||
поле — `telegram.enabled`.
|
||||
`config.example.toml` у поля стоит значение свежей установки. Первым таким
|
||||
полем был `telegram.enabled`; секция убрана 2026-08-14, и живого примера у
|
||||
правила сейчас нет.
|
||||
|
||||
+12
-14
@@ -69,11 +69,10 @@ transcriber — **приложение, а не библиотека**: внеш
|
||||
`contract.JobNotFoundError` (состояние и сообщение), `contract.NoopJobError`
|
||||
(состояние), `contract.LostAcquisitionError` (идентификатор задачи).
|
||||
|
||||
`tg.EmptyBotTokenError` был ровно тем случаем, против которого написано правило —
|
||||
тип без полей, — и снят задачей `local-run-without-telegram-token` 2026-08-13;
|
||||
его место занял sentinel `telegram.ErrEmptyToken`. Рядом живёт
|
||||
`contract.ErrDeliveryChannelDown` — тоже sentinel и по той же причине: заглушка
|
||||
отправителя не знает ни задачи, ни чата, и нести ей нечего.
|
||||
Правило это однажды нарушал `tg.EmptyBotTokenError` — тип без полей, — и был
|
||||
снят задачей `local-run-without-telegram-token` 2026-08-13 в пользу sentinel'а.
|
||||
Оба ушли из проекта 2026-08-14 вместе с входом Telegram; пример остаётся здесь
|
||||
как случай, а не как живой код.
|
||||
|
||||
## Граница и трансляция: приватный и публичный канал
|
||||
|
||||
@@ -83,8 +82,8 @@ transcriber — **приложение, а не библиотека**: внеш
|
||||
- **Приватный канал — логи** (владелец сервиса). Полная ошибка со всей цепочкой
|
||||
`%w` и контекстом. Пишется один раз на доменной границе — см.
|
||||
[logging.md](logging.md).
|
||||
- **Публичный канал — пользовательские поверхности** (Telegram, веб-UI, HTTP
|
||||
API). Сюда отдаём:
|
||||
- **Публичный канал — пользовательские поверхности** (веб-UI, HTTP API). Сюда
|
||||
отдаём:
|
||||
- **человекочитаемое сообщение** по доменной ошибке — не сырой `err.Error()` и
|
||||
не детали реализации (`database/sql`, пути на диске, имена внешних сервисов);
|
||||
- **корреляционный ключ** для владельца — идентификатор задачи, чтобы по нему
|
||||
@@ -118,8 +117,7 @@ transcriber — **приложение, а не библиотека**: внеш
|
||||
|
||||
У публичной границы две поверхности, и правило сырого текста для них разное.
|
||||
|
||||
- **Разовый ответ на действие** (тело HTTP-ответа, сообщение бота по результату
|
||||
команды) — строго нейтральный: отображение выше, `err.Error()` наружу не идёт,
|
||||
- **Разовый ответ на действие** (тело HTTP-ответа) — строго нейтральный: отображение выше, `err.Error()` наружу не идёт,
|
||||
полная ошибка остаётся в логах по идентификатору задачи.
|
||||
- **Сохранённая диагностика состояния** — колонка `error_text` задачи. Это
|
||||
**поверхность владельца**, а не пользователя: сюда сырой текст ошибки допустим
|
||||
@@ -131,9 +129,9 @@ transcriber — **приложение, а не библиотека**: внеш
|
||||
- **внешнее значение в тексте усекается на границе, а его размер называется
|
||||
числом рядом**: без этого непонятно, насколько сокращать.
|
||||
|
||||
*Расхождение:* `error_text` пишется целиком, без вычистки и без усечения, а
|
||||
пользователь Telegram видит отдельный человекочитаемый текст — это часть
|
||||
правила соблюдена.
|
||||
*Расхождение:* `error_text` пишется целиком, без вычистки и без усечения.
|
||||
Наружу он при этом не выходит: опрос готовности отдаёт признак остановки без
|
||||
машинного текста — эту часть правила держит спека `intake`.
|
||||
|
||||
## panic
|
||||
|
||||
@@ -145,8 +143,8 @@ transcriber — **приложение, а не библиотека**: внеш
|
||||
ронял процесс. В transcriber его вешает роутер хранилища сам
|
||||
(`apis.panicRecover`, слой с идентификатором `DefaultPanicRecoverMiddlewareId`
|
||||
на каждом роутере PocketBase): паникующий обработчик отдаёт `500`, процесс
|
||||
живёт. Своего слоя мы не пишем. У воркеров и у бота такой границы **нет**:
|
||||
паника в шаге конвейера роняет процесс целиком.
|
||||
живёт. Своего слоя мы не пишем. У воркеров такой границы **нет**: паника в
|
||||
шаге конвейера роняет процесс целиком.
|
||||
|
||||
## Несколько ошибок
|
||||
|
||||
|
||||
@@ -78,7 +78,7 @@
|
||||
| --- | --- |
|
||||
| Сравнение ошибок через `errors.Is` и `errors.As`, не `==` и не приведением типа | `.golangci.yml` → `errorlint` |
|
||||
| Ошибка не узнаётся сравнением текста сообщения (`strings.Contains(err.Error(), …)`, `err.Error() == …`) | `internal/archrules` → `TestОшибкаНеУзнаётсяПоТексту` |
|
||||
| Непроверенное возвращаемое значение ошибки | `.golangci.yml` → `errcheck`, включая присваивание в `_` (`check-blank`). Отказ, который решено не проверять, объявляют в `exclude-functions` поимённо — там сегодня `defer Close`, `os.Remove` и `send` |
|
||||
| Непроверенное возвращаемое значение ошибки | `.golangci.yml` → `errcheck`, включая присваивание в `_` (`check-blank`). Отказ, который решено не проверять, объявляют в `exclude-functions` поимённо — там сегодня `defer Close` и `os.Remove` |
|
||||
| Непроверенное приведение типа (`v := x.(T)`) | `.golangci.yml` → `errcheck` с `check-type-assertions`. Отдельная настройка, потому что такое приведение паникует, а не возвращает ошибку, и `check-blank` его не видит |
|
||||
| Проверенный отказ не оборачивается в `return nil` | `.golangci.yml` → `nilerr`. Механизирует половину инварианта «принятая запись не теряется молча»: молчаливый успех после отказа |
|
||||
| Отказ выборки из хранилища не теряется (`rows.Err()`), а сама выборка закрывается | `.golangci.yml` → `rowserrcheck`, `sqlclosecheck`. **Профилактические: предмета в коде сегодня нет** — выборки идут через `dbx` хранилища, а из `database/sql` употребляются только `sql.NullString` и `sql.ErrNoRows`. Правила заведены на будущий сырой запрос; мутацией проверены на пробе, а не на своём коде |
|
||||
@@ -89,7 +89,7 @@
|
||||
| Правило | Где механизировано |
|
||||
| --- | --- |
|
||||
| Ядро (`internal/service`) не знает ни адаптеров, ни транспортов | `internal/archrules` → `TestЯдроНеЗнаетОбАдаптерах`, `TestЯдроНеЗнаетОТранспортах` |
|
||||
| Транспорты (`controller/http`, `controller/tg`, `controller/worker`) не знают друг о друге | `internal/archrules` → `TestТранспортыНеЗнаютДругОДруге` |
|
||||
| Транспорты (`controller/http`, `controller/worker`) не знают друг о друге | `internal/archrules` → `TestТранспортыНеЗнаютДругОДруге` |
|
||||
| Адаптер не знает ни ядра, ни транспортов | `internal/archrules` → `TestАдаптерыНеЗнаютНиЯдра_НиТранспортов` |
|
||||
| Колонки записи согласованы: что пишет отображение ↔ что читает обратное ↔ что заводит шаг схемы | `internal/archrules` → правила о колонках. Закрывает инвариант «колонки записи правятся в двух местах» (CLAUDE.md, major), которого компилятор не держит. Литерал колонки ищется в телах нужных функций, а не в файле целиком |
|
||||
| Рубежи согласованы: дескриптор ↔ таблица выбора шага, в обе стороны | `internal/archrules` → правила о рубежах. Закрывает инвариант «рубеж объявляется одним дескриптором» (CLAUDE.md, major). Рубеж без шага останавливает запись, не начав работы; шаг без рубежа недостижим — захват такую запись не выдаст никогда |
|
||||
@@ -117,7 +117,7 @@
|
||||
| --- | --- |
|
||||
| Проверка судит ответ по готовому ответу (`Result()`), а не по живой карте заголовков обработчика | `.golangci.yml` → `forbidigo` с `analyze-types`, находки только в `*_test.go`. Судит по типу приёмника (`httptest.ResponseRecorder`), поэтому ловит любую форму: цепочкой, через переменную, по индексу карты, обходом, полем `HeaderMap`. Остаётся ревью проверка, идущая мимо recorder — через свой `http.ResponseWriter` |
|
||||
| Форма утверждения в проверках: «ожидалось» и «получено» не перепутаны местами, отказ судится `NoError`, а не `Nil`, `require` не зовут из горутины | `.golangci.yml` → `testifylint` |
|
||||
| Одновременный доступ проверен детектором, а не чтением кода | `Taskfile.yml` → шаг `tests` (`go test -race ./...`). Общее у воркеров — счётчики метрик, логгер и клиент бота; захват задачи в гонку не входит, он по построению её не даёт (одно состояние на воркер) — см. «Типовые ложноположительные» в [../review.md](../review.md). Что делает шаг без компилятора C и каким кодом краснеет — [CLAUDE.md](../../CLAUDE.md), «Гейт» |
|
||||
| Одновременный доступ проверен детектором, а не чтением кода | `Taskfile.yml` → шаг `tests` (`go test -race ./...`). Общее у воркеров — счётчики метрик и логгер; захват задачи в гонку не входит, он по построению её не даёт (одно состояние на воркер) — см. «Типовые ложноположительные» в [../review.md](../review.md). Что делает шаг без компилятора C и каким кодом краснеет — [CLAUDE.md](../../CLAUDE.md), «Гейт» |
|
||||
| Строчное подавление называет линтер и причину, а протухшее краснеет | `.golangci.yml` → `nolintlint` (`require-explanation`, `require-specific`, `allow-unused: false`) |
|
||||
|
||||
### Форма кода и файлов вне Go
|
||||
@@ -152,7 +152,7 @@
|
||||
|
||||
| Подавлено | Где | Почему |
|
||||
| --- | --- | --- |
|
||||
| `errcheck` на `defer Close`, `os.Remove` и `send` | `.golangci.yml`, `exclude-functions` | Отказ, который решено не проверять, объявляют поимённо — так он заметен |
|
||||
| `errcheck` на `defer Close` и `os.Remove` | `.golangci.yml`, `exclude-functions` | Отказ, который решено не проверять, объявляют поимённо — так он заметен |
|
||||
| Правило о заголовках вне `*_test.go` | `.golangci.yml`, `exclusions` | В рабочем коде `Header()` и есть способ отдать заголовок |
|
||||
| `time.Now` внутри `internal/clock` | там же | Единой точке чтения времени нечем читать время иначе |
|
||||
| Чтение времени и окружения в `*_test.go` | там же | Проверка строит вход прогона — фикстуру времени, `PATH`, окружение дочернего процесса, — а не метку домена и не настройки приложения. Исключение объявлено по тексту сообщения: правило называет четыре имени, и исключение обязано покрывать те же четыре |
|
||||
|
||||
+16
-29
@@ -28,7 +28,7 @@ OpenSpec.
|
||||
`jq` без регулярных выражений.
|
||||
|
||||
```json
|
||||
{"time":"2026-08-10T11:23:45.123456Z","level":"INFO","msg":"record accepted","capability":"intake","record_id":"…","source":"telegram","duration_seconds":137}
|
||||
{"time":"2026-08-10T11:23:45.123456Z","level":"INFO","msg":"record accepted","capability":"intake","record_id":"…","source":"api","duration_seconds":137}
|
||||
```
|
||||
|
||||
*Расхождение:* `main.go` ставит `slog.NewTextHandler(os.Stdout, …)`.
|
||||
@@ -37,7 +37,7 @@ OpenSpec.
|
||||
|
||||
- `msg` — короткая константа в нижнем регистре: `record accepted`,
|
||||
`recognition done`, `conversion failed`. Данные — в атрибутах:
|
||||
`log.Info("record accepted", "record_id", id, "source", "telegram")`.
|
||||
`log.Info("record accepted", "record_id", id, "source", "api")`.
|
||||
- `msg` — чистая категория без префикса подсистемы: `recognition done`, а не
|
||||
`recognize: done`. Подсистему выносим в поле `capability`, не в текст.
|
||||
- **Смена состояния задачи — единая категория `state transition`** с полями
|
||||
@@ -60,7 +60,7 @@ OpenSpec.
|
||||
| `DEBUG` | разработчику при отладке; в продакшене выключен | `GET /health`, пустой прогон воркера, проверка готовности операции распознавания, тела запросов и ответов внешних сервисов |
|
||||
| `INFO` | владельцу, разбор постфактум | приём записи, переход задачи, конвертация выполнена, текст отправлен, старт и остановка процессов, **событийный вызов внешнего сервиса** |
|
||||
| `WARN` | владельцу, «может стать проблемой» | повтор внешнего вызова, задача досталась повторно по истечении захвата, пустой текст распознавания |
|
||||
| `ERROR` | владельцу, в разбор | внешний сервис недоступен, задача ушла в `failed`, необработанная ошибка |
|
||||
| `ERROR` | владельцу, в разбор | внешний сервис недоступен, запись остановлена признаком, необработанная ошибка |
|
||||
|
||||
Правила:
|
||||
|
||||
@@ -99,7 +99,7 @@ OpenSpec.
|
||||
|
||||
| Когда добавляем | Поля |
|
||||
| --- | --- |
|
||||
| на входящий HTTP-запрос | `transport` (`http`, `telegram`), `http.method`, `http.route`, `http.status_code`, `duration_ms` |
|
||||
| на входящий HTTP-запрос | `transport` (`http`), `http.method`, `http.route`, `http.status_code`, `duration_ms` |
|
||||
| на задачу | `capability` (значения — по именам заведённых capability в `openspec/specs/`), `record_id`, `file_id`, `source` |
|
||||
| на запись об ошибке | `error` |
|
||||
| на вызов внешнего сервиса | `ext.service`, `ext.operation`, `ext.status_code`, `duration_ms`, `retry` |
|
||||
@@ -137,7 +137,7 @@ log := log.With("record_id", record.Id, "capability", "conversion")
|
||||
- Логируем ошибку **один раз — на границе доменного слоя**, которая определяет
|
||||
исход операции. Логирует эта единая точка, а не каждый транспорт — так
|
||||
транспорты остаются тонкими. Границы в transcriber:
|
||||
- приём записи (`CreateJobFromTelegram`, `CreateJobFromApi`);
|
||||
- приём записи (`CreateJobFromApi`);
|
||||
- **шаг конвейера** (`FindAndRunConversionJob`, `FindAndRunTranscribeJob`,
|
||||
`FindAndRunTranscribeCheckJob`) — исход шага, вызванного циклом воркера;
|
||||
- завершение и отказ задачи (`completeJob`, `failJob`).
|
||||
@@ -169,9 +169,9 @@ log := log.With("record_id", record.Id, "capability", "conversion")
|
||||
|
||||
**Каждый** вызов внешнего сервиса логируется. Поля:
|
||||
|
||||
- `ext.service` — `telegram`, `speechkit`, `object-storage`, `ffmpeg`;
|
||||
- `ext.operation` — логическая операция (`getFile`, `sendMessage`,
|
||||
`RecognizeFile`, `GetOperation`, `PutObject`, `convert`);
|
||||
- `ext.service` — `speechkit`, `object-storage`, `ffmpeg`;
|
||||
- `ext.operation` — логическая операция (`RecognizeFile`, `GetOperation`,
|
||||
`PutObject`, `convert`);
|
||||
- `ext.status_code` — код ответа, если применим;
|
||||
- `duration_ms` — длительность вызова;
|
||||
- `retry` — номер попытки, если повторы были.
|
||||
@@ -191,8 +191,7 @@ log := log.With("record_id", record.Id, "capability", "conversion")
|
||||
|
||||
*Расхождение:* обёртки `ext.*` нет. Из внешних вызовов логируется только
|
||||
конвертация (через метрику длительности) и запуск распознавания; заливка в
|
||||
Object Storage, скачивание файла из Telegram и опрос операции не логируются
|
||||
никак.
|
||||
Object Storage и опрос операции не логируются никак.
|
||||
|
||||
## HTTP и проверка здоровья
|
||||
|
||||
@@ -218,7 +217,6 @@ Object Storage, скачивание файла из Telegram и опрос оп
|
||||
|
||||
Никаких секретов в полях и сообщениях. Под запретом:
|
||||
|
||||
- токен бота Telegram;
|
||||
- ключ SpeechKit и заголовок `Authorization`;
|
||||
- пара ключей Object Storage;
|
||||
- **сам текст расшифровки и имена файлов пользователя** — это содержимое личной
|
||||
@@ -234,28 +232,17 @@ Object Storage, скачивание файла из Telegram и опрос оп
|
||||
- При сомнении не логируем значение, логируем факт его наличия
|
||||
(`"has_api_key", true`).
|
||||
- **Ошибка HTTP-транспорта несёт URL — возможный носитель секрета.**
|
||||
`*url.Error` из `net/http` встраивает полный URL запроса, а токен Telegram
|
||||
живёт прямо в пути (`…/bot<TOKEN>/…`). Такую ошибку разворачивают в
|
||||
`*url.Error` из `net/http` встраивает полный URL запроса, а секрет иногда
|
||||
живёт прямо в пути. Такую ошибку разворачивают в
|
||||
первопричину на границе клиента **до** лога и до обёртки: URL отбрасывается,
|
||||
проверка `errors.Is` на причину сохраняется. Общее правило: **секрет не кладём
|
||||
в URL, если у сервиса есть заголовок** — тогда его нет и в ошибке транспорта.
|
||||
|
||||
Обращения к Telegram этому правилу следуют, и точка чистки одна на все вызовы —
|
||||
`internal/adapter/telegram`, `NewBot`. Токен стоит в пути **каждого** обращения к
|
||||
Bot API, поэтому чистка на месте употребления закрывала бы один вызов из всех:
|
||||
|
||||
- отказ транспорта разворачивает в первопричину клиент бота (`safeClient`), а
|
||||
библиотека отдаёт наш отказ вызывающему нетронутым — этим закрыты `getFile`,
|
||||
`sendMessage`, скачивание записи и `getMe` из конструктора;
|
||||
- длинный опрос печатает свои отказы **пакетным логгером самой библиотеки**,
|
||||
минуя наш `slog`; логгер подменён на вычищающий (`tgbotapi.SetLogger`), и
|
||||
замена точная — токен известен.
|
||||
|
||||
Прежде здесь стоял `http.Get(file.Link(token))`, отказ уезжал в журнал вместе с
|
||||
токеном, а конвенция числила это расхождением с оценкой «не логируется», которая
|
||||
была неверной. Запись — [../review.md](../review.md), 2026-08-13; оракулом
|
||||
служат проверки `internal/adapter/telegram/bot_test.go`, судящие по тексту
|
||||
отказа и строке журнала.
|
||||
Живого случая у этого правила сейчас нет: единственный секрет, стоявший в пути
|
||||
обращения, — токен бота, и он ушёл вместе с входом Telegram 2026-08-14. Разбор
|
||||
случая и цена промаха записаны в [../review.md](../review.md), 2026-08-13:
|
||||
конвенция числила утечку расхождением с оценкой «не логируется», и оценка была
|
||||
неверной.
|
||||
|
||||
*Изъятие, а не расхождение:* расширение берётся из имени отправителя дословно
|
||||
(`filepath.Ext`), поэтому имя `запись.тайное-слово` отдаёт приватный хвост
|
||||
|
||||
@@ -108,5 +108,5 @@
|
||||
узнала»).
|
||||
- **Устройство service worker и версионирование статики** — задача
|
||||
[installable-pwa](../../tasks/items/installable-pwa.md).
|
||||
- **Как связываются пользователь Telegram и пользователь веба** — открытый вопрос
|
||||
- **Как связать чат Telegram с учётной записью** — открытый вопрос; от него зависит возвращение убранного 2026-08-14 входа
|
||||
«Учётные записи» в [../architecture.md](../architecture.md).
|
||||
|
||||
+15
-13
@@ -47,7 +47,7 @@ CGO сборке не нужен.
|
||||
| --- | --- | --- |
|
||||
| `id` | TEXT PK | Идентификатор записи, выдаёт хранилище |
|
||||
| `file` | file | Сам файл |
|
||||
| `owner` | relation → `users` | Владелец файла; пусто у файлов записи, принятой ботом |
|
||||
| `owner` | relation → `users` | Владелец файла; пустого значения не принимает |
|
||||
| `location` | select | `local` или `s3` |
|
||||
| `object_key` | TEXT | Ключ объекта; заведён прежним шагом и новым путём не заполняется |
|
||||
| `size` | INTEGER | Размер в байтах |
|
||||
@@ -66,8 +66,8 @@ capability, и третий смысл развёл бы одно слово п
|
||||
| Поле | Тип | Что |
|
||||
| --- | --- | --- |
|
||||
| `id` | TEXT PK | Идентификатор записи, выдаёт хранилище |
|
||||
| `owner` | relation → `users` | Владелец записи; пусто у записей, принятых ботом |
|
||||
| `source` | select | `api`, `telegram`, `unknown` |
|
||||
| `owner` | relation → `users` | Владелец записи; пустого значения не принимает |
|
||||
| `source` | select | `api`, `unknown`; значение `telegram` осталось историческим — вход убран, новых записей с ним не появляется |
|
||||
| `title`, `brief` | TEXT | Заголовок и краткое описание: читаются вместе со списком |
|
||||
| `state` | select | Рубеж: `uploaded`, `normalized`, `submitted`, `transcribed`, `done`; перечень закрыт схемой |
|
||||
| `state_entered_at` | DATETIME | Время входа в рубеж — сторож застревания |
|
||||
@@ -84,8 +84,8 @@ capability, и третий смысл развёл бы одно слово п
|
||||
| `structure` | relation → `structures` | Структура реплик |
|
||||
| `recognition` | relation → `recognitions` | Попытка распознавания |
|
||||
| `topics` | relation → `topics`, до 5 | Темы записи |
|
||||
| `tg_chat_id` | INTEGER | Куда отправить результат |
|
||||
| `tg_reply_message_id` | INTEGER | С каким сообщением связать |
|
||||
| `tg_chat_id` | INTEGER | Адресат ответа у записи убранного входа; кодом не читается |
|
||||
| `tg_reply_message_id` | INTEGER | Ответное сообщение у неё же; кодом не читается |
|
||||
| `created`, `updated` | DATETIME | Проставляет хранилище |
|
||||
|
||||
Индекс один — по паре «рубеж и признак остановки»: выборка захвата идёт по ним,
|
||||
@@ -188,10 +188,15 @@ capability, и третий смысл развёл бы одно слово п
|
||||
висела бы в панели вторым домом для понятия, которого больше нет.
|
||||
|
||||
**Владелец записи** заведён шагом `202608140001` — связью с коллекцией `users` в
|
||||
обеих таблицах. Пустое значение допустимо, и это решение с ценой: записи,
|
||||
принятые ботом, владельца не имеют вовсе, потому что связи чата Telegram с
|
||||
учётной записью сервис не ведёт. Обязательность для приёма по HTTP держит поэтому
|
||||
сам приём, а не схема.
|
||||
обеих таблицах, — и шагом `202608140003` пустого значения больше не принимает.
|
||||
Прежде принимал, и цену за это платили записи входа Telegram: связи чата с
|
||||
учётной записью сервис не вёл. Вход убран 2026-08-14, ничью запись заводить стало
|
||||
некому, и обязательность переехала из приёма в схему — туда, где её держит
|
||||
хранилище, а не договорённость.
|
||||
|
||||
**Колонки `tg_chat_id` и `tg_reply_message_id`** остались от убранного входа и
|
||||
кодом больше не читаются. Из схемы они не убираются: заводили их применённые
|
||||
шаги `202608110001` и `202608140002`, а применённый шаг не переписывается.
|
||||
|
||||
Выборка по владельцу сужает **чтение записи**: чужая, ничья и несуществующая
|
||||
дают один и тот же отказ. Выборку воркера владелец не сужает — конвейер
|
||||
@@ -288,11 +293,8 @@ capability, и третий смысл развёл бы одно слово п
|
||||
| Задержка перед первой проверкой операции | 10 секунд | `service/transcribe.go` | как было |
|
||||
| Задержка между проверками операции | 5 секунд | там же | как было |
|
||||
| Пауза воркера между прогонами | 1 секунда | `controller/worker/worker.go` | как было |
|
||||
| Предел длины сообщения Telegram | 4000 символов | `adapter/telegram/sender.go` | предел Telegram |
|
||||
| Таймаут мягкой остановки | 5 секунд | конфиг, `[server] shutdown_timeout` | — |
|
||||
| Таймаут жёсткой остановки | 20 секунд | конфиг, `[server] force_shutdown_timeout` | — |
|
||||
| Таймаут обновлений Telegram | 10 секунд | конфиг, `[telegram] update_timeout` | — |
|
||||
| Срок ожидания Telegram при сборке клиента | 10 секунд | `adapter/telegram.ProbeTimeout` | решение, не замер: одно обращение за `getMe` укладывается в доли секунды, дольше Telegram считается недоступным и сервис поднимается без него. Длинный опрос этим сроком не ограничен — клиент подменяется сразу после сборки |
|
||||
| Качество кодирования vorbis | `-q:a 4` | `adapter/converter/ffmpeg/ffmpeg.go` | — |
|
||||
| Жизнь приглашения завести владельца панели | 30 минут | умолчание PocketBase | — |
|
||||
| Потолок размера одной записи | 8 ГиБ | `entity.MaxRecordSize` | расчётный потолок в шесть часов с запасом на видео |
|
||||
@@ -320,4 +322,4 @@ capability, и третий смысл развёл бы одно слово п
|
||||
Чего среди настроек **нет**: режим журналирования, таймаут занятости и размер
|
||||
пула соединений задаёт хранилище своими умолчаниями, а не мы; срока хранения
|
||||
файлов и объектов нет вовсе. Таймаутов у
|
||||
обращений к Telegram, S3 и SpeechKit тоже нет — ни одного.
|
||||
обращений к S3 и SpeechKit тоже нет — ни одного.
|
||||
|
||||
+13
-16
@@ -21,12 +21,14 @@
|
||||
| --- | --- |
|
||||
| Владелец сервиса | Загрузить диктофонную запись или видео из семейного архива с телефона и получить текст. Видеть, кто сколько загрузил и во что это обошлось |
|
||||
| Приглашённый пользователь | Войти в приложение через свою учётную запись, загрузить запись, забрать текст, вернуться к ней через месяц. Приложение ставится на телефон; каждый видит только свои записи |
|
||||
| Пользователь Telegram | Отправить боту голосовое сообщение и получить текст ответом. Работает сегодня |
|
||||
| Внешняя программа | Отдать файл по HTTP, представившись своим токеном, и опросить готовность. Сегодня почти не работает: приём и опрос закрыты сессией OIDC, а своего токена у программы нет — годится только чужая сессия, снятая из браузера и предъявленная кукой либо заголовком `Authorization`. Токен приносит `api-tokens` |
|
||||
|
||||
**Основной вход — приложение**, бот и HTTP API дополняют его. До 2026-08-11
|
||||
основным был бот, и порядок здесь перевёрнут сознательно: диктофонная запись на
|
||||
несколько часов через Telegram не проходит вовсе.
|
||||
**Вход у сервиса один — HTTP API**, и приложение строится поверх него. До
|
||||
2026-08-11 основным входом был Telegram-бот. 2026-08-11 основным объявили
|
||||
приложение: диктофонная запись на несколько часов через Telegram не проходит
|
||||
вовсе. 2026-08-14 бот убран целиком — временно, до задачи, которая свяжет чат с
|
||||
учётной записью. Вместе с ним из потребителей ушёл пользователь
|
||||
Telegram.
|
||||
|
||||
Цель достигнута, когда:
|
||||
|
||||
@@ -35,8 +37,8 @@
|
||||
- запись расчётного потолка — шести часов — доходит до текста, а не прерывается
|
||||
ошибкой при достижении предела (норма — `openspec/specs/storage`);
|
||||
- сервисом пользуются несколько человек, и записи одного не видны другому;
|
||||
- текст доступен там же, где загружали, — в приложении и в Telegram. Человек
|
||||
узнаёт о его готовности, не держа приложение открытым;
|
||||
- текст доступен там же, где загружали. Человек узнаёт о его готовности, не
|
||||
держа приложение открытым;
|
||||
- расшифровка не теряется: к записи возвращаются через месяц и находят её по
|
||||
заголовку и темам;
|
||||
- владелец видит расход по каждому пользователю и понимает, во что обходится
|
||||
@@ -90,21 +92,16 @@
|
||||
2. **Возвращение к записи.** Через месяц человек открывает список, находит
|
||||
запись по заголовку или теме и читает вычитанный текст, а при нужде — сырую
|
||||
расшифровку.
|
||||
3. **Голосовое из Telegram.** Пользователь шлёт боту голосовое сообщение, бот
|
||||
отвечает «обрабатываю», через минуту приходит текст ответом на то же
|
||||
сообщение. Записи, чей текст длиннее предела сообщения Telegram, приходят
|
||||
несколькими частями. Работает сегодня.
|
||||
4. **Файл через Telegram.** То же для аудиофайла или документа с аудио: бот
|
||||
отличает их по MIME-типу и расширению. Работает сегодня.
|
||||
5. **Загрузка по HTTP.** Программа шлёт `POST /api/audio` со своим токеном,
|
||||
3. **Загрузка по HTTP.** Программа шлёт `POST /api/audio` со своим токеном,
|
||||
получает идентификатор задачи и опрашивает `GET /api/status/:id`, пока не
|
||||
увидит `done` и текст. Сегодня доступно только предъявившему сессию OIDC:
|
||||
анонимный запрос обоими адресами отклоняется. Своего входа у программы нет —
|
||||
его заводит `api-tokens`. Записи при этом разграничены: программа с чужой
|
||||
сессией видит только записи того, чью сессию предъявила.
|
||||
6. **Отказ на середине.** Конвертация или распознавание не удались — задача
|
||||
переходит в `failed`, а пользователь получает сообщение о том, что именно не
|
||||
вышло, и предложение повторить.
|
||||
4. **Отказ на середине.** Конвертация или распознавание не удались — запись
|
||||
получает признак остановки с причиной, и опрос готовности отдаёт этот признак
|
||||
тому, кто её загрузил. Сообщения о неудаче сервис никому не шлёт: доставка
|
||||
ушла вместе с ботом, а уведомления заводит задача `ntfy-delivery`.
|
||||
|
||||
## Референсы
|
||||
|
||||
|
||||
@@ -4,14 +4,16 @@
|
||||
[записки разведки](pocketbase.md) отличаются предметом: та мерила, **что даёт
|
||||
панель**, эта — **что библиотека делает молча**, если её не переубедить.
|
||||
|
||||
Все четыре наблюдения нашлись ревью, а не чтением документации: три из них
|
||||
выглядят как «значение по умолчанию — нет ограничения», а значат обратное.
|
||||
Наблюдения нашлись ревью, а не чтением документации, и все об одном роде промаха:
|
||||
объявление библиотеки выглядит как «ограничения нет» либо «ограничение есть», а
|
||||
значит обратное.
|
||||
|
||||
## Как снималось
|
||||
|
||||
Версия **0.39.10**, та же, что у первой записки. Прогоны — на пустом каталоге
|
||||
данных во временном каталоге и на поднятом сервере `127.0.0.1:18099`; боевые
|
||||
данные и ключи не участвовали. Числа ниже сняты 2026-08-11 и 2026-08-12.
|
||||
данные и ключи не участвовали. Числа сняты 2026-08-11 и 2026-08-12, последнее
|
||||
наблюдение — 2026-08-15.
|
||||
|
||||
## Нулевой потолок у поля файла значит 5 МиБ, а не «без предела»
|
||||
|
||||
@@ -86,6 +88,34 @@ core/field_file.go:310 if f.MaxSize <= 0 { return DefaultFileFieldMaxSize }
|
||||
прогоном: при первом запуске строка со ссылкой в журнале есть, после заведения
|
||||
владельца при следующем запуске её нет.
|
||||
|
||||
## Обязательность связи проверяется у записи, а не у колонки
|
||||
|
||||
`Required` у поля связи — правило **проверки записи при сохранении**, а не
|
||||
ограничение таблицы. Шаг схемы, объявляющий колонку обязательной на базе, где уже
|
||||
лежат строки с пустым значением, проходит **зелёным** и такие строки оставляет:
|
||||
|
||||
```
|
||||
core/field_relation.go:156 ColumnType отдаёт TEXT DEFAULT '' NOT NULL — от Required не зависит
|
||||
core/collection_validate.go ни одной проверки, читающей существующие строки
|
||||
```
|
||||
|
||||
Проверено прогоном 2026-08-15 на копии хранилища во временном каталоге: строка с
|
||||
пустым владельцем заведена до шага, шаг применён тем же кодом, что и на подъёме,
|
||||
и вывод:
|
||||
|
||||
```
|
||||
STEP 003 (Required=true) поверх ничьей записи: err=<nil>
|
||||
ПОСЛЕ ШАГА: строка на месте, owner=""
|
||||
Save остановленной ничьей записи: err=failed to update audio record: owner: cannot be blank.
|
||||
```
|
||||
|
||||
Следствие для нас: оставленная строка становится **незакрываемой**. Захват идёт
|
||||
сырым запросом мимо проверки и выдаёт её воркеру, а всякое сохранение отказывает —
|
||||
включая то, которым ставится признак остановки. Искать такие строки надо запросом
|
||||
до выкладки, а не прогоном самого шага: прогон чистую базу от грязной не
|
||||
отличает. Цена решения записана в
|
||||
[adr/ADR-2026-08-15-owner-required-by-schema.md](../adr/ADR-2026-08-15-owner-required-by-schema.md).
|
||||
|
||||
## Чего эта записка не узнала
|
||||
|
||||
- **Во что обходится потолок в 8 ГиБ на диске.** Число выбрано расчётом из
|
||||
@@ -96,3 +126,6 @@ core/field_file.go:310 if f.MaxSize <= 0 { return DefaultFileFieldMaxSize }
|
||||
— нет.
|
||||
- **Поведение под одновременной правкой панели и конвейера в бою.** Проверено
|
||||
тестом на одной машине, не живой нагрузкой.
|
||||
- **Сколько строк с пустой связью выдерживает смена признака обязательности.**
|
||||
Проверено на одной строке: суть наблюдения — сам факт отсутствия проверки, а не
|
||||
её цена на объёме.
|
||||
|
||||
+91
-38
@@ -51,14 +51,14 @@
|
||||
- повтор шага на той же задаче не создаёт лишних файлов и записей;
|
||||
- отвечает пользователю ровно один раз.
|
||||
|
||||
**Транспорт** (`internal/controller/tg`, `internal/controller/http`):
|
||||
**Транспорт** (`internal/controller/http`):
|
||||
|
||||
- проверяет право отправителя до всякой работы;
|
||||
- не логирует ошибку, которую уже залогировал доменный слой;
|
||||
- переводит доменную ошибку в свой ответ, а не отдаёт сырой текст;
|
||||
- закрывает то, что открыл, на всех ветках выхода.
|
||||
|
||||
**Клиент внешнего сервиса** (`adapter/recognizer/yandex`, `adapter/telegram`):
|
||||
**Клиент внешнего сервиса** (`adapter/recognizer/yandex`):
|
||||
|
||||
- имеет таймаут и не виснет, когда внешний сервис не отвечает;
|
||||
- не кладёт секрет в URL и не даёт ему утечь через ошибку транспорта;
|
||||
@@ -121,17 +121,19 @@
|
||||
- **«Файлы и объекты не удаляются, диск растёт».** Факт верный и записан в
|
||||
[database.md](database.md); срок хранения не задан сознательно, задачи на него нет.
|
||||
Новой находкой это не считается, пока не измерен рост.
|
||||
- **«Запись без владельца не достаётся никому».** Не дефект: владельца не имеют
|
||||
записи, принятые ботом, — связи чата Telegram с учётной записью приложения
|
||||
сервис не ведёт, её заводит `telegram-account-link`. Ответ такой записи по API
|
||||
совпадает с ответом на несуществующую, и это норма — расшифровку отправитель
|
||||
получает в чат.
|
||||
- **«Запись без владельца не достаётся никому».** Строка отменена **дважды**, и
|
||||
обе отмены оставлены намеренно: прогон, помнящий любую из прежних редакций,
|
||||
иначе выбросил бы настоящую находку как известную.
|
||||
|
||||
**Прежняя редакция этой строки отменена 2026-08-14.** До задачи
|
||||
`record-ownership` здесь стояло «вошедший видит чужие записи — не дефект и не
|
||||
новость»: разграничения не было сознательно. Теперь оно есть, и такая находка
|
||||
настоящая. Строка оставлена вместо удаления намеренно: прогон, помнящий её
|
||||
прежний вид, выбросил бы регрессию не глядя.
|
||||
До задачи `record-ownership` здесь стояло «вошедший видит чужие записи — не
|
||||
дефект и не новость»: разграничения не было сознательно. Первая отмена
|
||||
2026-08-14 завела разграничение и объявила не дефектом уже другое — запись без
|
||||
владельца, принятую ботом.
|
||||
|
||||
Вторая отмена того же дня, задачей `remove-telegram-intake`, сняла и это:
|
||||
колонка владельца пустого значения больше не принимает, ничьих записей у
|
||||
сервиса не бывает вовсе. **Запись без владельца сегодня — настоящая находка**,
|
||||
а не известное исключение.
|
||||
|
||||
### Вопросы по темам
|
||||
|
||||
@@ -145,10 +147,10 @@
|
||||
делает с задачей, деньгами и ответом отправителю» (чтение `worker.go` и
|
||||
`transcribe.go`, 2026-08-13; прежняя запись от 2026-08-10 устарела вместе с
|
||||
дефектом «остановка хоронила запись»).
|
||||
- `operations`: появился ли таймаут у обращения к Telegram, S3 и SpeechKit — ни у
|
||||
одного из них таймаута нет, и проброс контекста на этот вопрос **не отвечает**:
|
||||
контекст здесь несёт жизнь процесса, а не дедлайн вызова (чтение `tg.go`,
|
||||
`s3.go`, `speechkit.go`, 2026-08-13).
|
||||
- `operations`: появился ли таймаут у обращения к S3 и SpeechKit — ни у одного
|
||||
из них таймаута нет, и проброс контекста на этот вопрос **не отвечает**:
|
||||
контекст здесь несёт жизнь процесса, а не дедлайн вызова (чтение `s3.go`,
|
||||
`speechkit.go`, 2026-08-13).
|
||||
- `operations`: не удвоилась ли запись об одном сбое — шаг логирует ошибку и
|
||||
возвращает её воркеру, который логирует снова (чтение `transcribe.go`,
|
||||
2026-08-10).
|
||||
@@ -161,14 +163,12 @@
|
||||
- `security`: не уходит ли значение, пришедшее снаружи, меткой метрики — страница
|
||||
метрик отдаётся без проверки отправителя, и метка это поверхность пошире
|
||||
журнала (журнал, запись 2026-08-11 про хвост имени).
|
||||
- `architecture`: не появился ли второй путь приёма мимо
|
||||
`createTranscribeJob` — сегодня через него идут оба входа
|
||||
- `architecture`: не появился ли второй путь приёма мимо `createRecord` — сегодня
|
||||
он единственный, которым запись попадает в хранилище
|
||||
([architecture.md](architecture.md), «Единые точки проекта»).
|
||||
- `architecture`: не поехало ли поведение в `architecture.md` вместо спеки —
|
||||
заведены четыре capability (`intake`, `pipeline`, `storage`, `access`), и
|
||||
первые две описаны частично. Поведение прочих узлов, включая
|
||||
приём из Telegram, живёт в обзоре под маркерами долга, а соблазн дописать туда
|
||||
ещё — самый большой.
|
||||
заведённые capability описывают поведение не целиком, и остаток живёт в обзоре
|
||||
под маркерами долга, а соблазн дописать туда ещё — самый большой.
|
||||
- `conventions`: новая колонка правится в обоих местах репозитория, а новый
|
||||
рубеж — одним дескриптором
|
||||
(CLAUDE.md, «Инварианты»).
|
||||
@@ -205,12 +205,12 @@
|
||||
- замена хранилища или переход на PocketBase — любой её кусок;
|
||||
- смена модели очереди: захват, повторы и воркеры разом;
|
||||
- каркас приложения: сборка фронтенда, раздача статики и шаг гейта разом;
|
||||
- изменение, трогающее оба входа сразу — Telegram и HTTP.
|
||||
- изменение, убирающее или возвращающее вход приёма целиком.
|
||||
|
||||
**Незнакомое здесь** (поднимает до `large`, ось формы решения):
|
||||
|
||||
- вход через OIDC и разграничение доступа: как связаны пользователь Telegram и
|
||||
пользователь приложения, до начала работы назвать нельзя;
|
||||
- вход через OIDC и разграничение доступа: как связать чат Telegram с учётной
|
||||
записью, до начала работы назвать нельзя;
|
||||
- всё, что делается на выбранном фреймворке впервые: правила
|
||||
[conventions/web-ui.md](conventions/web-ui.md) выведены из выбора и из замера
|
||||
на пробном экране, а не из написанного кода, и первая же задача проверяет их
|
||||
@@ -224,7 +224,6 @@
|
||||
|
||||
**Мелкое здесь** (опускает до `small`):
|
||||
|
||||
- правка текста, который видит пользователь Telegram;
|
||||
- новая метрика в `internal/metrics`;
|
||||
- правка `config.example.toml` и умолчаний `defaultConfig()` без нового поля;
|
||||
- правка документов канона.
|
||||
@@ -259,19 +258,19 @@ API и имя не откатываются обратной правкой по
|
||||
`adapter/metaviewer/ffmpeg` нет; решение и его цена — в
|
||||
[adr/ADR-2026-08-11-stub-adapters-in-tests.md](adr/ADR-2026-08-11-stub-adapters-in-tests.md);
|
||||
- **работа сервиса с настоящими внешними собеседниками.** Сам сервис поднять
|
||||
теперь можно: с `telegram.enabled = false` он встаёт и работает одним входом
|
||||
(`openspec/specs/intake`, «Признак включения решает, поднимается ли вход
|
||||
Telegram»). Живой прогон — осмотр HTTP, панели, журнала и остановки — доступен
|
||||
теперь любой задаче. Прежняя формулировка «всё, что требует поднять сервис целиком»
|
||||
снята задачей `local-run-without-telegram-token` 2026-08-13; рецепт прогона
|
||||
сменился с пустого ключа доступа на выключенный вход задачей
|
||||
`telegram-enabled-flag` того же дня.
|
||||
можно: он встаёт своим единственным входом на выдуманных непустых ключах
|
||||
секций `[auth]` и `[yandex]` — наружу они на старте не ходят. Живой прогон —
|
||||
осмотр HTTP, панели, журнала, метрик и остановки — доступен любой задаче.
|
||||
Прежняя формулировка «всё, что требует поднять сервис целиком» снята задачей
|
||||
`local-run-without-telegram-token` 2026-08-13; рецепт прогона менялся дважды —
|
||||
с пустого ключа доступа на выключенный вход (`telegram-enabled-flag` того же
|
||||
дня), а 2026-08-14 признак включения ушёл вместе с самим входом.
|
||||
|
||||
**Остаток**: за настоящий Telegram, SpeechKit и Object Storage живой прогон
|
||||
по-прежнему не отвечает — боевым токеном запускаться запрещено, ключи Yandex в
|
||||
прогоне выдуманные, а распознавание подменяют в коде. Проверить живьём можно
|
||||
подъём, отказ старта, маршруты и остановку; нельзя — приём из Telegram,
|
||||
расшифровку и заливку.
|
||||
**Остаток**: за настоящие SpeechKit и Object Storage живой прогон по-прежнему
|
||||
не отвечает — ключи Yandex в прогоне выдуманные, а распознавание подменяют в
|
||||
коде. Проверить живьём можно подъём, отказ старта, маршруты, метрики и
|
||||
остановку; нельзя — расшифровку и заливку. Вход через живого провайдера OIDC
|
||||
тоже недоступен: сессию в прогоне выдать нечем.
|
||||
|
||||
## Журнал дефектов
|
||||
|
||||
@@ -281,6 +280,60 @@ API и имя не откатываются обратной правкой по
|
||||
истории git 2026-08-10: поле «Чем воспроизведён» называет у них коммит, а не
|
||||
оракул, и выдумывать оракул задним числом нельзя.
|
||||
|
||||
## 2026-08-15 — пустой второй ответ распознавателя стирал сохранённую расшифровку [пойман ревью]
|
||||
|
||||
- **Где:** `internal/adapter/repo/pocketbase/text_repo.go`, `TextRepository.Put`
|
||||
и `StructureRepository.Put`; путь до них — `poll` → `storeOutcome` в
|
||||
`internal/service/transcribe.go`. Кода задачи `remove-telegram-intake` дефект не
|
||||
касался: она этот путь не трогала
|
||||
- **Симптом:** поток от SpeechKit, закрывшийся на первом же ответе, отказом не
|
||||
считается — наружу уходит пустой результат без отказа. Замена содержимого шла
|
||||
безусловно, и повторный опрос той же операции клал пустое поверх сохранённой
|
||||
расшифровки. Шаг при этом объявлял запись готовой: рубеж двигался, опрос
|
||||
готовности отдавал `done` без текста
|
||||
- **Причина:** соседний хранитель того же результата — сырой ответ провайдера —
|
||||
от пустого значения защищён условием `len(raw) > 0` с самого заведения, а текст
|
||||
и структура реплик такого условия не имели. Разное правило у двух хранителей
|
||||
одного результата
|
||||
- **Чем воспроизведён:** падающий тест враждебного прохода, переснятый триажем, —
|
||||
`expected: "Личный разговор." actual: ""`. Оракул закреплён в дереве:
|
||||
`internal/service/recognition_test.go`, `TestEmptySecondAnswerKeepsArchivedText`;
|
||||
он же проверяет, что до второго ответа дело действительно дошло
|
||||
- **Почему не поймали раньше:** повторный опрос одной операции — не редкость, но
|
||||
и не штатный путь: он наступает, когда держатель захвата умер, сохранение рубежа
|
||||
отказало либо человек снял остановку в панели. Ни один прогон до этого не строил
|
||||
такого входа, а от чтения кода защита у соседа выглядела общей
|
||||
- **Что меняем:** правило «пустое не кладётся поверх сохранённого» записано
|
||||
нормой в спеку `storage` и держится **хранилищем**, а не шагом: шагов, кладущих
|
||||
текст, больше одного, и правило у одного из них у остальных читалось бы как
|
||||
снятое. Дефект пред-существующий, чинился решением владельца от 2026-08-14 в
|
||||
задаче, которая его нашла
|
||||
|
||||
## 2026-08-15 — пустая расшифровка перестала быть заметной вместе с убранным входом [пойман ревью]
|
||||
|
||||
- **Где:** `internal/service/transcribe.go`, шаг завершения; документы
|
||||
`docs/conventions/logging.md` и `docs/architecture.md`
|
||||
- **Симптом:** запись с пустым распознаванием доходила до конечного рубежа и от
|
||||
успешной не отличалась ничем — ни строкой журнала, ни ответом опроса
|
||||
- **Причина:** единственным следом этого случая был текст, уходивший отправителю
|
||||
в чат («на записи нет текста»). Задача убрала доставку целиком, и след исчез
|
||||
вместе с ней — при том, что конвенция журнала называет пустой текст
|
||||
распознавания поимённым примером уровня «может стать проблемой», а обзор
|
||||
архитектуры обещал заглушку
|
||||
- **Чем воспроизведён:** `internal/service/recognition_test.go`,
|
||||
`TestEmptyRecognitionIsNamedInJournal` — подставной распознаватель отдаёт
|
||||
готовую операцию с пустым результатом, проверка судит уровень строки и
|
||||
идентификатор записи
|
||||
- **Почему не поймали раньше:** удаление сняло **последнего потребителя** видимого
|
||||
признака, а не сам признак; такое не видно ни компилятору, ни грепу по
|
||||
удаляемому имени. Нашёл проход конвенций, сверив таблицу уровней журнала с тем,
|
||||
что осталось в коде
|
||||
- **Что меняем:** шаг опроса пишет строку уровня «может стать проблемой» с
|
||||
идентификатором записи; строка обзора архитектуры переписана на фактическое
|
||||
поведение. Класс общий: **удаляя канал, проверь, не был ли он единственным
|
||||
потребителем сигнала** — сигнал переживает канал только там, где его переносят
|
||||
руками
|
||||
|
||||
## 2026-08-13 — сторож инварианта про секрет искал подстроку, которой не бывает [пойман ревью]
|
||||
|
||||
- **Где:** `internal/config/config_test.go`, проверка «значение ключа доступа не
|
||||
|
||||
+19
-44
@@ -15,11 +15,10 @@
|
||||
записи, а чужая отвечает «не найдено». Целевому периметру недостаёт теперь второго уровня
|
||||
доступа — страницы расхода для владельца сервиса.
|
||||
|
||||
Записи, принятые ботом, владельца не имеют и по API не достаются никому: связи
|
||||
чата с учётной записью приложения нет, её заводит `telegram-account-link`.
|
||||
|
||||
Разграничение доступа в Telegram осталось прежним — белым списком, и с учётной
|
||||
записью приложения он не связан.
|
||||
Ничьих записей у сервиса больше не бывает: колонка владельца пустого значения
|
||||
не принимает, и держит это схема хранилища. Прежде такие записи заводил вход
|
||||
Telegram — связи чата с учётной записью сервис не вёл, — и 2026-08-14 вход убран
|
||||
вместе с этим исключением.
|
||||
|
||||
**Целевой периметр шире сегодняшнего не только входом.** Содержимое записи
|
||||
начинает уходить на три новые стороны — языковой модели, в канал уведомлений и
|
||||
@@ -59,9 +58,7 @@
|
||||
| --- | --- | --- |
|
||||
| Аудиофайл и его имя | `POST /api/audio`, multipart-поле `audio` | Любой вошедший через OIDC; без сессии — `401` до чтения тела |
|
||||
| Идентификатор задачи | `GET /api/status/:id` | Любой вошедший через OIDC; без сессии — `401`, одинаковый для заведённой и незаведённой задачи |
|
||||
| Голосовое, аудио, документ | Telegram, длинный опрос | Любой пользователь Telegram; обрабатывается только из белого списка |
|
||||
| Имя файла в Telegram | Поле `file_path` ответа Bot API | Telegram, а через него — отправитель |
|
||||
| Содержимое аудио | Файл, скармливаемый `ffmpeg` и `ffprobe` | Отправитель по любому из каналов |
|
||||
| Содержимое аудио | Файл, скармливаемый `ffmpeg` и `ffprobe` | Отправитель |
|
||||
| Текст расшифровки | Поток gRPC от SpeechKit | Yandex, а через него — содержимое записи |
|
||||
|
||||
Что добавится вместе с целевым периметром — каждый вход появляется своей
|
||||
@@ -82,9 +79,10 @@
|
||||
|
||||
## Куда уходит содержимое записи
|
||||
|
||||
Сегодня запись и её текст покидают наш сервер тремя путями: файл уезжает в
|
||||
Yandex Object Storage, оттуда его читает SpeechKit, а текст возвращается в
|
||||
Telegram отправителю.
|
||||
Сегодня запись покидает наш сервер двумя путями: файл уезжает в Yandex Object
|
||||
Storage, оттуда его читает SpeechKit. Третий путь — ответ в Telegram — исчез
|
||||
2026-08-14 вместе с убранным входом: текст теперь достаётся только по опросу
|
||||
готовности и в панели владельца.
|
||||
|
||||
Целевой периметр добавляет три пути, каждый — своей задачей:
|
||||
|
||||
@@ -156,10 +154,6 @@ Telegram отправителю.
|
||||
|
||||
## Что разграничивает доступ
|
||||
|
||||
- **Telegram** — белый список `[server] users_while_list`. Сверяется со строкой
|
||||
автора сообщения (`update.Message.From.String()`, то есть `@username` либо имя
|
||||
с фамилией), а не с числовым идентификатором. Имя пользователя Telegram
|
||||
меняется владельцем в любой момент: список привязан к изменяемому значению.
|
||||
- **HTTP API** — сессия, заведённая входом через OIDC у Authelia. Предъявляется
|
||||
кукой `transcriber_session`, обесценивается выходом, срок жизни назначен числом
|
||||
([database.md](database.md), «Настройки с числовым значением»).
|
||||
@@ -203,9 +197,6 @@ Telegram отправителю.
|
||||
| Личный токен | Права своего владельца программе, без браузерной сессии | `api-tokens` |
|
||||
| Признак владельца сервиса | Страницу расхода и сводку по всем пользователям | `admin-stats-screen` |
|
||||
|
||||
Белый список Telegram при этом перестаёт быть отдельным механизмом: право
|
||||
писать боту выводится из учётной записи (`telegram-account-link`).
|
||||
|
||||
Признак владельца сервиса — **второй уровень доступа**, которого в сегодняшней
|
||||
модели нет вовсе: до него всё разграничение сводилось к «свой или чужой».
|
||||
Откуда он берётся — из группы OIDC или из конфигурации — не решено
|
||||
@@ -229,10 +220,10 @@ Telegram отправителю.
|
||||
`record_events` (журнал событий, содержимого не несёт) и `topics` (словарь
|
||||
тем человека). Всякая новая коллекция, куда содержимое переезжает, закрывается
|
||||
наравне с записью — норму держит спека `storage`.
|
||||
2. **Токен бота Telegram.** Даёт полный доступ к боту и к перепискам с ним.
|
||||
3. **Ключи Yandex Cloud** — `speech_kit_api_key` и пара ключей Object Storage.
|
||||
2. **Ключи Yandex Cloud** — `speech_kit_api_key` и пара ключей Object Storage.
|
||||
Утечка оплачивается деньгами и доступом к бакету.
|
||||
4. **Белый список пользователей** — сам по себе перечень имён.
|
||||
3. **Секрет клиента OIDC** — вместе с адресами провайдера открывает вход в
|
||||
приложение от чужого имени.
|
||||
|
||||
Всё перечисленное лежит в `config.toml`. Файл в `.gitignore`, на сервер его
|
||||
кладёт Ansible; `gitleaks` на pre-commit смотрит только индекс коммита.
|
||||
@@ -288,30 +279,14 @@ Telegram отправителю.
|
||||
приведения хвост читал бы кто угодно из интернета, а множеством значений метки
|
||||
распоряжался бы анонимный отправитель.
|
||||
|
||||
Приём из Telegram имени, данного человеком, до сервиса не доводит: оттуда
|
||||
приходит путь, выданный самим Telegram. Настоящее имя документа дальше проверки
|
||||
типа файла не идёт.
|
||||
Два пути утечки токена бота — адрес Bot API в отказе транспорта и отказ сборки
|
||||
клиента — закрыты задачами `no-user-filename-in-log` и
|
||||
`local-run-without-telegram-token` 2026-08-13 и потеряли предмет 2026-08-14
|
||||
вместе с убранным входом: ни клиента, ни токена у сервиса больше нет. Разбор
|
||||
случая остался в [review.md](review.md) — он про класс, а не про Telegram.
|
||||
|
||||
Токен бота стоит в пути **каждого** обращения к Bot API (`bot<TOKEN>/getFile`,
|
||||
`…/sendMessage`, `…/getMe`, `…/getUpdates`) и в ссылке на скачивание
|
||||
(`file.Link(token)`). Сами адреса нигде не логируются, но до 2026-08-13 их
|
||||
уносил **отказ транспорта**: `*url.Error` встраивает адрес целиком, а отказы
|
||||
скачивания и отправки пишутся в журнал. Теперь адрес на границе клиента снимает
|
||||
свой `Do` — `internal/adapter/telegram`, `NewBot`: он чистит отказ, а
|
||||
подменённый логгер библиотеки вычищает токен из строк длинного опроса, которые
|
||||
она печатает сама. Транспорт бота токена больше не получает вовсе: клиента ему
|
||||
отдают готовым. Правило — [conventions/logging.md](conventions/logging.md),
|
||||
случай — [review.md](review.md), оракул — `internal/adapter/telegram/bot_test.go`.
|
||||
|
||||
Ещё один путь закрыт задачей `local-run-without-telegram-token` 2026-08-13, и до
|
||||
неё он был открыт: токен, не разбирающийся как часть адреса (перенос строки из
|
||||
шаблона выкладки, невычищенная `%`-последовательность), роняет сборку клиента
|
||||
**раньше** обращения к нему — то есть мимо чистки на границе клиента. Отказ
|
||||
конструктора теперь чистится отдельно. Нашло это ревью кода тремя проходами
|
||||
независимо; оракул — там же, в `bot_test.go`.
|
||||
|
||||
Третий путь закрыт задачей `telegram-enabled-flag` 2026-08-13, и он **шире
|
||||
токена бота**: до неё утечь мог любой секрет конфига. Отказ разбора файла
|
||||
Путь, который остался, закрыт задачей `telegram-enabled-flag` 2026-08-13, и он
|
||||
**шире всякого одного ключа**: до неё утечь мог любой секрет конфига. Отказ разбора файла
|
||||
настроек пересказывался как есть, а библиотека разбора собирает текст отказа из
|
||||
разбираемого куска — `toml.ParseError` кладёт в сообщение само значение. Строка
|
||||
секретного ключа с оборванной кавычкой — типовая поломка криво собранного
|
||||
|
||||
@@ -10,7 +10,6 @@ require (
|
||||
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.18.3
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.97.3
|
||||
github.com/aws/smithy-go v1.27.7
|
||||
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/pocketbase/dbx v1.12.0
|
||||
@@ -50,7 +49,6 @@ require (
|
||||
github.com/go-sql-driver/mysql v1.9.2 // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/kylelemons/godebug v1.1.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.15 // indirect
|
||||
github.com/mattn/go-isatty v0.0.23 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
@@ -66,12 +64,12 @@ require (
|
||||
github.com/spf13/cobra v1.10.2 // indirect
|
||||
github.com/spf13/pflag v1.0.10 // indirect
|
||||
golang.org/x/crypto v0.54.0 // indirect
|
||||
golang.org/x/image v0.44.0 // indirect
|
||||
golang.org/x/image v0.45.0 // indirect
|
||||
golang.org/x/net v0.57.0 // indirect
|
||||
golang.org/x/oauth2 v0.36.0 // indirect
|
||||
golang.org/x/sync v0.22.0 // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
golang.org/x/text v0.40.0 // indirect
|
||||
golang.org/x/text v0.41.0 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
|
||||
@@ -74,8 +74,6 @@ github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre
|
||||
github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
|
||||
github.com/go-sql-driver/mysql v1.9.2 h1:4cNKDYQ1I84SXslGddlsrMhc8k4LeDVj6Ad6WRjiHuU=
|
||||
github.com/go-sql-driver/mysql v1.9.2/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
|
||||
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 h1:wG8n/XJQ07TmjbITcGiUaOtXxdrINDz1b0J1w0SzqDc=
|
||||
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1/go.mod h1:A2S0CWkNylc2phvKXWBBdD3K0iGnDBGbzRpISP2zBl8=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
@@ -162,10 +160,10 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk
|
||||
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
|
||||
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
|
||||
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||
golang.org/x/image v0.44.0 h1:+tDekMZED9+LrtB3G5xzRggpVh9CARjZqROla3R3R+I=
|
||||
golang.org/x/image v0.44.0/go.mod h1:V8K3KE9KKKE+pLpQDOeN18w9oacNSvy1tDOirTu4xtY=
|
||||
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
|
||||
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
|
||||
golang.org/x/image v0.45.0 h1:FMb1nTbH5H9vF55SriQHgFw5GnNL9Jg6L25BwXKzhB0=
|
||||
golang.org/x/image v0.45.0/go.mod h1:n62x/7RqlwXDvGsSU4u6IUTUf6KghUZ9Bt7cG/T9Fx4=
|
||||
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
|
||||
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
|
||||
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
|
||||
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
|
||||
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
|
||||
@@ -178,11 +176,11 @@ golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
|
||||
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
|
||||
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
|
||||
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
|
||||
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
|
||||
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
|
||||
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
|
||||
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
||||
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
||||
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
||||
|
||||
@@ -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", "недоставки не было")
|
||||
}
|
||||
@@ -20,7 +20,6 @@ import (
|
||||
pbrepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase"
|
||||
"git.vakhrushev.me/av/transcriber/internal/config"
|
||||
httpcontroller "git.vakhrushev.me/av/transcriber/internal/controller/http"
|
||||
tgcontroller "git.vakhrushev.me/av/transcriber/internal/controller/tg"
|
||||
"git.vakhrushev.me/av/transcriber/internal/controller/worker"
|
||||
"git.vakhrushev.me/av/transcriber/internal/metrics"
|
||||
"git.vakhrushev.me/av/transcriber/internal/service"
|
||||
@@ -60,14 +59,6 @@ func main() {
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Включённый вход без ключа доступа — ошибка настройки, а не режим: бот по
|
||||
// пустому ключу не появится, а тихий подъём без него оставил бы отправителей
|
||||
// без ответов.
|
||||
if err := cfg.Telegram.Validate(); err != nil {
|
||||
logger.Error("Unable to start with incomplete telegram settings", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Числа конвейера проверяются здесь же: ноль воркеров — объявленный режим, а
|
||||
// отрицательное число и нулевой предел простоя — опечатка, и подниматься с
|
||||
// ней значит остановить всякую запись первым же захватом.
|
||||
@@ -112,12 +103,6 @@ func main() {
|
||||
metaviewer := ffmpegmv.NewFfmpegMetaViewer()
|
||||
converter := ffmpegconv.NewFfmpegConverter()
|
||||
|
||||
tgBot, tgSender, err := buildTelegram(cfg.Telegram, logger)
|
||||
if err != nil {
|
||||
logger.Error("Failed to create Telegram bot", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
recognizer, err := yandex.NewYandexAudioRecognizerService(yandex.YandexAudioRecognizerConfig{
|
||||
Region: cfg.Yandex.ObjStorageRegion,
|
||||
AccessKey: cfg.Yandex.ObjStorageAccessKey,
|
||||
@@ -147,7 +132,6 @@ func main() {
|
||||
metaviewer,
|
||||
converter,
|
||||
recognizer,
|
||||
tgSender,
|
||||
cfg.Pipeline.StuckLimits(),
|
||||
logger,
|
||||
)
|
||||
@@ -159,32 +143,6 @@ func main() {
|
||||
// Создаем WaitGroup для ожидания завершения всех воркеров
|
||||
var wg sync.WaitGroup
|
||||
|
||||
tgConfig := tgcontroller.TelegramConfig{
|
||||
UpdateTimeout: cfg.Telegram.UpdateTimeout,
|
||||
UserWhiteList: cfg.Server.UsersWhiteList,
|
||||
}
|
||||
|
||||
// Транспорт поднимается только там, где есть клиент: о том, что бота нет,
|
||||
// сказано выше единственной записью, и вторая здесь была бы записью о том
|
||||
// же факте.
|
||||
var tgController *tgcontroller.TelegramController
|
||||
if tgBot != nil {
|
||||
tgController, err = tgcontroller.NewTelegramController(tgConfig, tgBot, transcribeService, logger)
|
||||
if err != nil {
|
||||
logger.Error("Failed to create Telegram controller", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Запускаем Telegram бот в отдельной горутине
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
logger.Info("Starting Telegram bot")
|
||||
tgController.Start(ctx)
|
||||
logger.Info("Telegram bot stopped gracefully")
|
||||
}()
|
||||
}
|
||||
|
||||
// Пул одинаковых воркеров: специализации у них нет, шаг выбирается по рубежу
|
||||
// самой записи. Число приходит настройкой, ноль — законное значение.
|
||||
pool := worker.NewPool(cfg.Pipeline.Workers, transcribeService.RunStep, logger)
|
||||
@@ -194,9 +152,9 @@ func main() {
|
||||
pool.Start(ctx)
|
||||
}()
|
||||
|
||||
// Вход по HTTP поднимается всегда: он основной, и отдельного разреза у него
|
||||
// нет. Признак ставится рядом с признаком Telegram, чтобы владелец судил об
|
||||
// обоих входах одним отбором.
|
||||
// Вход у сервиса один — приём по HTTP, — и метка ставится только ему. Метки
|
||||
// убранного входа Telegram здесь нет намеренно: ноль читался бы как поломка,
|
||||
// а признак существует ради того дня, когда входов снова станет больше.
|
||||
metrics.IntakeUpGauge.WithLabelValues("http").Set(1)
|
||||
|
||||
// Наши маршруты живут на роутере хранилища: панель отдаётся тем же портом,
|
||||
@@ -305,11 +263,6 @@ func main() {
|
||||
logger.Error("HTTP server stopped unexpectedly, shutting down")
|
||||
}
|
||||
|
||||
if tgController != nil {
|
||||
logger.Info("Shutting down Telegram bot...")
|
||||
tgController.Stop()
|
||||
}
|
||||
|
||||
// Создаем контекст с таймаутом для graceful shutdown HTTP сервера
|
||||
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), time.Duration(cfg.Server.ShutdownTimeout)*time.Second)
|
||||
defer shutdownCancel()
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-08-14
|
||||
@@ -0,0 +1,225 @@
|
||||
## Context
|
||||
|
||||
Сервис принимает записи двумя входами. Приложение и внешняя программа приходят по
|
||||
HTTP с сессией, и у их записей есть владелец; бот приходит от чата, и у его
|
||||
записей владельца нет — связи чата с учётной записью сервис не ведёт. Отсюда
|
||||
оговорка про бота в каждом решении о владельце: в спеке приёма, в спеке доступа,
|
||||
в схеме хранилища и в отборе записей.
|
||||
|
||||
Вход Telegram при этом несёт собственный вес: клиент, транспорт обновлений,
|
||||
разбиение длинного текста на сообщения, заглушка отправителя, сборка входа при
|
||||
старте с разбором «недоступность или ошибка настройки», список допущенных людей,
|
||||
доставка ответа из конвейера и правило о недоставленном ответе.
|
||||
|
||||
Ограничение, которое правит решением: применённый шаг схемы не переписывается —
|
||||
это инвариант проекта, и он **critical**. Колонки `tg_chat_id`,
|
||||
`tg_reply_message_id` и значение `telegram` перечня `source` заведены шагами
|
||||
`202608110001` и `202608140002`, оба применены. В боевой базе по этим колонкам
|
||||
лежат записи живых людей.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
|
||||
- Убрать вход Telegram из кода, настроек и зависимостей целиком.
|
||||
- Сделать владельца записи безусловным: завести запись без владельца сервису
|
||||
нечем.
|
||||
- Сделать владельца обязательным **в схеме**, а не только в приёме: ни одной
|
||||
записи не потерять и ни одного применённого шага схемы не переписать.
|
||||
- Оставить возврат входа дешёвым: удалённое лежит в истории git одним изменением,
|
||||
и возвращается оно вместе со связью чата и учётной записи.
|
||||
|
||||
**Non-Goals:**
|
||||
|
||||
- Замена доставки ответа. Уведомлений взамен чата не заводим — на это стоит
|
||||
задача `ntfy-delivery`.
|
||||
- Чистка хранилища от колонок и значений бота. Ни нового шага схемы, ни правки
|
||||
применённых.
|
||||
- Переделка приёма по HTTP, конвейера, распознавания и панели.
|
||||
- Связь чата Telegram с учётной записью. Она нужна возврату входа, и заводит её
|
||||
своя задача.
|
||||
|
||||
## Decisions
|
||||
|
||||
### Схема теряет только необязательность владельца
|
||||
|
||||
Колонки чата и ответного сообщения остаются, значение `telegram` в перечне
|
||||
источников остаётся, применённые шаги не переписываются. Новый шаг схемы у
|
||||
изменения один, и делает он ровно одно: колонка владельца у аудиозаписи и у файла
|
||||
перестаёт принимать пустое значение.
|
||||
|
||||
Шаг безопасен потому, что записей без владельца в боевой базе нет — это сказал
|
||||
владелец сервиса, отвечая на прямой вопрос, и на этом ответе решение и стоит.
|
||||
Будь такие записи, шаг пришлось бы либо отменить, либо оплатить необратимой
|
||||
правкой чужих данных: назначить им владельца выдумкой или удалить.
|
||||
|
||||
**Проверяется это запросом, а не прогоном шага**, и разница выяснилась ревью с
|
||||
оракулом: хранилище держит обязательность связи проверкой записи при сохранении,
|
||||
а не ограничением таблицы. Смена признака на базе с ничьей записью проходит
|
||||
зелёным и такую запись оставляет — то есть прогон шага на копии боевой базы
|
||||
чистую базу от грязной не отличает. Заставить шаг считать строки самому владелец
|
||||
решил не делать (решение от 2026-08-14): безопасность держится ручной проверкой,
|
||||
и она названа первым шагом плана перехода.
|
||||
|
||||
Цена промаха, если ничью запись всё же проглядят: она становится незакрываемой.
|
||||
Захват идёт сырым запросом мимо проверки и выдаёт её воркеру, а всякое сохранение
|
||||
отказывает — включая то, которым ставится признак остановки. Запись повторяется
|
||||
неограниченно, не оставляя следа ни в метрике, ни в журнале событий.
|
||||
|
||||
Колонка темы словаря обязательной была уже — разное правило у трёх колонок одного
|
||||
смысла читалось бы как недосмотр, и теперь их правило одно.
|
||||
|
||||
Рассмотрено и отвергнуто:
|
||||
|
||||
- **Новый шаг, убирающий колонки бота.** Он законен — запрет стоит на правке
|
||||
применённого шага, а не на новом, — но необратим по данным: колонки заполнены у
|
||||
записей живых людей, и восстановить их после удаления неоткуда. Возврат входа
|
||||
завёл бы их заново пустыми.
|
||||
- **Сужение перечня `source` до `unknown` и `api`.** Строки со значением
|
||||
`telegram` в базе есть, и после сужения перечня они перестают проходить
|
||||
проверку схемы: панель откажется их сохранять, а поведение записи при чтении
|
||||
зависит от библиотеки. Цена — молчаливая порча уже принятых записей.
|
||||
|
||||
Поля адресата уходят при этом из **модели** — колонки остаются в схеме, а
|
||||
аудиозапись их больше не несёт. Это безопасно ровно потому, что колонки адресата
|
||||
пишет только заведение записи: снимок шага конвейера кладётся отображением, где
|
||||
их нет вовсе, и потому значение уже принятой записи он не затирает. Правила
|
||||
`internal/archrules` такую пару держат: колонка, которую никто не пишет, законна,
|
||||
незаконна колонка, которую пишут, но не читают.
|
||||
|
||||
Следствие: константа `entity.SourceTelegram` остаётся в модели. На неё ссылается
|
||||
применённый шаг `202608140002`, и убрать её значило бы переписать применённый
|
||||
шаг. В модели она получает комментарий об историческом значении: новых записей с
|
||||
ним не появляется.
|
||||
|
||||
### Владелец записи перестаёт быть необязательным и в модели
|
||||
|
||||
Поле владельца в аудиозаписи становится обычной строкой вместо ссылки, которой
|
||||
позволено отсутствовать. Модель здесь повторяет схему, а не расходится с ней:
|
||||
значения «владельца нет» больше не существует ни на одном уровне.
|
||||
|
||||
Рассмотрено и отвергнуто:
|
||||
|
||||
- **Оставить ссылку, которой позволено отсутствовать.** Она заставляет каждого
|
||||
читателя решать, что делать с отсутствием, хотя отсутствия больше не бывает.
|
||||
Два способа выразить одно значение расходятся молча.
|
||||
- **Держать обязательность одним приёмом, схему не трогать.** Так было задумано
|
||||
сперва, и это оставляло дыру: ничью запись заводили руками в панели, она
|
||||
уходила в конвейер, стоила денег на распознавание и не доставалась потом
|
||||
никому. Решение владельца от 2026-08-14 — обязательность держит схема.
|
||||
|
||||
Правило «пустой владелец не совпадает ни с одной записью» при этом остаётся и не
|
||||
становится избыточным: схема запрещает **заводить** ничью запись, а это правило
|
||||
запрещает **спрашивать** ничьим именем. Снять его, сославшись на схему, значит
|
||||
открыть выборку первому же вызывающему без учётной записи.
|
||||
|
||||
### Отправителя о готовности и об остановке узнаёт опрос, и другого канала нет
|
||||
|
||||
Доставка из конвейера убирается вместе с договором об отправителе сообщений.
|
||||
Правило о недоставленном ответе уходит: недоставке взяться неоткуда.
|
||||
|
||||
Рассмотрено и отвергнуто:
|
||||
|
||||
- **Оставить заглушку отправителя.** Договор без единой реализации, кроме
|
||||
пустой, — это мёртвый шов, который читается как незаконченная работа и
|
||||
переживает не одну задачу.
|
||||
- **Завести уведомление взамен сразу.** Это другой предмет со своей внешней
|
||||
зависимостью; задача на него уже стоит в беклоге, и слепить их значит собрать
|
||||
два изменения в одно.
|
||||
|
||||
### Метка убранного входа не выставляется вовсе
|
||||
|
||||
Признак поднятого входа остаётся, метка `telegram` у него больше не появляется.
|
||||
Ноль вместо неё читается как «вход есть, но не поднялся», то есть как поломка;
|
||||
владелец, у которого на этот признак стоит отбор, увидел бы аварию на ровном
|
||||
месте.
|
||||
|
||||
### Незнакомые ключи настроек по-прежнему не судятся
|
||||
|
||||
Секция `[telegram]` и ключ `server.users_while_list` убираются из структуры
|
||||
настроек и из образца. Имя ключа написано с опечаткой — `while` вместо `white`, —
|
||||
и это записано «Расхождением» в `docs/conventions/config.md`; удаление ключа
|
||||
закрывает и его. Файл, где их забыли, сервис поднимет молча: разбор настроек
|
||||
незнакомые ключи не проверяет.
|
||||
|
||||
Рассмотрено и отвергнуто: **завести отказ старта по незнакомому ключу**. Это
|
||||
новое поведение настроек, полезное само по себе, но чужое этой задаче: оно
|
||||
касается всех ключей, а не убранных, и разбирается своей задачей.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- **У сервиса не остаётся входа, которым человек может воспользоваться.** Это
|
||||
главный риск изменения, и он не смягчается ничем внутри задачи. Приложения нет,
|
||||
своего токена у программы нет, значит после выкладки положить запись можно
|
||||
единственным способом: собранным руками запросом с сессией, снятой из браузера
|
||||
после входа. Срок этого состояния задаётся чужими задачами — экраном приложения
|
||||
и личными токенами, — и внутри этой задачи не назначается. Прецедент в журнале
|
||||
ревью: 2026-08-12 поверхность закрыли так, что войти не мог никто, и спасением
|
||||
тогда был именно бот.
|
||||
- **Боевой файл настроек переживёт выкладку с мёртвой секцией** → сервис
|
||||
поднимется без бота, и это ровно то, чего мы хотим; секцию убирает человек при
|
||||
выкладке, и об этом сказано в плане перехода.
|
||||
- **Записи, застрявшие в конвейере на минуту выкладки, дойдут до текста, и ответа
|
||||
в чат по ним не уйдёт** → отправитель в Telegram ответа не получит вовсе.
|
||||
Смягчения нет и быть не может: чат — это и есть убираемый вход. Расшифровка не
|
||||
теряется, владелец сервиса видит её в панели.
|
||||
- **Ничья запись, если она всё же найдётся, шагом не ловится** → она остаётся в
|
||||
базе и становится незакрываемой: сохранить её нечем, остановить тоже, а следа
|
||||
не остаётся ни в метрике, ни в журнале событий. Смягчение одно и ручное —
|
||||
проверка запросом первым шагом плана перехода. Заставить шаг считать строки
|
||||
самому владелец решил не делать.
|
||||
- **Возврат входа стоит восстановления кода** → удаление уезжает одним
|
||||
изменением, и его архив вместе с историей git даёт точку возврата. Связь чата с
|
||||
учётной записью всё равно потребует нового кода: без неё возвращать вход значит
|
||||
возвращать записи без владельца.
|
||||
- **Правила, потерявшие предмет, останутся в памятке и в модели угроз** →
|
||||
инварианты про белый список бота и про ответ отправителю убираются синком
|
||||
документации тем же изменением, а не следующей задачей.
|
||||
|
||||
## Migration Plan
|
||||
|
||||
1. Проверить боевую базу запросом: `SELECT count(*) FROM audio_records WHERE
|
||||
owner = ''` и то же по `files`. Оба должны отдать ноль. Прогон самого шага на
|
||||
копии этого не показывает — он проходит зелёным и на грязной базе.
|
||||
2. Собрать образ (`task image`) — сборка образа в гейт не входит, и человек
|
||||
обязан прогнать её сам.
|
||||
3. Выложить (`inv pl -- transcriber` из `pet-project-server`) — запускает человек.
|
||||
4. Убрать из боевого файла настроек секцию `[telegram]` и ключ
|
||||
`server.users_while_list`. Порядок с шагом 3 безразличен: оставленные ключи
|
||||
сервис пропускает молча, и для подъёма этот шаг не нужен.
|
||||
5. Ключ доступа к боту остаётся в хранилище выкладки под возврат входа — решение
|
||||
владельца от 2026-08-14. Ротация не делается, бот у Telegram остаётся
|
||||
зарегистрированным. Цена решения названа прямо: отправитель голосового не
|
||||
получит ни ответа, ни отказа и не отличит выведенный вход от сломанного
|
||||
сервиса. Оповещать людей из списка допущенных владелец не стал.
|
||||
6. Откат — прежний образ. Он потребует вернуть настройки: сервис прошлой версии
|
||||
без ключа `telegram.enabled` не поднимается. Схему откат вернёт своим
|
||||
обратным шагом — колонка владельца снова примет пустое значение, — и на
|
||||
записях это не сказывается: пустых среди них нет.
|
||||
|
||||
### Формы решения, между которыми выбирали
|
||||
|
||||
Форма выбрана не единственной рассмотренной, и остальные две названы здесь, а не
|
||||
подразумеваются:
|
||||
|
||||
- **Выключить вход признаком, код оставить.** Признак `telegram.enabled` заведён
|
||||
2026-08-13 и обязателен, а приём по HTTP владельца уже требует: одна правка
|
||||
ключа в боевом файле даёт «новых записей без владельца не заводится» ценой ноля
|
||||
строк кода и мгновенным возвратом. Отвергнуто по причине из раздела «Why»:
|
||||
двойная модель остаётся в коде, и оговорку про бота продолжает платить каждая
|
||||
следующая задача. Размен здесь честный — стоимость сопровождения против
|
||||
стоимости отката, — и выбран он в пользу первой.
|
||||
- **Сузить бота до исходящего канала.** Приём убрать, отправку оставить с одним
|
||||
адресатом — чатом владельца строкой настроек; связь чата с учётной записью для
|
||||
этого не нужна. Смягчает главный риск: человек хотя бы узнаёт исход. Отвергнуто
|
||||
потому, что заводит понятие «канал уведомления владельца», которое тут же
|
||||
переделает задача `ntfy-delivery`, и держит ради этого клиент, договор и
|
||||
зависимость — плату за временный мост.
|
||||
|
||||
## Open Questions
|
||||
|
||||
- Критерии приёмки постановка не назвала: они предложены в `tasks.md` и
|
||||
подтверждаются человеком на чекпоинте.
|
||||
- Судьба задач беклога `telegram-account-link` и
|
||||
`bot-api-only-through-bot-client`: обе теряют предмет до возвращения входа.
|
||||
Решает владелец; это изменение их не трогает.
|
||||
@@ -0,0 +1,80 @@
|
||||
## Why
|
||||
|
||||
Сервис принимает записи двумя входами, и входы расходятся в главном: у записи,
|
||||
пришедшей из приложения, есть владелец, а у записи, пришедшей от бота, владельца
|
||||
нет и быть не может — связи чата с учётной записью сервис не ведёт. Пока такие
|
||||
записи заводятся, правило «каждая запись принадлежит человеку» действует
|
||||
наполовину: половину записей оно накрывает, а другую половину обходит, и каждое
|
||||
решение о владельце приходится писать с оговоркой про бота.
|
||||
|
||||
Основной вход сегодня — приложение, и работа идёт над ним. Второй вход при этом
|
||||
стоит денег на каждой задаче: его нельзя не учитывать в приёме, в доставке
|
||||
ответа, в отборе записей и в настройках. Убираем его, чтобы модель стала одной, и
|
||||
убираем **временно**: бот вернётся, когда появится связь чата с учётной записью.
|
||||
|
||||
## What Changes
|
||||
|
||||
- **BREAKING**: вход Telegram убирается целиком. Бот не поднимается, записи от
|
||||
него не принимаются, ответ в чат не уходит.
|
||||
- **BREAKING по смыслу, а не по разбору**: настройки входа Telegram и список
|
||||
допущенных к боту людей уходят из конфигурации. Файл, где их забыли, сервис
|
||||
примет молча — незнакомые ключи разбор настроек не судит, — но значить они
|
||||
перестают что бы то ни было. Секцию убирает человек при выкладке.
|
||||
- У записи появляется владелец на правах обязательного, и держит это хранилище:
|
||||
колонка владельца у записи и у файла перестаёт принимать пустое значение. Завести
|
||||
ничью запись нельзя ничем — ни приёмом, ни конвейером, ни рукой в панели.
|
||||
- Из конвейера уходит доставка ответа отправителю: единственный оставшийся вход
|
||||
узнаёт исход опросом готовности и из панели владельца. Вместе с доставкой
|
||||
уходят правило про недоставленный ответ и обязанность остановки сообщать
|
||||
отправителю.
|
||||
- Наблюдатель видит один поднятый вход вместо двух.
|
||||
- Записи с источником Telegram, если они есть, остаются нетронутыми: колонки чата
|
||||
и ответного сообщения из схемы не убираются, и ни одна запись не удаляется.
|
||||
Записей **без владельца** в базе при этом нет — так ответил владелец сервиса, и
|
||||
на этом стоит обязательность колонки; прогон нового шага схемы на копии боевой
|
||||
базы проверяет это до выкладки.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
Новых нет: изменение убирает поведение, а не заводит.
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `intake`: уходит требование о признаке включения входа Telegram; требование о
|
||||
видимых наблюдателю входах говорит об одном входе.
|
||||
- `pipeline`: уходят требования о недоставленном ответе и об обязанности всякой
|
||||
остановки сообщать отправителю — адресата у сообщения не осталось. Взамен
|
||||
появляется требование, что конвейер наружу не обращается вовсе. Из выборки
|
||||
воркера и из требования о держателе захвата уходят оговорки про записи без
|
||||
владельца и про ответ отправителю.
|
||||
- `access`: запись без владельца перестаёт быть возможной. Правило «чужую запись
|
||||
не отдают» не меняется; остаётся и правило «пустой владелец не совпадает ни с
|
||||
одной записью» — оно запрещает спрашивать ничьим именем, а не заводить ничью
|
||||
запись.
|
||||
- `storage`: колонка владельца у аудиозаписи и у файла перестаёт принимать пустое
|
||||
значение — это новый шаг схемы. Приведённая копия файла получает владельца
|
||||
своей записи. Из требований о подъёме на чистом каталоге и о пароле владельца
|
||||
уходит «обоими входами».
|
||||
|
||||
## Impact
|
||||
|
||||
- **Код**: удаляются клиент бота, отправитель сообщений, разбиение длинного
|
||||
текста, заглушка отправителя, транспорт бота, сборка входа при старте и договор
|
||||
об отправителе сообщений. Из службы расшифровки уходят приём записи от бота и
|
||||
отправка текста в чат.
|
||||
- **Настройки**: секция `[telegram]` целиком и ключ `server.users_while_list`
|
||||
(имя ключа в коде написано с опечаткой, и она здесь воспроизведена намеренно —
|
||||
искать в боевом файле надо именно его).
|
||||
- **Зависимости**: `go-telegram-bot-api/telegram-bot-api/v5` уходит из манифеста.
|
||||
- **Хранилище**: один новый шаг схемы, и он делает колонку владельца обязательной
|
||||
у аудиозаписи и у файла. Применённые шаги не переписываются, колонки чата и
|
||||
ответного сообщения остаются на месте вместе с записями, которые их заполнили.
|
||||
- **Наблюдение**: метка `telegram` у признака поднятого входа больше не
|
||||
выставляется.
|
||||
- **Документы**: паспорт теряет потребителя и два типовых сценария; инвариант о
|
||||
белом списке бота уходит из памятки; модель угроз теряет вход.
|
||||
- **Задачи беклога**: `telegram-account-link` и `bot-api-only-through-bot-client`
|
||||
теряют предмет до возвращения бота. Судьбу их решает владелец — это изменение
|
||||
их не закрывает.
|
||||
@@ -0,0 +1,192 @@
|
||||
# Ревью remove-telegram-intake — триаж
|
||||
|
||||
## Сводка
|
||||
|
||||
- **Режим:** по графу. **Метка:** `large` (разметка `review-scope`). 50 файлов, −2394 строки, семь
|
||||
удалённых файлов кода, новый шаг схемы; необратимый элемент — шаг схемы, переписаны инварианты
|
||||
`CLAUDE.md`.
|
||||
- **База диффа:** `origin/master` отстала на два закрытых изменения; сверка шла против `HEAD` плюс
|
||||
незакоммиченное рабочее дерево.
|
||||
- **Гейт:** зелёный целиком (`task gate` → 0).
|
||||
- **Сигнал о заниженной метке:** `review-code` возражений не подал. `review-basics` не запускался —
|
||||
второго независимого голоса о метке нет.
|
||||
- **На вход:** 22 находки (specs 5, code 8, architecture 3, ops 2, adversary 4, autotests 0) плюс
|
||||
4 замечания. После дедупликации по причине — 11 различных причин, одна выброшена как ошибочная.
|
||||
|
||||
### План с исходом по каждой теме
|
||||
|
||||
| тема | дом | глубина | кто закрывает | исход |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| requirements | `openspec/specs/{intake,pipeline,access,storage}` + дельты | разбор | specs | закрыта, 5 находок |
|
||||
| autotests | `CLAUDE.md` «Гейт»/«Команды» | — | autotests | закрыта, 0 находок + замечание о дрейфе `go-linters.md` |
|
||||
| conventions + техника | `docs/conventions/` | разбор | code | закрыта, 7 находок + 1 ошибочная |
|
||||
| architecture | `docs/architecture.md` (+ `passport.md`) | доказательство | architecture | закрыта, 3 находки |
|
||||
| security | `docs/security.md` | доказательство | adversary | закрыта, 4 находки + 3 свойства без пути |
|
||||
| operations | `docs/architecture.md` «Эксплуатация» (+ `database.md`) | доказательство | ops | закрыта, 2 находки |
|
||||
|
||||
Тем без отчёта нет. Тем без дома нет. `basics` не запускался — своих тем сверх ядра план ему не дал.
|
||||
|
||||
## Блокирует мердж
|
||||
|
||||
### Ничья запись переживёт новый шаг схемы и станет незакрываемой
|
||||
|
||||
- Файл: `internal/adapter/repo/pocketbase/migrations/202608140003_owner_required.go:20-30`;
|
||||
`openspec/changes/remove-telegram-intake/design.md`, «Migration Plan», п. 1
|
||||
- Severity: major. Confidence: high
|
||||
- Оракул (переснят триажем, `go test ./internal/adapter/repo/pocketbase/ -run TestTriageProbeOwnerRequiredOverOwnerlessRow`):
|
||||
|
||||
```
|
||||
PRE-STEP: ничья запись заведена, owner=""
|
||||
STEP 003 (Required=true) поверх ничьей записи: err=<nil>
|
||||
ПОСЛЕ ШАГА: строка на месте, owner=""
|
||||
FindAndAcquire: выдана запись
|
||||
Save остановленной ничьей записи: err=failed to update audio record: owner: cannot be blank.
|
||||
```
|
||||
|
||||
- Последствие: `Required` у поля связи PocketBase — проверка при сохранении записи, а не ограничение
|
||||
таблицы. Шаг проходит зелёным на базе с ничьей записью, и запись остаётся. Дальше она выдаётся
|
||||
воркеру (захват идёт сырым запросом мимо валидации), любое сохранение падает, `halt()` перехватывает
|
||||
отказ до строк, растящих `WorkerJobCounter` и пишущих `record_events`. Запись не останавливается,
|
||||
берётся снова по истечении срока захвата и повторяется неограниченно: ни метрики, ни журнала
|
||||
событий, только строка в логе контейнера. Предвыкладочная проверка, на которой стоит безопасность
|
||||
шага, не отличает чистую базу от грязной.
|
||||
- Найдено проходами: specs, code, ops (три прохода, одна причина).
|
||||
- **Действие: развилка.** Три варианта: (1) шаг сам считает ничьи строки и отказывается;
|
||||
(2) шаг остаётся, утверждение «падает на живой базе» уходит из комментария и плана, а в порядок
|
||||
выкладки добавляется ручная проверка запросом; (3) шаг приводит данные — необратимо, решение
|
||||
владельца обязательно.
|
||||
|
||||
### Второй ответ провайдера, приехавший пустым, стирает сохранённую расшифровку
|
||||
|
||||
- Файл: `internal/service/transcribe.go:716-733` (`poll` → `storeOutcome`),
|
||||
`internal/adapter/repo/pocketbase/text_repo.go:40-53`
|
||||
- Severity: critical. Confidence: high
|
||||
- Оракул: падающий тест, переснят триажем — `expected: "Личный разговор." actual: ""`.
|
||||
- Последствие: поток gRPC SpeechKit, закрывшийся на первом `Recv`, отказом не считается — `outcome`
|
||||
пуст, `err == nil`. `Texts.Put` кладёт пустое поверх сохранённого безусловно, рубеж двигается,
|
||||
запись доходит до `done` без текста. Сырой ответ уцелеет (`Finish` пишет вложение под условием
|
||||
`len(raw) > 0`), но `ReadRaw`/`Parse` в боевом коде не зовёт никто.
|
||||
- **Регрессией этого изменения не является:** `poll`, `storeOutcome` и `text_repo.go` диффом не
|
||||
тронуты. Изменение сняло последний видимый признак вырожденного ответа — заглушку «на записи нет
|
||||
текста», — но она уходила только в Telegram.
|
||||
- Найдено проходом: adversary.
|
||||
- **Действие: развилка.** (1) `storeOutcome` не пишет пустое поверх непустого; (2) вырожденный ответ
|
||||
считается отказом шага; (3) задача, мердж как есть.
|
||||
|
||||
## Стоит исправить сейчас
|
||||
|
||||
### Пустая расшифровка перестала замечаться, а два документа обещают, что она замечена
|
||||
|
||||
- Файл: `internal/service/transcribe.go` (`finish`), `docs/conventions/logging.md:62`,
|
||||
`docs/architecture.md:142`
|
||||
- Severity: major. Confidence: high
|
||||
- Оракул: `logging.md:62` называет «пустой текст распознавания» поимённым примером уровня `WARN`;
|
||||
`architecture.md:142` утверждал «запись завершается заглушкой „на записи нет текста"». Заглушку
|
||||
изменение убрало вместе с доставкой, замены не было.
|
||||
- **Действие: инлайн. Исправлено:** `poll` пишет `WARN` с идентификатором записи при пустом
|
||||
результате; строка `architecture.md:142` переписана на фактическое поведение; заведён оракул
|
||||
`TestEmptyRecognitionIsNamedInJournal`.
|
||||
|
||||
### Приёмка переписанного инварианта «остановленная запись несёт причину» не может упасть
|
||||
|
||||
- Файл: `internal/service/pipeline_test.go`
|
||||
- Severity: minor. Confidence: high
|
||||
- Оракул: `Halt()` ставит `HaltedAt` и `HaltReason` одним движением, `IsHalted()` читает `HaltedAt` —
|
||||
значит `require.NotNil(HaltReason)` следует из `require.True(IsHalted())`. Независимый сигнал
|
||||
(`sender.sent()`) убран вместе с доставкой.
|
||||
- **Действие: инлайн. Исправлено:** вторым утверждением взята строка журнала событий — отдельное
|
||||
сохранение, способное упасть само по себе; добавлена третья причина остановки.
|
||||
|
||||
### Удаление входа не доведено: подавление линтера, конвенции, фикстуры и комментарии
|
||||
|
||||
- Файл: `.golangci.yml:158`; `docs/conventions/go-linters.md:81,92,155`; `internal/contract/error.go`;
|
||||
`internal/config/config_test.go`; `internal/service/transcribe.go`; `internal/contract/repository.go`;
|
||||
`internal/metrics/format_label.go`; `internal/service/ownership_test.go`;
|
||||
`openspec/specs/pipeline/spec.md:104`
|
||||
- Severity: minor. Confidence: high
|
||||
- Последствие: подавление `errcheck` по мёртвому символу — заряженная мина: вход убран временно, метод
|
||||
`send` вернётся под тем же именем и молча окажется без проверки отказов. Остальное — документы и
|
||||
комментарии, обещающие ответ отправителю, включая нормативный доклад `contract/repository.go`.
|
||||
- Найдено проходами: specs, code, architecture, autotests (одна причина, четыре прохода).
|
||||
- **Действие: инлайн. Исправлено целиком:** снято подавление и строки про `send`; `controller/tg`
|
||||
убран из таблицы архправил; удалён `ErrDeliveryChannelDown`; переписаны комментарии; фикстуры
|
||||
переведены с мёртвой секции; требование «Захват задачи неделим» внесено в дельту.
|
||||
|
||||
### Анонимный запрос кладёт до мегабайта своего текста в журнал контейнера одной строкой
|
||||
|
||||
- Файл: `main.go:184-202`
|
||||
- Severity: major. Confidence: high
|
||||
- Оракул: живой прогон — 900000 байт в пути → 404, журнал вырос с 1048 до 901194 байт.
|
||||
- Последствие: длину строки журнала задаёт неузнанный посетитель; разбор инцидента по журналу
|
||||
становится невозможен. Тот же класс назван недопустимым в `internal/controller/http/auth.go:277-284`.
|
||||
- **Дефект пред-существующий:** `main.go` этим изменением правится только в части подъёма входов.
|
||||
- **Действие: развилка.** Чинить здесь или заводить задачей.
|
||||
|
||||
## Гипотезы без доказательства
|
||||
|
||||
- **`transcribed` — лишний рубеж** (architecture): готовая расшифровка платит отдельный захват и
|
||||
попадает под сторож застревания за проход, переставляющий одну колонку. Оракула, что это случалось,
|
||||
нет. Вопрос владельцу — место ему в задаче.
|
||||
- **Отказ приёма по пустому владельцу приходит новому вызывающему как 500** (specs): путь сегодня
|
||||
недостижим, транспорт отвергает раньше.
|
||||
- **`http.status_code=0` у всякого отвергнутого запроса** (adversary, `main.go:194-199`).
|
||||
Пред-существующее.
|
||||
- **Хвост имени отправителя уезжает в журнал внутри поля `error`** (adversary): инвариант приватности
|
||||
не нарушен, нарушена форма изъятия. Пред-существующее.
|
||||
- **Три свойства без построенного пути** (adversary): длительность из метаданных ничем не ограничена
|
||||
(переполнение `int`); непустой, но более короткий ответ провайдера затирает и вложение;
|
||||
`POST /api/collections/users/request-verification` открыт анониму.
|
||||
|
||||
**Выброшено как ошибочное:** находка `code` о `gofmt` на `pipeline_test.go` — `gofmt -l .` пуст,
|
||||
проверено дважды.
|
||||
|
||||
**Выброшено как вкусовщина:** переименование `CreateJobFromApi`.
|
||||
|
||||
**Проектные ложноположительные:** строка «Запись без владельца не достаётся никому» в `docs/review.md`
|
||||
отменена дважды, последний раз этой же задачей — находка о ничьей записи ей подтверждается, а не
|
||||
отсеивается.
|
||||
|
||||
## Promote candidates
|
||||
|
||||
- **В `docs/conventions/logging.md`:** длину поля журнала не задаёт вызывающий — всё, что приходит
|
||||
извне, уезжает в журнал усечённым до объявленного предела. Правила нет, случай второй.
|
||||
- **Отклонённый кандидат:** страж существования символов в `exclude-functions` — заводить нельзя,
|
||||
`CLAUDE.md` «Проверок над проверками не заводить» запрещает стражей предмета у правил.
|
||||
|
||||
## Границы покрытия
|
||||
|
||||
**Что запускалось.** Шесть проходов на метке `large`, режим по графу: specs, autotests, code,
|
||||
architecture, adversary, ops. `basics` не запускался — план не дал ему тем сверх ядра. Корректор метки
|
||||
(`review-code`) отработал, возражений не подал; второго корректора в прогоне не было.
|
||||
|
||||
**Потолки проходов.** Ни один проход не сообщил свой потолок и что осталось за срезом, хотя контракт
|
||||
обязывает. Неизвестно, показал ли `code` все находки или только верхние.
|
||||
|
||||
**Что не влезло в потолок триажа** (названо, а не выброшено):
|
||||
|
||||
- откат образа после вычистки секции `[telegram]` из боевого конфига роняет старт прежнего бинаря
|
||||
(`ops`, оракул — прогон исторического бинаря `9a964f2`);
|
||||
- ряд метрики `intake_up{telegram}` исчезает, а не обнуляется — цена не названа в документах владельца;
|
||||
- `down202608140003` не покрыт (0.0%) — так у всех четырёх шагов `down*`, и они операционно
|
||||
недостижимы: cobra-команды PocketBase не подключены;
|
||||
- частичное покрытие двух требований дельты: «Поднятые входы видны наблюдателю» (оракул только живой)
|
||||
и «Приведённая копия получает владельца записи» (косвенно).
|
||||
|
||||
**Что осталось целиком на человеке** — из `docs/review.md`, «Недоступно проверке»: поведение внешних
|
||||
сервисов под нагрузкой и на границах, реальный профиль нагрузки, стойкость `ffmpeg` к вредоносному
|
||||
входу, поведение настоящей Authelia, поведение браузера с куками. Сознательно перестали проверять:
|
||||
разбор вывода настоящего `ffprobe`; работа с настоящими SpeechKit и Object Storage; вход через живого
|
||||
провайдера OIDC.
|
||||
|
||||
**Четыре строки, которые в конвейере не закрывает никто:**
|
||||
|
||||
1. Решения проекта не сверялись — `docs/adr/` процессный, расхождение ловит `av-dev:doc-healthcheck`.
|
||||
2. Записанные наблюдения (`docs/research/`) не использовались; всякое число снято на этом прогоне.
|
||||
3. Поимённая сверка с руководствами по стилю Go не задавалась ни одним проходом.
|
||||
4. Альтернативной реализации, с которой можно сдиффить решения, у конвейера нет.
|
||||
|
||||
**Чем работал триаж.** Две пробы на копии дерева в скретчпаде, базы — временные каталоги. Боевых
|
||||
данных, боевых ключей и выкладки не касался.
|
||||
|
||||
Формулировки «критичных проблем не обнаружено» в отчёте нет: одна `critical` подтверждена падающим
|
||||
тестом и живёт в сервисе прямо сейчас.
|
||||
@@ -0,0 +1,57 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: У записи есть владелец, и чужую ей не отдают
|
||||
|
||||
Сервис SHALL заводить у каждой принятой записи владельца — учётную запись, от
|
||||
имени которой запись принята, — и MUST отдавать данные такой записи только её
|
||||
владельцу. Запись без владельца MUST не заводиться ничем — ни приёмом, ни конвейером, ни
|
||||
рукой в панели: колонка владельца пустого значения не принимает, и норму эту
|
||||
держит capability `storage`.
|
||||
|
||||
Владелец назначается один раз, при приёме, и MUST не меняться: совместного
|
||||
доступа, ролей и передачи записи другому сервис не знает.
|
||||
|
||||
Владелец MUST браться из предъявленной сессии и ниоткуда больше. Владелец,
|
||||
пришедший полем запроса, дал бы всякому вошедшему право завести запись на чужое
|
||||
имя.
|
||||
|
||||
Обращение к чужой записи MUST быть неотличимо от обращения к несуществующей.
|
||||
Отдельный отказ «доступ запрещён» превращает опрос в перебор — по разнице
|
||||
ответов считывается, какие записи заведены, а идентификатор записи и есть то,
|
||||
что разграничение прячет. Каким именно ответом это выражено, нормирует
|
||||
capability `intake`: там живёт адрес опроса, и держатель нормы обязан быть один.
|
||||
|
||||
Пустой владелец MUST не совпадать ни с одной записью. Правило записано со стороны
|
||||
**спрашивающего** и остаётся в силе, хотя записей без владельца в хранилище
|
||||
больше нет: спрашивающий с пустым владельцем — это вызов, у которого нет учётной
|
||||
записи, и отвечать ему надо отказом, а не выборкой. Держится оно отдельно от
|
||||
схемы намеренно: схема запрещает **заводить** ничью запись, а это правило
|
||||
запрещает **спрашивать** ничьим именем, и одно другое не заменяет.
|
||||
|
||||
#### Scenario: Своя запись доступна
|
||||
|
||||
- **GIVEN** человек вошёл и принял запись
|
||||
- **WHEN** он спрашивает состояние этой записи своей сессией
|
||||
- **THEN** ответ несёт состояние записи
|
||||
|
||||
#### Scenario: Чужая запись неотличима от несуществующей
|
||||
|
||||
- **GIVEN** запись принята одним вошедшим
|
||||
- **WHEN** её состояние спрашивает другой вошедший
|
||||
- **THEN** ответ тот же, что и на неизвестный идентификатор, — и кодом, и телом
|
||||
|
||||
#### Scenario: Владельца не задают запросом
|
||||
|
||||
- **WHEN** запрос на приём записи несёт своё значение владельца
|
||||
- **THEN** владельцем принятой записи становится предъявитель сессии
|
||||
|
||||
#### Scenario: Ничью запись завести нечем
|
||||
|
||||
- **WHEN** запись пытаются завести с пустым владельцем
|
||||
- **THEN** хранилище её не сохраняет
|
||||
|
||||
#### Scenario: Пустой владелец не открывает ничего
|
||||
|
||||
- **GIVEN** заведены две записи: своя и чужая
|
||||
- **WHEN** состояние каждой спрашивают с пустым владельцем
|
||||
- **THEN** ответ на обе тот же, что и на неизвестный идентификатор
|
||||
@@ -0,0 +1,245 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Приём записи по HTTP
|
||||
|
||||
Сервис SHALL принимать запись от внешней программы запросом `POST /api/audio` с
|
||||
телом `multipart/form-data` и полем `audio` **только от узнанного отправителя**.
|
||||
Запрос без сессии MUST получать код `401`, и по нему MUST не заводиться ни файл,
|
||||
ни аудиозапись. Принятая запись от узнанного отправителя MUST быть сохранена и
|
||||
получить заведённую под неё аудиозапись на рубеже `uploaded`; ответ MUST нести
|
||||
идентификатор записи полем `job_id` и её рубеж полем `status`.
|
||||
|
||||
Значение рубежа в ответе изменилось: прежде приём отдавал `created`. Перечень
|
||||
состояний назван проектом необратимым, и ломка объявлена прямо — состояние
|
||||
теперь называет достигнутое, а не предстоящее, и `created` в новом перечне нет
|
||||
вовсе.
|
||||
|
||||
Имена полей ответа нормативны и MUST остаться прежними: контракт HTTP API
|
||||
объявлен проектом необратимым, и переименование поля ломает внешнюю программу
|
||||
молча. Меняются значения поля рубежа, а не его имя.
|
||||
|
||||
Отказ по отсутствию сессии наступает **раньше** чтения тела: запись, за которую
|
||||
не заплатит узнанный отправитель, не должна попасть даже в память.
|
||||
|
||||
Приём не судит о годности записи сам: расширение он берёт из имени файла, а
|
||||
пригодность содержимого узнаёт у источника метаданных.
|
||||
|
||||
Куда именно ложится принятая запись, приёму не принадлежит: раскладку выбирает
|
||||
хранилище, и нормирует её capability `storage`.
|
||||
|
||||
Владельцем принятой записи приём SHALL назначать предъявителя сессии. Обязательность
|
||||
владельца при этом MUST держаться и схемой хранилища: колонка владельца пустого
|
||||
значения не принимает вовсе, и норму эту держит capability `storage`. Проверка в
|
||||
приёме от этого не лишняя — она отвечает отправителю понятным отказом до того, как
|
||||
запись попадёт в память, а схема отвечала бы отказом сохранения после укладки
|
||||
файла.
|
||||
|
||||
Предъявитель, чья сессия не даёт учётной записи пользователя, MUST получать
|
||||
отказ `403` и MUST получать его **до чтения тела** — там же, где стоит отказ по
|
||||
отсутствию сессии. Сессия владельца панели — именно такой случай: узнан он всё
|
||||
же узнан, а записи в коллекции пользователей у него нет, и владельцем записи он
|
||||
стать не может.
|
||||
|
||||
Код здесь другой, чем у запроса без сессии, и это не оплошность: `401` значит
|
||||
«предъяви себя», а предъявитель себя предъявил. Утечки по разнице кодов нет —
|
||||
оба ответа говорят о самом спрашивающем, а не о том, какие записи заведены.
|
||||
|
||||
Отказ **после** укладки записи потребовал бы убрать уже сохранённый файл, а
|
||||
уборки файлов сервис не умеет вовсе: норма, обязывающая к недостижимому, не
|
||||
пишется.
|
||||
|
||||
#### Scenario: Запись принята
|
||||
|
||||
- **GIVEN** источник метаданных читает запись и отдаёт её длительность
|
||||
- **AND** отправитель предъявил сессию
|
||||
- **WHEN** программа шлёт `POST /api/audio` с полем `audio`
|
||||
- **THEN** ответ имеет код `201`, а в теле лежат непустой `job_id` и `status`
|
||||
со значением `uploaded`
|
||||
- **AND** содержимое записи целиком лежит в хранилище одним файлом
|
||||
- **AND** владельцем заведённой аудиозаписи стоит предъявитель сессии
|
||||
|
||||
#### Scenario: Сессия не даёт учётной записи пользователя
|
||||
|
||||
- **GIVEN** предъявлена сессия владельца панели
|
||||
- **WHEN** он шлёт `POST /api/audio` с полем `audio`
|
||||
- **THEN** ответ имеет код `403`
|
||||
- **AND** ни файла, ни аудиозаписи не заводится
|
||||
|
||||
#### Scenario: Сессии нет
|
||||
|
||||
- **WHEN** программа шлёт `POST /api/audio` с полем `audio` без сессии
|
||||
- **THEN** ответ имеет код `401`
|
||||
- **AND** ни файла, ни аудиозаписи не заводится
|
||||
- **AND** тело ответа не несёт данных записи
|
||||
|
||||
#### Scenario: Поля с записью нет
|
||||
|
||||
- **GIVEN** отправитель предъявил сессию
|
||||
- **WHEN** программа шлёт `POST /api/audio` без поля `audio`
|
||||
- **THEN** ответ имеет код `400` и сообщение об отсутствии записи
|
||||
- **AND** ни файла, ни аудиозаписи не заводится
|
||||
|
||||
#### Scenario: Размеру записи приём не судья
|
||||
|
||||
- **GIVEN** источник метаданных читает запись и отдаёт её длительность
|
||||
- **AND** отправитель предъявил сессию
|
||||
- **WHEN** программа шлёт запись нулевой длины
|
||||
- **THEN** ответ имеет код `201`: собственного порога по размеру у приёма нет
|
||||
|
||||
### Requirement: Опрос готовности задачи
|
||||
|
||||
Сервис SHALL отдавать рубеж аудиозаписи по запросу `GET /api/status/:id`
|
||||
**только её владельцу**. Запрос без сессии MUST получать код `401`, и тело
|
||||
такого ответа MUST не нести ни рубежа записи, ни текста расшифровки. Ответ
|
||||
владельцу MUST нести идентификатор полем `job_id`, рубеж полем `status` и время
|
||||
заведения полем `created_at`, а текст расшифровки полем `transcription_text`, и
|
||||
это поле MUST отсутствовать в ответе, пока текста нет: пустая строка на месте
|
||||
отсутствующего текста читается как «расшифровка пуста».
|
||||
|
||||
Видов текста у записи больше одного, поэтому ответ MUST называть вид, который
|
||||
отдаёт: в поле `transcription_text` уходит **сырая расшифровка**, и только она.
|
||||
Вычитанный текст этим полем MUST не подменяться — иначе значение поля менялось бы
|
||||
у одной и той же записи от того, успел ли отработать необязательный шаг, а
|
||||
контракт объявлен необратимым. Отдача «последнего записанного» текста MUST не
|
||||
применяться: она делает ответ функцией порядка записи, а не состояния записи.
|
||||
|
||||
Перечень значений поля `status` MUST совпадать с перечнем рубежей конвейера:
|
||||
`uploaded`, `normalized`, `submitted`, `transcribed`, `done`. Прежних значений
|
||||
`created`, `converted`, `transcribe`, `failed` и `dead` в ответе MUST не быть.
|
||||
Это объявленная ломка публичного контракта: рубеж называет достигнутое, а отказ
|
||||
перестал быть состоянием.
|
||||
|
||||
Остановленная запись MUST отдавать рубеж, на котором она остановлена, и MUST
|
||||
нести признак остановки отдельным полем `halted` со значением истины. Машинный
|
||||
текст отказа MUST в ответ не попадать: он принадлежит журналу владельца сервиса,
|
||||
а не отправителю. Этот адрес — **единственное** место, где отправитель узнаёт о
|
||||
неудаче: доставки ответа отправителю у сервиса больше нет, и признак остановки
|
||||
здесь несёт всю обязанность целиком.
|
||||
|
||||
Отказ без сессии MUST не зависеть от того, есть такая запись или нет: иначе по
|
||||
кодам ответа перебирается список заведённых записей.
|
||||
|
||||
Запись, принадлежащая другому, MUST отвечать тем же, чем отвечает неизвестный
|
||||
идентификатор, — кодом `404` и тем же телом.
|
||||
|
||||
#### Scenario: Запись найдена
|
||||
|
||||
- **GIVEN** отправитель предъявил сессию
|
||||
- **WHEN** он спрашивает рубеж своей записи
|
||||
- **THEN** ответ имеет код `200` и несёт `job_id`, `status` и `created_at`
|
||||
- **AND** значение `status` принадлежит перечню рубежей конвейера
|
||||
|
||||
#### Scenario: Запись остановлена
|
||||
|
||||
- **GIVEN** запись остановлена признаком на рубеже приведения
|
||||
- **WHEN** владелец спрашивает её рубеж
|
||||
- **THEN** поле `status` несёт рубеж приведения
|
||||
- **AND** поле `halted` несёт истину
|
||||
- **AND** машинного текста отказа в ответе нет
|
||||
|
||||
#### Scenario: Сессии нет
|
||||
|
||||
- **WHEN** программа спрашивает рубеж заведённой записи без сессии
|
||||
- **THEN** ответ имеет код `401`
|
||||
- **AND** тело ответа не несёт ни рубежа записи, ни текста расшифровки
|
||||
|
||||
#### Scenario: Без сессии неизвестная запись неотличима от заведённой
|
||||
|
||||
- **WHEN** программа без сессии спрашивает рубеж заведённой записи, а затем
|
||||
рубеж по неизвестному идентификатору
|
||||
- **THEN** оба ответа имеют код `401`
|
||||
|
||||
#### Scenario: Чужая запись неотличима от неизвестной
|
||||
|
||||
- **GIVEN** запись заведена одним вошедшим
|
||||
- **WHEN** её рубеж спрашивает другой вошедший
|
||||
- **THEN** ответ имеет код `404` и то же тело, что и ответ по неизвестному
|
||||
идентификатору
|
||||
- **AND** тело ответа не несёт ни рубежа записи, ни текста расшифровки
|
||||
|
||||
#### Scenario: Расшифровки ещё нет
|
||||
|
||||
- **GIVEN** отправитель предъявил сессию
|
||||
- **WHEN** он спрашивает рубеж своей записи, которая ещё не дошла до текста
|
||||
- **THEN** поля `transcription_text` в ответе нет вовсе
|
||||
|
||||
#### Scenario: Записи с таким идентификатором нет
|
||||
|
||||
- **GIVEN** отправитель предъявил сессию
|
||||
- **WHEN** программа спрашивает рубеж по неизвестному идентификатору
|
||||
- **THEN** ответ имеет код `404` и сообщение о ненайденной записи
|
||||
|
||||
### Requirement: Имя файла, данное отправителем, не попадает в журнал
|
||||
|
||||
Приём SHALL не писать имя файла, данное отправителем, ни в одну свою журнальную
|
||||
запись — ни на успешном пути, ни на пути отказа, где имя могло бы приехать
|
||||
текстом ошибки. Имя приходит извне вместе с записью и принадлежит содержимому
|
||||
личной переписки наравне с текстом расшифровки; журнал уезжает в собранные логи,
|
||||
откуда строку не убрать.
|
||||
|
||||
Расширение, взятое из этого имени, в журнале остаётся собственным полем: по нему
|
||||
прослеживается путь записи. Что именно попадает в журнал ради прослеживаемости,
|
||||
нормирует требование ниже; наружу расширение выходит только приведённым к
|
||||
известному виду — этому отдано отдельное требование.
|
||||
|
||||
Оговорка про второй вход из требования ушла вместе с ним: имя, данное
|
||||
отправителем, доходит до сервиса единственным путём — приёмом по HTTP, — и
|
||||
сценарии судят именно его.
|
||||
|
||||
#### Scenario: Имя записи не видно в журнале принятой записи
|
||||
|
||||
- **GIVEN** источник метаданных читает запись и отдаёт её длительность
|
||||
- **WHEN** программа шлёт `POST /api/audio` с записью, чья основа имени несёт
|
||||
опознаваемую строку при обычном расширении `.mp3`
|
||||
- **THEN** ни одна журнальная запись приёма этой строки не содержит
|
||||
- **AND** расширение `.mp3` в журнале допустимо
|
||||
|
||||
#### Scenario: Имя записи не видно в журнале при отказе приёма
|
||||
|
||||
- **GIVEN** источник метаданных не может прочитать запись
|
||||
- **WHEN** программа шлёт `POST /api/audio` с записью, чья основа имени несёт
|
||||
опознаваемую строку
|
||||
- **THEN** ни одна журнальная запись приёма, включая запись об ошибке, этой
|
||||
строки не содержит
|
||||
|
||||
### Requirement: Поднятые входы видны наблюдателю
|
||||
|
||||
Сервис SHALL отдавать признак поднятости по каждому входу приёма отдельной
|
||||
метрикой и MUST выставлять метку только тому входу, который у сервиса есть.
|
||||
Метки убранного входа в метриках MUST не быть вовсе: признак со значением нуля
|
||||
читался бы как «вход есть, но не поднялся», то есть как поломка, а вечная
|
||||
единица рядом с ним — как исправность того, чего нет.
|
||||
|
||||
Проверяемое здесь одно — **набор меток**, и это честнее прежнего. Вход остался
|
||||
один, страница метрик отдаётся тем же сервером, что и приём, и значение нуля у
|
||||
единственной метки недостижимо: чтобы прочитать признак, надо дотянуться до
|
||||
входа, о котором он сообщает. Прежнее обоснование — «иначе потерянный вход не
|
||||
виден ничем» — было верно, пока входов было два; сегодня неподнятый вход виден
|
||||
неудачей чтения самих метрик.
|
||||
|
||||
Различать поднятый и неподнятый вход признак MUST снова, как только входов у
|
||||
сервиса станет больше одного.
|
||||
|
||||
#### Scenario: В метриках только оставшийся вход
|
||||
|
||||
- **GIVEN** сервис поднялся
|
||||
- **WHEN** наблюдатель читает метрики
|
||||
- **THEN** признак поднятости несёт метку входа HTTP со значением единицы
|
||||
- **AND** метки убранного входа Telegram в метриках нет вовсе
|
||||
|
||||
## REMOVED Requirements
|
||||
|
||||
### Requirement: Признак включения решает, поднимается ли вход Telegram
|
||||
|
||||
**Reason**: Вход Telegram убран из сервиса целиком, и решать о его подъёме стало
|
||||
нечего. Настройки входа — признак включения, ключ доступа и срок ожидания
|
||||
обновлений — уходят из конфигурации вместе с ним.
|
||||
|
||||
**Migration**: Секцию `[telegram]` и ключ `server.users_while_list` — имя в коде
|
||||
именно такое, с опечаткой, и в боевом файле искать надо его — из файла настроек
|
||||
убрать руками. Оставленные ключи сервис пропускает молча — незнакомые
|
||||
ключи разбор настроек не судит, — и потому файл, забытый как есть, поднимет
|
||||
сервис без бота и без единого слова о том, что секция больше ничего не значит.
|
||||
Записи с источником Telegram, если они в базе есть, остаются нетронутыми и
|
||||
достаются своему владельцу; ответ в чат по ним не уходит. Возврат входа заводится
|
||||
новым изменением вместе со связью чата и учётной записи.
|
||||
@@ -0,0 +1,258 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Конвейер ответа отправителю не шлёт
|
||||
|
||||
Шаг конвейера SHALL доводить запись до достигнутого рубежа и MUST не обращаться
|
||||
к отправителю вовсе — ни с готовым текстом, ни с сообщением о неудаче. Исход
|
||||
своей записи отправитель узнаёт опросом готовности и в панели владельца; адрес
|
||||
опроса и содержимое ответа нормирует capability `intake`.
|
||||
|
||||
Требование заведено взамен доставки в чат, убранной вместе с входом Telegram.
|
||||
Без него молчание конвейера читалось бы как недоделка: прежде ответ уходил, и
|
||||
всякий, кто помнит это, ищет в шаге отправку, а её отсутствие принимает за
|
||||
потерянную ветку.
|
||||
|
||||
Инвариант проекта «Принятая запись не теряется молча» держится теперь опросом
|
||||
готовности — там остановка видна признаком — и журналом владельца, где у неё
|
||||
стоит причина. Обязанность при этом сменила направление: прежде об отказе
|
||||
сообщали, теперь отказ доступен спросившему. Отправитель, который не
|
||||
спрашивает, об остановке не узнаёт.
|
||||
|
||||
Записи, которой этот канал недоступен, не бывает: у каждой записи есть владелец,
|
||||
и опрос отдаёт ему её исход. Держится это обязательностью владельца в схеме
|
||||
хранилища — норму держит capability `storage`.
|
||||
|
||||
#### Scenario: Готовый текст отправителю не уходит
|
||||
|
||||
- **GIVEN** запись дошла до конечного рубежа
|
||||
- **WHEN** шаг конвейера её завершает
|
||||
- **THEN** ни одного обращения наружу с текстом расшифровки не уходит
|
||||
- **AND** текст достаётся опросом готовности
|
||||
|
||||
#### Scenario: Остановка видна опросом, а не сообщением
|
||||
|
||||
- **GIVEN** запись остановлена по исчерпании отказов
|
||||
- **WHEN** владелец записи спрашивает её рубеж
|
||||
- **THEN** ответ несёт достигнутый рубеж и признак остановки
|
||||
- **AND** в журнале владельца сервиса есть запись об остановке с причиной
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Захват задачи неделим
|
||||
|
||||
Захват записи воркером SHALL быть одним неделимым шагом хранилища: выбор
|
||||
подходящей записи и пометка её захваченной MUST происходить вместе.
|
||||
|
||||
Захват MUST возвращать **идентификатор записи и признак этого захвата**, а не
|
||||
перечень её колонок. Колонки записи шаг читает сам, обычным чтением. Иначе
|
||||
всякая новая колонка аудиозаписи попадала бы под инвариант проекта о колонках
|
||||
очереди, и забытая в захвате колонка приезжала бы нулевой, а первое же
|
||||
сохранение писало бы этот ноль поверх сохранённого значения.
|
||||
|
||||
**Признак захвата MUST быть значением, уникальным для каждого захвата**, а не
|
||||
признаком занятости. Условие записи результата сверяет именно это значение:
|
||||
захват, перевыданный другому — по протуханию срока или после того, как человек
|
||||
снял признак остановки в панели, — обязан обращать запись первого в отказ.
|
||||
Условие, проверяющее лишь непустоту признака или срок, пропустило бы обоих, и
|
||||
два шага записали бы в одну запись по очереди, испортив её результат.
|
||||
|
||||
Одна и та же запись MUST доставаться ровно одному захватившему. Двум вызывающим,
|
||||
пришедшим за работой одновременно, запись MUST достаться одному, а второй MUST
|
||||
получить признак «работы сейчас нет».
|
||||
|
||||
Срок протухания захвата MUST ехать с рубежом записи, а не с воркером: воркер не
|
||||
привязан к шагу и не знает заранее, что вытянет. Срок MUST записываться числом
|
||||
при самом захвате.
|
||||
|
||||
Порядок выборки MUST быть определён однозначно: сравнения по неуникальному
|
||||
значению для этого мало, и к нему MUST добавляться ключ записи. Иначе порядок
|
||||
обработки невоспроизводим, а проверка, опирающаяся на «следующую» запись, зелена
|
||||
через раз.
|
||||
|
||||
Требование стоит на инварианте проекта «Принятая запись не теряется молча»:
|
||||
захват, разделённый на два шага, отдаёт одну запись двум воркерам, и работа
|
||||
одного из них теряется без следа.
|
||||
|
||||
Признак «работы нет» этим требованием не переопределяется — его нормирует
|
||||
требование «Пустой прогон воркера — не отказ».
|
||||
|
||||
#### Scenario: За работой пришли трое разом
|
||||
|
||||
- **GIVEN** к работе пригодна ровно одна запись
|
||||
- **WHEN** три захвата идут одновременно
|
||||
- **THEN** запись получает ровно один из них
|
||||
- **AND** двое остальных получают признак «работы сейчас нет»
|
||||
|
||||
#### Scenario: Захваченная запись не выдаётся второй раз
|
||||
|
||||
- **GIVEN** запись захвачена и срок захвата не истёк
|
||||
- **WHEN** приходит следующий захват
|
||||
- **THEN** эта запись ему не выдаётся
|
||||
|
||||
#### Scenario: Захват отдаёт идентификатор и свой признак
|
||||
|
||||
- **GIVEN** к работе пригодна запись
|
||||
- **WHEN** воркер её захватывает
|
||||
- **THEN** захват возвращает идентификатор записи и признак этого захвата
|
||||
- **AND** колонки записи шаг читает отдельным чтением
|
||||
|
||||
#### Scenario: Признак перевыданного захвата отличается от прежнего
|
||||
|
||||
- **GIVEN** запись захвачена, и признак первого захвата известен
|
||||
- **WHEN** человек снимает признак остановки, и запись захватывает другой воркер
|
||||
- **THEN** признак нового захвата отличается от признака первого
|
||||
|
||||
### Requirement: Результат пишет только держатель захвата
|
||||
|
||||
Шаг конвейера SHALL записывать свой результат только тогда, когда захват записи
|
||||
всё ещё принадлежит ему. Запись MUST быть условна по **признаку этого захвата** —
|
||||
значению, уникальному для каждого захвата, — а не по занятости записи вообще.
|
||||
Шаг, чей захват за время работы достался другому, MUST завершиться без записи
|
||||
результата.
|
||||
|
||||
Требование закрывает то, чего неделимость захвата не закрывает: захват протухает
|
||||
не только у мёртвого воркера, но и у живого — шаг, идущий дольше своего срока,
|
||||
теряет запись, продолжая работать. Снять захват может и человек, вернувший
|
||||
остановленную запись в работу. Без условия по уникальному признаку два воркера
|
||||
пишут в одну запись по очереди, а счётчик отказов сбрасывает тот, кто уже не
|
||||
владелец.
|
||||
|
||||
Довод про два ответа отправителю из требования ушёл вместе с доставкой: обращений
|
||||
наружу шаг не делает. Требование от этого не ослабло — порча записи двумя
|
||||
пишущими остаётся его предметом целиком.
|
||||
|
||||
Шаг MUST записывать только те поля, которыми распоряжается сам. Запись он держит
|
||||
снимком с момента захвата и до записи — это часы, — и безусловная запись снимка
|
||||
стёрла бы всё, что владелец правил в панели за это время: молча, без строки в
|
||||
журнале и без отказа в панели. Владелец увидел бы успешное сохранение и был бы
|
||||
уверен, что правка на месте. Владелец записи, заголовок, краткое описание и темы
|
||||
конвейер MUST не трогать.
|
||||
|
||||
#### Scenario: Правка владельца пережила сохранение шага
|
||||
|
||||
- **GIVEN** шаг держит захваченную запись
|
||||
- **AND** владелец за это время изменил в панели поле, которого шаг не касается
|
||||
- **WHEN** шаг записывает свой результат
|
||||
- **THEN** результат шага записан
|
||||
- **AND** правка владельца на месте
|
||||
|
||||
#### Scenario: Захват ушёл под работающим шагом
|
||||
|
||||
- **GIVEN** шаг работает над захваченной записью
|
||||
- **AND** за это время та же запись досталась другому захвату
|
||||
- **WHEN** первый шаг доходит до записи результата
|
||||
- **THEN** результат не записывается
|
||||
|
||||
#### Scenario: Человек снял остановку под работающим шагом
|
||||
|
||||
- **GIVEN** шаг работает над захваченной записью
|
||||
- **AND** человек за это время снял с неё признак остановки, освободив захват
|
||||
- **AND** запись досталась другому воркеру
|
||||
- **WHEN** первый шаг доходит до записи результата
|
||||
- **THEN** результат не записывается
|
||||
|
||||
### Requirement: Число отказов ограничивает повторы шага
|
||||
|
||||
У аудиозаписи SHALL быть число отказов. Оно MUST расти при каждом захвате и MUST
|
||||
возвращаться к нулю, когда шаг завершился без отказа либо отложил работу. Рост
|
||||
при захвате, а не при отказе, засчитывает попытку и записи, брошенной на
|
||||
середине: шаг, уносящий с собой процесс, до объявления отказа не доходит
|
||||
никогда.
|
||||
|
||||
**Остановка сервиса отказом не считается.** Шаг, прерванный отменой по
|
||||
собственной остановке сервиса, MUST возвращать число отказов назад и MUST не
|
||||
выносить записи приговора: запись не виновата в том, что нас перезапустили, и
|
||||
несколько выкладок подряд иначе останавливают здоровую многочасовую запись с
|
||||
приговором «отказы исчерпаны». Всякая другая причина, по которой шаг не дошёл до
|
||||
объявления исхода, отказ тратит.
|
||||
|
||||
Запись, захваченная с числом отказов сверх заданного предела, MUST
|
||||
останавливаться признаком тем, кто её захватил, и MUST не отдаваться шагу в
|
||||
работу. Остановка эта видна отправителю опросом готовности наравне с прочими —
|
||||
норму держит capability `intake`.
|
||||
|
||||
Этот сторож MUST отвечать только за повторы внутри шага. Время, проведённое
|
||||
записью в рубеже, MUST мериться отдельным сторожем: одно число не справляется ни
|
||||
с одной из двух обязанностей — опрос, вернувший «ещё в работе», обнуляет его, и
|
||||
зависшая чужая операция опрашивается вечно, а не обнулял бы — убивал бы здоровую
|
||||
запись.
|
||||
|
||||
#### Scenario: Запись отказывает на каждой попытке
|
||||
|
||||
- **GIVEN** шаг конвейера отказывает на каждой попытке
|
||||
- **WHEN** запись проходит заданное число отказов
|
||||
- **THEN** у неё появляется признак остановки
|
||||
- **AND** следующий захват её не выдаёт
|
||||
- **AND** опрос готовности отдаёт владельцу записи признак остановки
|
||||
|
||||
#### Scenario: Шаг уносит процесс, не объявив отказа
|
||||
|
||||
- **GIVEN** шаг конвейера обрывается вместе с процессом на каждой попытке
|
||||
- **WHEN** запись захватывается снова заданное число раз
|
||||
- **THEN** у неё появляется признак остановки
|
||||
|
||||
#### Scenario: Остановка сервиса отказа не тратит
|
||||
|
||||
- **GIVEN** шаг работает над записью
|
||||
- **WHEN** сервис останавливают, и шаг прерывается отменой
|
||||
- **THEN** число отказов записи прежнее
|
||||
- **AND** признака остановки у записи не появляется
|
||||
|
||||
#### Scenario: Прошедшая запись отказов не копит
|
||||
|
||||
- **GIVEN** запись прошла подряд несколько рубежей без единого отказа
|
||||
- **WHEN** смотрят её число отказов
|
||||
- **THEN** оно не приблизилось к пределу
|
||||
|
||||
### Requirement: Выборка воркера владельцем не сужается
|
||||
|
||||
Воркер SHALL брать записи всех владельцев подряд и MUST не учитывать владельца
|
||||
при выборе очередной записи.
|
||||
|
||||
Владелец решает, кому запись показывать, а не кому её считать. Сужение выборки
|
||||
владельцем поставило бы записи одних людей в зависимость от того, кто первым
|
||||
завёл учётную запись.
|
||||
|
||||
Оговорка про записи без владельца из требования ушла: заводить их стало нечем —
|
||||
колонка владельца пустого значения не принимает, и норму держит capability
|
||||
`storage`.
|
||||
|
||||
Владелец записи MUST переживать работу конвейера: шаг, сохраняющий свой
|
||||
результат, владельца не трогает и не затирает.
|
||||
|
||||
#### Scenario: Записи двух владельцев проходят одним воркером
|
||||
|
||||
- **GIVEN** заведены записи двух разных владельцев на одном рубеже
|
||||
- **WHEN** воркер забирает работу
|
||||
- **THEN** ему достаются обе, в порядке заведения
|
||||
|
||||
#### Scenario: Шаг конвейера владельца не затирает
|
||||
|
||||
- **GIVEN** запись с владельцем прошла шаг конвейера
|
||||
- **WHEN** шаг сохраняет свой результат
|
||||
- **THEN** владелец записи остаётся прежним
|
||||
|
||||
## REMOVED Requirements
|
||||
|
||||
### Requirement: Недоставленный ответ не роняет шаг
|
||||
|
||||
**Reason**: Доставка ответа отправителю убрана вместе с входом Telegram, и
|
||||
недоставке взяться неоткуда: обращения наружу шаг больше не делает. Обе прежние
|
||||
причины недоставки — неподнятый вход отправителя и неназванный адресат записи —
|
||||
описывали именно этот вход.
|
||||
|
||||
**Migration**: Счётчик недоставленных ответов и записи журнала о недоставке
|
||||
уходят вместе с требованием; наблюдателю, построившему на них отбор, ждать от
|
||||
них значений больше нечего. Исход записи виден опросом готовности и журналом
|
||||
событий записи.
|
||||
|
||||
### Requirement: Всякая остановка сообщает отправителю
|
||||
|
||||
**Reason**: Обязанность сообщить требовала адресата, а адресатом был чат
|
||||
Telegram. С убранным входом сообщать стало нечем и некуда, и обязанность
|
||||
переходит к опросу готовности — её держит требование «Конвейер ответа
|
||||
отправителю не шлёт» вместе с capability `intake`.
|
||||
|
||||
**Migration**: Отправитель узнаёт об остановке признаком в ответе опроса
|
||||
готовности. Владелец сервиса видит остановку записью журнала и полем причины у
|
||||
самой записи — как и прежде.
|
||||
@@ -0,0 +1,181 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Пустой результат не кладётся поверх сохранённого
|
||||
|
||||
Хранилище SHALL не заменять сохранённое содержимое приложения записи — текст и
|
||||
структуру реплик — пустым. Замена пустым MUST оставлять прежнее значение и
|
||||
считаться сделанной работой, а не отказом.
|
||||
|
||||
Требование стоит на повторном опросе одной и той же операции распознавания.
|
||||
Повтор — обычное дело: держатель захвата умер, сохранение рубежа отказало,
|
||||
человек снял признак остановки в панели. Провайдер при этом вправе ответить
|
||||
пустым потоком, отказом это не считается, и безусловная замена стирала бы
|
||||
расшифровку живого человека — без следа и без возврата, потому что сервис
|
||||
объявлен архивом и удаления по требованию не знает.
|
||||
|
||||
Та же защита MUST стоять у сырого ответа провайдера: разное правило у двух
|
||||
хранителей одного результата читается как недосмотр, и один из них молча теряет
|
||||
то, ради чего второй заведён.
|
||||
|
||||
Норма записана со стороны **хранилища**, а не шага: шагов, кладущих текст,
|
||||
больше одного, и правило, записанное у одного из них, у остальных читалось бы
|
||||
как снятое.
|
||||
|
||||
#### Scenario: Пустой второй ответ не стирает расшифровку
|
||||
|
||||
- **GIVEN** расшифровка записи сохранена
|
||||
- **WHEN** ту же операцию опрашивают снова, и провайдер отвечает пустым
|
||||
- **THEN** сохранённая расшифровка остаётся прежней
|
||||
- **AND** шаг завершается без отказа
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Сервис поднимается на чистом каталоге данных
|
||||
|
||||
Сервис SHALL приводить хранилище в рабочий вид сам: на пустом каталоге данных он
|
||||
MUST завести свою схему и принимать записи своим входом — приёмом по HTTP — без
|
||||
единого ручного шага до первого запуска.
|
||||
|
||||
Прежние данные не переносятся. Каталог, оставшийся от прежней раскладки, MUST не
|
||||
читаться и не считаться источником: сервис начинает с чистого листа, и это
|
||||
решение задачи, а не следствие отказа.
|
||||
|
||||
Схема MUST заводиться версионированными шагами, а применённый шаг MUST не
|
||||
переписываться — только новым шагом. Иначе повторный запуск на уже заведённом
|
||||
каталоге разошёлся бы с первым молча.
|
||||
|
||||
Каталог данных у сервиса MUST быть один: база и файлы записей лежат под ним
|
||||
вместе, и второго пути к ним не заводится.
|
||||
|
||||
#### Scenario: Первый запуск на пустом каталоге
|
||||
|
||||
- **GIVEN** каталог данных пуст
|
||||
- **WHEN** сервис запускается
|
||||
- **THEN** он заводит своё хранилище и продолжает работу
|
||||
- **AND** принятая следом запись доходит до состояния `done`
|
||||
|
||||
#### Scenario: Повторный запуск на заведённом каталоге
|
||||
|
||||
- **GIVEN** сервис уже запускался на этом каталоге и завёл хранилище
|
||||
- **WHEN** он запускается снова
|
||||
- **THEN** он не заводит схему второй раз и не теряет прежние записи
|
||||
|
||||
### Requirement: Пароль владельца от панели не лежит в конфигурации
|
||||
|
||||
Сервис SHALL не заводить в конфигурации ключа под пароль владельца от панели.
|
||||
Пароль MUST задаваться самим владельцем, а хранилище MUST держать только его
|
||||
отпечаток.
|
||||
|
||||
Требование стоит на инварианте проекта «Секрет не покидает конфиг» с другой
|
||||
стороны: секрет, которого в конфигурации нет, не утекает вместе с ней и не
|
||||
уезжает в выкладку третьим путём. Пароль от панели открывает все записи и все
|
||||
файлы разом — это самое чувствительное, что есть у сервиса.
|
||||
|
||||
Приглашение завести владельца сервис MUST печатать только пока владельца нет, и
|
||||
оно MUST истекать по времени. Приглашение равносильно паролю от панели, а
|
||||
печатается оно в журнал контейнера, откуда строку не убрать: бессрочное отдало бы
|
||||
панель всякому читателю логов навсегда.
|
||||
|
||||
Пока владелец пароля не задал, сервис MUST принимать записи: панель без владельца
|
||||
приёму не мешает.
|
||||
|
||||
#### Scenario: Владелец пароля ещё не задал
|
||||
|
||||
- **GIVEN** каталог данных пуст и владелец панели не заведён
|
||||
- **WHEN** сервис запускается
|
||||
- **THEN** он принимает записи
|
||||
- **AND** ни один ключ конфигурации не несёт пароля от панели
|
||||
|
||||
#### Scenario: Владелец заведён, приглашение больше не печатается
|
||||
|
||||
- **GIVEN** владелец панели заведён
|
||||
- **WHEN** сервис запускается снова
|
||||
- **THEN** приглашения завести владельца в журнале нет
|
||||
|
||||
### Requirement: Владелец задачи лежит связью с учётной записью
|
||||
|
||||
Хранилище SHALL держать владельца аудиозаписи отдельной колонкой — связью с
|
||||
учётной записью, — и эта колонка MUST не иметь умолчания: запись, чей владелец
|
||||
не назван, не достаётся никому по недосмотру схемы.
|
||||
|
||||
Колонка MUST не допускать пустого значения. Прежде допускала, и цену платили за
|
||||
записи, принятые ботом: связи чата с учётной записью сервис не вёл. С убранным
|
||||
входом заводить ничью запись стало некому, и обязательность переезжает из одного
|
||||
лишь приёма в схему — туда, где её держит хранилище, а не договорённость. Разница
|
||||
не косметическая: пока обязательность жила в приёме, ничью запись заводили руками
|
||||
в панели, и она уходила в конвейер, стоила денег на распознавание и не доставалась
|
||||
потом никому.
|
||||
|
||||
Владелец MUST не назначаться и не меняться конвейером.
|
||||
|
||||
#### Scenario: Колонка появляется на пустой базе
|
||||
|
||||
- **WHEN** сервис поднимается на чистом каталоге данных
|
||||
- **THEN** у аудиозаписи есть колонка владельца
|
||||
- **AND** умолчания у неё нет
|
||||
- **AND** пустого значения она не принимает
|
||||
|
||||
#### Scenario: Запись без владельца не сохраняется
|
||||
|
||||
- **GIVEN** сервис поднят
|
||||
- **WHEN** аудиозапись пытаются сохранить с пустым владельцем — приёмом,
|
||||
конвейером или руками в панели
|
||||
- **THEN** хранилище её не сохраняет
|
||||
|
||||
#### Scenario: Конвейер владельца не назначает
|
||||
|
||||
- **GIVEN** запись с владельцем прошла шаг конвейера
|
||||
- **WHEN** смотрят её владельца
|
||||
- **THEN** он прежний
|
||||
|
||||
### Requirement: Файл записи сужается владельцем наравне с задачей
|
||||
|
||||
Хранилище SHALL держать владельца и у файла записи — той же связью с учётной
|
||||
записью, — и правило просмотра файлов MUST пускать к файлу только его владельца.
|
||||
|
||||
Владелец файла MUST назначаться при приёме, из предъявленной сессии, а колонка
|
||||
файла MUST не допускать пустого значения наравне с колонкой записи. Прежде пустое
|
||||
значение оставалось у файлов, заведённых конвейером для записи без владельца;
|
||||
таких записей больше не заводится, и разное правило у записи и у её файла
|
||||
читалось бы как недосмотр.
|
||||
|
||||
Файл, заведённый шагом конвейера, — приведённую копию заводит именно он —
|
||||
MUST получать владельца своей записи. Иного источника владельца у файла нет, и
|
||||
шаг, оставивший его пустым, упрётся в отказ сохранения: запись накопит отказы и
|
||||
остановится признаком на первом же приведении.
|
||||
|
||||
Ссылки на файлы у записи две — на принятую копию и на приведённую, — и обе живут
|
||||
до конца, но владелец файла MUST по-прежнему лежать своей колонкой, а не
|
||||
выводиться через запись: файл переживает свою запись, и заведённый шагом до
|
||||
сохранения записи он остаётся с владельцем и без ссылки.
|
||||
|
||||
Отказ наступает **на переходе по ссылке**, а не на выдаче токена файла: токен
|
||||
хранилище выдаёт на предъявителя, а не на файл, и о файле при выдаче не
|
||||
спрашивает вовсе. Требовать отказа при выдаче значит требовать механизма,
|
||||
которого нет, — а проверка, написанная под такое требование, зеленела бы, не
|
||||
касаясь пути, по которому аудио и уходит.
|
||||
|
||||
#### Scenario: Чужой файл не отдаётся
|
||||
|
||||
- **GIVEN** запись принята одним вошедшим
|
||||
- **WHEN** другой вошедший идёт по ссылке на файл этой записи со своим токеном
|
||||
- **THEN** содержимого он не получает
|
||||
|
||||
#### Scenario: Свой файл отдаётся
|
||||
|
||||
- **GIVEN** человек принял запись
|
||||
- **WHEN** он идёт по ссылке на файл своей записи со своим токеном
|
||||
- **THEN** содержимое отдаётся
|
||||
|
||||
#### Scenario: Файл без владельца не сохраняется
|
||||
|
||||
- **GIVEN** сервис поднят
|
||||
- **WHEN** файл записи пытаются сохранить с пустым владельцем
|
||||
- **THEN** хранилище его не сохраняет
|
||||
|
||||
#### Scenario: Приведённая копия получает владельца записи
|
||||
|
||||
- **GIVEN** запись с владельцем дошла до приведения
|
||||
- **WHEN** шаг заводит приведённую копию файла
|
||||
- **THEN** владельцем копии стоит владелец записи
|
||||
- **AND** шаг завершается без отказа
|
||||
@@ -0,0 +1,131 @@
|
||||
## 1. Модель записи и отображение в хранилище
|
||||
|
||||
- [x] 1.1 Убрать из `entity.AudioRecord` поля адресата ответа `TgChatId` и
|
||||
`TgReplyMessageId`
|
||||
- [x] 1.2 Оставить константу `entity.SourceTelegram` и объявить её комментарием
|
||||
историческим значением: на неё ссылается применённый шаг схемы `202608140002`,
|
||||
переписывать который запрещено инвариантом проекта
|
||||
- [x] 1.3 Сделать владельца записи обычной строкой вместо ссылки, допускающей
|
||||
отсутствие, и провести это через `applyToRecord` и `recordToAudioRecord`
|
||||
- [x] 1.4 Убрать колонки адресата из `record_mapping.go` в обоих направлениях;
|
||||
заведёнными в схеме они при этом остаются
|
||||
- [x] 1.5 Завести **новый** шаг схемы: колонка владельца у аудиозаписи и у файла
|
||||
перестаёт принимать пустое значение. Откат шага возвращает необязательность
|
||||
- [x] 1.6 Убедиться, что ни один **применённый** шаг схемы не изменён: `task
|
||||
migrations` отвечает нулём
|
||||
- [x] 1.7 Проверить, что конвейер заводит приведённую копию файла с владельцем
|
||||
записи: без этого первый же шаг приведения упрётся в обязательность колонки
|
||||
|
||||
## 2. Служба расшифровки
|
||||
|
||||
- [x] 2.1 Убрать метод приёма записи от бота `CreateJobFromTelegram`
|
||||
- [x] 2.2 Убрать из службы отправителя сообщений: поле, параметр конструктора,
|
||||
отправку текста, сообщение о неудаче и запись о недоставке
|
||||
- [x] 2.3 Убрать метрику недоставленных ответов вместе с её причинами
|
||||
- [x] 2.4 Убрать человеческие тексты отказа, которые уходили отправителю: их
|
||||
единственным читателем была отправка. Шаг, доводящий запись до конечного
|
||||
рубежа, остаётся — он двигает рубеж, — и обращений наружу не делает ни одного
|
||||
- [x] 2.5 Убрать из `internal/archrules` транспорт `internal/controller/tg` из
|
||||
перечня транспортов: правило требует существования названных пакетов, и без
|
||||
этой правки гейт краснеет удалением каталога
|
||||
|
||||
## 3. Вход и сборка сервиса
|
||||
|
||||
- [x] 3.1 Удалить пакет `internal/adapter/telegram` целиком
|
||||
- [x] 3.2 Удалить транспорт `internal/controller/tg` целиком
|
||||
- [x] 3.3 Удалить `telegram_build.go` и `telegram_build_test.go`
|
||||
- [x] 3.4 Убрать из `main.go` сборку входа, запуск транспорта в отдельной
|
||||
горутине и остановку бота при завершении
|
||||
- [x] 3.5 Убрать из `internal/contract` договор об отправителе сообщений
|
||||
- [x] 3.6 Убрать выставление метки `telegram` у признака поднятого входа; метка
|
||||
`http` остаётся
|
||||
|
||||
## 4. Настройки и зависимости
|
||||
|
||||
- [x] 4.1 Убрать `TelegramConfig`, её умолчания и проверку обязательности ключа
|
||||
`telegram.enabled`
|
||||
- [x] 4.2 Убрать ключ `server.users_while_list` из структуры настроек и
|
||||
умолчаний. Имя написано с опечаткой — `while` вместо `white`, — и она стоит
|
||||
«Расхождением» в `docs/conventions/config.md`: удаление ключа закрывает и его
|
||||
- [x] 4.3 Убрать секцию `[telegram]` из `config.example.toml` вместе с
|
||||
пояснениями. Ключа списка допущенных в образце нет — это второе записанное
|
||||
«Расхождение», и оно закрывается тем же удалением
|
||||
- [x] 4.4 Прогнать `go mod tidy` и убедиться, что `go-telegram-bot-api` ушёл из
|
||||
`go.mod` и `go.sum`
|
||||
|
||||
## 5. Проверки
|
||||
|
||||
- [x] 5.1 Поправить тесты, опирающиеся на убранный вход: приём, владение,
|
||||
конвейер, метрики, недоставка, завершение работы
|
||||
- [x] 5.2 Оставить проверку того, что запись без владельца не заводится: приём
|
||||
без учётной записи отвечает `403` и не заводит ни файла, ни записи
|
||||
- [x] 5.3 Оставить проверку того, что запись без владельца не достаётся опросом:
|
||||
ответ тот же, что и на неизвестный идентификатор
|
||||
- [x] 5.4 `task gate` зелёный целиком
|
||||
- [x] 5.5 Поведенческая проверка на живом сервисе: подъём с файлом настроек без
|
||||
секции `[telegram]`, приём записи по HTTP, опрос готовности, признак поднятого
|
||||
входа в метриках
|
||||
|
||||
## 6. Документы канона
|
||||
|
||||
- [x] 6.1 Паспорт: убрать потребителя «Пользователь Telegram» и сценарии 3 и 4;
|
||||
поправить строку об основном входе и сценарий 6 — сообщения о неудаче
|
||||
отправитель больше не получает, а видит исход опросом. Убранный вход записать
|
||||
событием с датой, как записаны прочие сдвиги границы
|
||||
- [x] 6.2 `CLAUDE.md`: убрать инвариант «Бот отвечает только тем, кто в белом
|
||||
списке» (**critical**) целиком; переписать инвариант «Остановленная запись
|
||||
сообщает отправителю, какой бы ни была причина» (**major**) в терминах опроса
|
||||
готовности; переписать инвариант «Принятая запись не теряется молча»
|
||||
(**major**) — он требует сообщить пользователю и называет состояние `failed`,
|
||||
которого нет с прошлой задачи; снять «и без ответа отправителю» из инварианта о
|
||||
держателе захвата;
|
||||
поправить раздел «Что это» (входов больше не два), «Стек» (зависимость ушла) и
|
||||
запрет «Боевым токеном бота не запускаться» вместе с рецептом локального
|
||||
подъёма через `telegram.enabled = false`
|
||||
- [x] 6.3 `docs/architecture.md`: поправить обзор capability поимённо — буллеты
|
||||
`intake` (признак включения входа), `pipeline` (недоставленный ответ), `access`
|
||||
(запись из Telegram без владельца), строку «поведение прочих узлов, включая
|
||||
приём из Telegram, живёт только в коде», принцип «бот, HTTP-сервер и воркеры в
|
||||
одном бинарнике» и строку «через него идут оба входа» в «Единых точках
|
||||
проекта»; `docs/security.md` —
|
||||
убрать вход из периметра; `docs/database.md` — сказать про колонки, оставшиеся
|
||||
без кода; `docs/conventions/` — снять ключи бота и закрыть оба «Расхождения»
|
||||
про `users_while_list`
|
||||
- [x] 6.4 `docs/review.md`: снять род узла «транспорт `internal/controller/tg`» и
|
||||
«клиент внешнего сервиса `adapter/telegram`» из типовых узлов, ложноположительное
|
||||
про запись без владельца, вопрос «через него идут оба входа», триггер метки
|
||||
«трогает оба входа сразу» и рецепт живого прогона через `telegram.enabled = false`
|
||||
- [x] 6.5 `README.md`: убрать вход из описания сервиса и из настроек
|
||||
- [x] 6.6 Проза актуальных спек, которую дельты не правят: разделы Purpose у
|
||||
`intake`, `pipeline` и `access` — дельты правят только требования, и проза
|
||||
иначе уедет в архив с обещаниями про бота
|
||||
|
||||
## Критерии приёмки
|
||||
|
||||
Постановка пришла текстом и критериев не назвала. Ниже — **предложенные**;
|
||||
приняты они после ответа человека на чекпоинте.
|
||||
|
||||
1. Ни одного упоминания входа Telegram нигде в дереве, кроме мест, где оно
|
||||
законно. Оракул: `grep -ri telegram` по всему репозиторию; законны ровно
|
||||
четыре места — применённые шаги схемы и константа источника, архив изменений
|
||||
`openspec/changes/archive/`, записи решений `docs/adr/` и историческая часть
|
||||
журнала ревью. Всё прочее — находка. Оракул нарочно шире кода: прошлые
|
||||
удаления теряли документы именно потому, что их сверяли грепом по `internal/`.
|
||||
2. Применённые шаги схемы не переписаны, колонки `tg_chat_id`,
|
||||
`tg_reply_message_id` и значение `telegram` перечня источников на месте.
|
||||
Изменение добавляет ровно один новый шаг — обязательность владельца. Оракул:
|
||||
`task migrations` отвечает нулём, `git diff` по каталогу шагов показывает
|
||||
только добавленный файл.
|
||||
3. Запись без владельца завести нечем ни одним путём. Оракулы: тест приёма, где
|
||||
сессия не даёт учётной записи, — ответ `403`, ни файла, ни аудиозаписи не
|
||||
заведено; тест хранилища — сохранение записи и файла с пустым владельцем
|
||||
отклоняется схемой.
|
||||
4. Отправитель узнаёт об остановке опросом готовности. Оракул: тест опроса на
|
||||
остановленной записи — поле рубежа несёт достигнутый рубеж, поле остановки
|
||||
несёт истину, машинного текста отказа в ответе нет.
|
||||
5. Сервис поднимается с файлом настроек без секции `[telegram]` и принимает
|
||||
запись по HTTP. Оракул: живой запуск по разделу команд `CLAUDE.md`, `POST
|
||||
/api/audio` отвечает `201`, `GET /api/status/:id` отвечает `200`.
|
||||
6. Признак поднятого входа несёт метку `http` и не несёт метки `telegram`.
|
||||
Оракул: чтение адреса метрик у поднятого сервиса.
|
||||
7. `task gate` зелёный целиком.
|
||||
@@ -10,12 +10,10 @@ OIDC, чем предъявляется сессия, что её прекращ
|
||||
только на вопрос «узнан ли пришедший», но и на «чьё он смотрит». Запись из веба
|
||||
принадлежит тому, кто её принёс, и чужая неотличима от несуществующей.
|
||||
|
||||
Записи, принятые ботом, владельца не имеют вовсе и по API не достаются никому:
|
||||
связи чата Telegram с учётной записью приложения сервис не ведёт, её заводит
|
||||
отдельная задача.
|
||||
|
||||
Вход из Telegram эта capability не нормирует: бот проверяет отправителя своим
|
||||
белым списком, и с учётной записью приложения тот список не связан.
|
||||
Записи без владельца у сервиса не бывает: колонка владельца пустого значения не
|
||||
принимает, и норму эту держит capability `storage`. Прежде такие записи заводил
|
||||
вход Telegram — связи чата с учётной записью сервис не вёл, — и 2026-08-14 вход
|
||||
убран вместе с этим исключением.
|
||||
## Requirements
|
||||
### Requirement: Вход через внешнего провайдера
|
||||
|
||||
@@ -350,13 +348,14 @@ MUST не делать. Кто допущен, определяет правил
|
||||
|
||||
### Requirement: У записи есть владелец, и чужую ей не отдают
|
||||
|
||||
Сервис SHALL заводить у каждой записи, принятой **по HTTP**, — владельца, то
|
||||
есть учётную запись, от имени которой запись принята, — и MUST отдавать данные
|
||||
такой записи только её владельцу. Владелец назначается один раз, при приёме, и
|
||||
MUST не меняться у записи, у которой владелец есть: совместного доступа, ролей и
|
||||
передачи записи другому сервис не знает. Оговорка не случайна — назначить
|
||||
владельца записи, у которой его нет, вправе задача, заводящая связь чата
|
||||
Telegram с учётной записью.
|
||||
Сервис SHALL заводить у каждой принятой записи владельца — учётную запись, от
|
||||
имени которой запись принята, — и MUST отдавать данные такой записи только её
|
||||
владельцу. Запись без владельца MUST не заводиться ничем — ни приёмом, ни конвейером, ни
|
||||
рукой в панели: колонка владельца пустого значения не принимает, и норму эту
|
||||
держит capability `storage`.
|
||||
|
||||
Владелец назначается один раз, при приёме, и MUST не меняться: совместного
|
||||
доступа, ролей и передачи записи другому сервис не знает.
|
||||
|
||||
Владелец MUST браться из предъявленной сессии и ниоткуда больше. Владелец,
|
||||
пришедший полем запроса, дал бы всякому вошедшему право завести запись на чужое
|
||||
@@ -368,16 +367,12 @@ Telegram с учётной записью.
|
||||
что разграничение прячет. Каким именно ответом это выражено, нормирует
|
||||
capability `intake`: там живёт адрес опроса, и держатель нормы обязан быть один.
|
||||
|
||||
Пустой владелец MUST не совпадать ни с одной записью — ни со своей, ни с чужой,
|
||||
ни с ничьей. Правило записано со стороны **спрашивающего**, а не со стороны
|
||||
записи: обязательность владельца, которую держит одна лишь подпись метода, пустую
|
||||
строку пропускает, и первый же вызывающий без учётной записи получил бы ровно
|
||||
множество записей без владельца, то есть все записи бота.
|
||||
|
||||
Записи, принятые из Telegram, владельца не имеют: связи чата с учётной записью
|
||||
приложения сервис не ведёт. Такая запись MUST не доставаться по API никому —
|
||||
ответ на неё тот же, что и на несуществующую, — а её расшифровка уезжает
|
||||
отправителю в чат, как и прежде.
|
||||
Пустой владелец MUST не совпадать ни с одной записью. Правило записано со стороны
|
||||
**спрашивающего** и остаётся в силе, хотя записей без владельца в хранилище
|
||||
больше нет: спрашивающий с пустым владельцем — это вызов, у которого нет учётной
|
||||
записи, и отвечать ему надо отказом, а не выборкой. Держится оно отдельно от
|
||||
схемы намеренно: схема запрещает **заводить** ничью запись, а это правило
|
||||
запрещает **спрашивать** ничьим именем, и одно другое не заменяет.
|
||||
|
||||
#### Scenario: Своя запись доступна
|
||||
|
||||
@@ -396,15 +391,13 @@ capability `intake`: там живёт адрес опроса, и держат
|
||||
- **WHEN** запрос на приём записи несёт своё значение владельца
|
||||
- **THEN** владельцем принятой записи становится предъявитель сессии
|
||||
|
||||
#### Scenario: Запись из Telegram не достаётся по API
|
||||
#### Scenario: Ничью запись завести нечем
|
||||
|
||||
- **GIVEN** запись принята ботом
|
||||
- **WHEN** её состояние спрашивает вошедший человек
|
||||
- **THEN** ответ тот же, что и на неизвестный идентификатор
|
||||
- **WHEN** запись пытаются завести с пустым владельцем
|
||||
- **THEN** хранилище её не сохраняет
|
||||
|
||||
#### Scenario: Пустой владелец не открывает ничего
|
||||
|
||||
- **GIVEN** заведены три задачи: своя, чужая и принятая ботом
|
||||
- **GIVEN** заведены две записи: своя и чужая
|
||||
- **WHEN** состояние каждой спрашивают с пустым владельцем
|
||||
- **THEN** ответ на все три тот же, что и на неизвестный идентификатор
|
||||
|
||||
- **THEN** ответ на обе тот же, что и на неизвестный идентификатор
|
||||
|
||||
+33
-128
@@ -6,12 +6,9 @@
|
||||
записью, что уезжает в ответ и что происходит, когда запись не удалось
|
||||
прочитать. Плюс наличие входов: с каким из них сервис вправе подняться.
|
||||
|
||||
Приём по существу описан пока **только для HTTP** — того, что нормируют
|
||||
проверки. Про вход Telegram нормировано одно: настроен он или нет и что из этого
|
||||
следует для подъёма. Кто допущен к боту и как забирается присланная им запись,
|
||||
требованиями по-прежнему не описано — требование, написанное без проверки, это
|
||||
предположение, а не норма. Первая задача, которая трогает поведение приёма из
|
||||
Telegram, дописывает его сюда.
|
||||
Вход у сервиса один — приём по HTTP, — и описан он тем, что нормируют проверки.
|
||||
Второй вход, Telegram, убран 2026-08-14 вместе со своими требованиями; его
|
||||
возвращение заводит их заново, вместе со связью чата и учётной записи.
|
||||
## Requirements
|
||||
### Requirement: Приём записи по HTTP
|
||||
|
||||
@@ -40,10 +37,12 @@ Telegram, дописывает его сюда.
|
||||
Куда именно ложится принятая запись, приёму не принадлежит: раскладку выбирает
|
||||
хранилище, и нормирует её capability `storage`.
|
||||
|
||||
Владельцем принятой записи приём SHALL назначать предъявителя сессии. Проверка
|
||||
стоит здесь, а не только в схеме хранилища: колонка владельца допускает пустое
|
||||
значение ради записей из Telegram, и приём по HTTP — то место, где
|
||||
обязательность держится.
|
||||
Владельцем принятой записи приём SHALL назначать предъявителя сессии. Обязательность
|
||||
владельца при этом MUST держаться и схемой хранилища: колонка владельца пустого
|
||||
значения не принимает вовсе, и норму эту держит capability `storage`. Проверка в
|
||||
приёме от этого не лишняя — она отвечает отправителю понятным отказом до того, как
|
||||
запись попадёт в память, а схема отвечала бы отказом сохранения после укладки
|
||||
файла.
|
||||
|
||||
Предъявитель, чья сессия не даёт учётной записи пользователя, MUST получать
|
||||
отказ `403` и MUST получать его **до чтения тела** — там же, где стоит отказ по
|
||||
@@ -154,11 +153,9 @@ Telegram, дописывает его сюда.
|
||||
нормирует требование ниже; наружу расширение выходит только приведённым к
|
||||
известному виду — этому отдано отдельное требование.
|
||||
|
||||
Сценарии судят приём по HTTP, потому что имя, данное отправителем, доходит до
|
||||
сервиса только оттуда: из Telegram приходит путь, выданный самим Telegram, а не
|
||||
имя человека. Правка при этом ложится на общий шаг заведения задачи, через
|
||||
который идут оба входа, поэтому своей нормы приём из Telegram здесь не получает —
|
||||
её напишет задача, которая тронет его поведение.
|
||||
Оговорка про второй вход из требования ушла вместе с ним: имя, данное
|
||||
отправителем, доходит до сервиса единственным путём — приёмом по HTTP, — и
|
||||
сценарии судят именно его.
|
||||
|
||||
#### Scenario: Имя записи не видно в журнале принятой записи
|
||||
|
||||
@@ -265,15 +262,15 @@ Telegram, дописывает его сюда.
|
||||
Остановленная запись MUST отдавать рубеж, на котором она остановлена, и MUST
|
||||
нести признак остановки отдельным полем `halted` со значением истины. Машинный
|
||||
текст отказа MUST в ответ не попадать: он принадлежит журналу владельца сервиса,
|
||||
а не отправителю. Отправитель узнаёт о неудаче ответом там, откуда пришла
|
||||
запись, — это нормирует capability `pipeline`.
|
||||
а не отправителю. Этот адрес — **единственное** место, где отправитель узнаёт о
|
||||
неудаче: доставки ответа отправителю у сервиса больше нет, и признак остановки
|
||||
здесь несёт всю обязанность целиком.
|
||||
|
||||
Отказ без сессии MUST не зависеть от того, есть такая запись или нет: иначе по
|
||||
кодам ответа перебирается список заведённых записей.
|
||||
|
||||
Запись, принадлежащая другому, MUST отвечать тем же, чем отвечает неизвестный
|
||||
идентификатор, — кодом `404` и тем же телом. То же MUST относиться к записи без
|
||||
владельца: запись, принятая ботом, по этому адресу не достаётся никому.
|
||||
идентификатор, — кодом `404` и тем же телом.
|
||||
|
||||
#### Scenario: Запись найдена
|
||||
|
||||
@@ -325,117 +322,25 @@ Telegram, дописывает его сюда.
|
||||
### Requirement: Поднятые входы видны наблюдателю
|
||||
|
||||
Сервис SHALL отдавать признак поднятости по каждому входу приёма отдельной
|
||||
метрикой. Признак MUST выставляться при сборке входа и MUST различать поднятый
|
||||
вход и неподнятый.
|
||||
метрикой и MUST выставлять метку только тому входу, который у сервиса есть.
|
||||
Метки убранного входа в метриках MUST не быть вовсе: признак со значением нуля
|
||||
читался бы как «вход есть, но не поднялся», то есть как поломка, а вечная
|
||||
единица рядом с ним — как исправность того, чего нет.
|
||||
|
||||
Требование стоит на том, что иначе потерянный вход не виден ничем: проба
|
||||
здоровья отвечает «сервис работает» и при неподнятом боте, а запись журнала
|
||||
живёт до ротации и вопрос «работает ли вход сейчас» не отвечает. Метрика —
|
||||
единственный канал наблюдения, который у владельца автоматизирован.
|
||||
Проверяемое здесь одно — **набор меток**, и это честнее прежнего. Вход остался
|
||||
один, страница метрик отдаётся тем же сервером, что и приём, и значение нуля у
|
||||
единственной метки недостижимо: чтобы прочитать признак, надо дотянуться до
|
||||
входа, о котором он сообщает. Прежнее обоснование — «иначе потерянный вход не
|
||||
виден ничем» — было верно, пока входов было два; сегодня неподнятый вход виден
|
||||
неудачей чтения самих метрик.
|
||||
|
||||
#### Scenario: Вход Telegram не поднят
|
||||
Различать поднятый и неподнятый вход признак MUST снова, как только входов у
|
||||
сервиса станет больше одного.
|
||||
|
||||
- **GIVEN** сервис поднялся без Telegram
|
||||
#### Scenario: В метриках только оставшийся вход
|
||||
|
||||
- **GIVEN** сервис поднялся
|
||||
- **WHEN** наблюдатель читает метрики
|
||||
- **THEN** признак поднятости входа Telegram равен нулю
|
||||
- **AND** признак поднятости входа HTTP равен единице
|
||||
|
||||
### Requirement: Признак включения решает, поднимается ли вход Telegram
|
||||
|
||||
Намерение владельца SHALL объявляться отдельным признаком включения входа
|
||||
Telegram, а ключ доступа MUST означать только доступ. При выключенном входе
|
||||
сервис MUST подниматься без Telegram и MUST не смотреть на ключ доступа вовсе.
|
||||
При включённом входе пустой ключ MUST быть отказом старта: сообщение называет имя
|
||||
незаполненного ключа и MUST не нести его значения.
|
||||
|
||||
Признак включения MUST быть в настройках задан. Умолчания у него нет: файл, где
|
||||
признака нет вовсе, негоден, и сервис MUST выходить с ошибкой настройки, назвав
|
||||
недостающий ключ. Умолчание здесь было бы угаданным намерением, а признак заведён
|
||||
затем, чтобы намерение объявляли: любое умолчание делает одну из двух ошибок
|
||||
тихой — либо бот молча пропадает, либо файл без признака молча работает.
|
||||
|
||||
Выключенный вход MUST быть назван в журнале **ровно одной** записью уровня `INFO`
|
||||
при старте. Это выбор владельца, а не отклонение, и предупреждать о нём не о чем;
|
||||
предупреждение остаётся за тем, чего владелец не выбирал.
|
||||
|
||||
При включённом входе сервис SHALL подниматься, когда вход поднять не удалось, и
|
||||
MUST продолжать работу оставшимся входом: приём по HTTP, опрос готовности и
|
||||
конвейер расшифровки работают в полном объёме. Неподнятый вход MUST быть назван в
|
||||
журнале **ровно одной** записью уровня `WARN` при старте — с причиной и без
|
||||
значения ключа.
|
||||
|
||||
Исключение одно, и оно проходит по тому, **ответил ли Telegram**. Ответ «такого
|
||||
бота нет» — ошибка настройки: бот по этому ключу не появится ни от ожидания, ни
|
||||
от повтора, и старт MUST кончаться отказом. Сервис, молча потерявший бота после
|
||||
опечатки в ключе, перестаёт отвечать своим отправителям, и узнать об этом было бы
|
||||
неоткуда.
|
||||
|
||||
Всё прочее — недоступность: сеть, DNS, авария Bot API, истёкший срок ожидания.
|
||||
Она MUST не влиять на подъём. Основной вход сервиса — не Telegram, и ронять его
|
||||
целиком из-за чужой аварии нельзя: перезапуск в такую минуту оставил бы без
|
||||
работы и приём по HTTP, и панель, и конвейер, которому Telegram не нужен вовсе.
|
||||
|
||||
Ожидание при сборке MUST быть ограничено сроком. Без него недоступность
|
||||
неотличима от подъёма: обращение к Telegram стоит на пути старта, и молчащий
|
||||
собеседник останавливал бы его бессрочно — без записи, без порта и без пробы
|
||||
здоровья.
|
||||
|
||||
Требование нормирует **наличие входа**, а не приём из него.
|
||||
|
||||
#### Scenario: Вход выключен
|
||||
|
||||
- **GIVEN** в настройках сервиса вход Telegram выключен
|
||||
- **WHEN** сервис запускается
|
||||
- **THEN** он поднимается и принимает записи по HTTP
|
||||
- **AND** конвейер расшифровки работает
|
||||
- **AND** бот не заведён, а в журнале ровно одна запись уровня `INFO` о том, что
|
||||
вход выключен настройкой
|
||||
|
||||
#### Scenario: Вход выключен, а ключ доступа задан
|
||||
|
||||
- **GIVEN** в настройках сервиса вход Telegram выключен
|
||||
- **AND** ключ доступа при этом заполнен
|
||||
- **WHEN** сервис запускается
|
||||
- **THEN** он поднимается без Telegram, и бот не заводится
|
||||
- **AND** к Telegram не уходит ни одного обращения
|
||||
|
||||
#### Scenario: Вход включён, а ключа доступа нет
|
||||
|
||||
- **GIVEN** в настройках сервиса вход Telegram включён
|
||||
- **AND** ключ доступа пуст
|
||||
- **WHEN** сервис запускается
|
||||
- **THEN** старт кончается отказом
|
||||
- **AND** сообщение об отказе называет имя незаполненного ключа
|
||||
|
||||
#### Scenario: Признака включения в настройках нет
|
||||
|
||||
- **GIVEN** в настройках сервиса нет признака включения входа Telegram
|
||||
- **AND** ключ доступа заполнен и Telegram признаёт по нему бота
|
||||
- **WHEN** сервис запускается
|
||||
- **THEN** старт кончается отказом настройки
|
||||
- **AND** сообщение об отказе называет недостающий ключ
|
||||
|
||||
#### Scenario: Вход включён и ключ годен
|
||||
|
||||
- **GIVEN** в настройках сервиса вход Telegram включён
|
||||
- **AND** стоит ключ, по которому Telegram признаёт бота
|
||||
- **WHEN** сервис запускается
|
||||
- **THEN** он поднимается и работает обоими входами
|
||||
|
||||
#### Scenario: Telegram не отвечает
|
||||
|
||||
- **GIVEN** в настройках сервиса вход Telegram включён и ключ непуст
|
||||
- **AND** Telegram недоступен либо не отвечает дольше отведённого срока
|
||||
- **WHEN** сервис запускается
|
||||
- **THEN** он поднимается и принимает записи по HTTP
|
||||
- **AND** бот не заведён, а в журнале запись уровня `WARN` с причиной
|
||||
- **AND** запись не несёт значения ключа
|
||||
|
||||
#### Scenario: Telegram ответил, что такого бота нет
|
||||
|
||||
- **GIVEN** в настройках сервиса вход Telegram включён и ключ непуст
|
||||
- **AND** Telegram отвечает отказом на этот ключ
|
||||
- **WHEN** сервис запускается
|
||||
- **THEN** старт кончается отказом
|
||||
- **AND** ни журнал, ни текст отказа не несут значения ключа
|
||||
- **THEN** признак поднятости несёт метку входа HTTP со значением единицы
|
||||
- **AND** метки убранного входа Telegram в метриках нет вовсе
|
||||
|
||||
|
||||
+57
-127
@@ -3,14 +3,14 @@
|
||||
## Purpose
|
||||
|
||||
Конвейер расшифровки: как аудиозапись движется по рубежам, что делает воркер,
|
||||
когда работы нет, что считается отказом шага и что бывает с ответом отправителю,
|
||||
когда доставить его некуда.
|
||||
когда работы нет, и что считается отказом шага.
|
||||
|
||||
Описаны цепочка рубежей и смысл рубежа, остановка признаком и её причины, оба
|
||||
сторожа — число отказов и время в рубеже, — откладывание работы отдельно от
|
||||
перехода, неделимость захвата и срок его протухания, условие записи результата
|
||||
держателем захвата, нарастающая пауза перед повтором, число воркеров настройкой,
|
||||
журнал событий записи и недоставка ответа при неподнятом входе.
|
||||
журнал событий записи и молчание конвейера наружу: обращений к отправителю он не
|
||||
делает вовсе, и свой исход тот узнаёт опросом готовности.
|
||||
|
||||
Сознательно не описаны: освобождение ресурсов внешних клиентов и **какие отказы
|
||||
считаются приговором записи, а какие поводом к повтору**. Второе — не пробел
|
||||
@@ -101,7 +101,7 @@
|
||||
захват, перевыданный другому — по протуханию срока или после того, как человек
|
||||
снял признак остановки в панели, — обязан обращать запись первого в отказ.
|
||||
Условие, проверяющее лишь непустоту признака или срок, пропустило бы обоих, и
|
||||
два шага записали бы в одну запись и оба ответили бы отправителю.
|
||||
два шага записали бы в одну запись по очереди, испортив её результат.
|
||||
|
||||
Одна и та же запись MUST доставаться ровно одному захватившему. Двум вызывающим,
|
||||
пришедшим за работой одновременно, запись MUST достаться одному, а второй MUST
|
||||
@@ -155,14 +155,18 @@
|
||||
всё ещё принадлежит ему. Запись MUST быть условна по **признаку этого захвата** —
|
||||
значению, уникальному для каждого захвата, — а не по занятости записи вообще.
|
||||
Шаг, чей захват за время работы достался другому, MUST завершиться без записи
|
||||
результата и без ответа отправителю.
|
||||
результата.
|
||||
|
||||
Требование закрывает то, чего неделимость захвата не закрывает: захват протухает
|
||||
не только у мёртвого воркера, но и у живого — шаг, идущий дольше своего срока,
|
||||
теряет запись, продолжая работать. Снять захват может и человек, вернувший
|
||||
остановленную запись в работу. Без условия по уникальному признаку два воркера
|
||||
пишут в одну запись по очереди, счётчик отказов сбрасывает тот, кто уже не
|
||||
владелец, а отправитель получает два ответа на одну запись.
|
||||
пишут в одну запись по очереди, а счётчик отказов сбрасывает тот, кто уже не
|
||||
владелец.
|
||||
|
||||
Довод про два ответа отправителю из требования ушёл вместе с доставкой: обращений
|
||||
наружу шаг не делает. Требование от этого не ослабло — порча записи двумя
|
||||
пишущими остаётся его предметом целиком.
|
||||
|
||||
Шаг MUST записывать только те поля, которыми распоряжается сам. Запись он держит
|
||||
снимком с момента захвата и до записи — это часы, — и безусловная запись снимка
|
||||
@@ -185,7 +189,6 @@
|
||||
- **AND** за это время та же запись досталась другому захвату
|
||||
- **WHEN** первый шаг доходит до записи результата
|
||||
- **THEN** результат не записывается
|
||||
- **AND** отправителю ничего не отправляется
|
||||
|
||||
#### Scenario: Человек снял остановку под работающим шагом
|
||||
|
||||
@@ -263,89 +266,18 @@ MUST расти с числом её отказов до объявленног
|
||||
- **THEN** задержка до следующей проверки каждый раз одна и та же
|
||||
- **AND** число отказов записи не растёт
|
||||
|
||||
### Requirement: Недоставленный ответ не роняет шаг
|
||||
|
||||
Шаг конвейера SHALL доводить запись до достигнутого рубежа, когда ответ
|
||||
отправителю доставить не удалось, и MUST не считать недоставку отказом шага.
|
||||
Недоставка MUST быть записана в журнал владельца, MUST нести идентификатор
|
||||
записи, MUST называть причину и MUST считаться отдельной метрикой с причиной
|
||||
меткой.
|
||||
|
||||
Причин у недоставки две, и исход у них общий: **вход отправителя не поднят** —
|
||||
запись заведена прошлым запуском, а сервис поднялся без этого входа; и **адресат
|
||||
у записи не назван** — источником значится Telegram, а чата в записи нет.
|
||||
|
||||
Уровень записи MUST различать эти причины. Неподнятый вход — объявленный режим,
|
||||
и его уровень «может стать проблемой». Неназванный адресат — симптом порчи
|
||||
записи: у записи из Telegram чат есть всегда, и пропасть он может только от
|
||||
дефекта, самый коварный источник которого назван инвариантом проекта про колонки
|
||||
очереди. Один уровень на обе причины утопил бы этот сигнал в потоке штатных
|
||||
записей о ненастроенном боте.
|
||||
|
||||
Общий исход — не упрощение, а следствие момента: ответ уходит **после** того, как
|
||||
достигнутый рубеж сохранён. Работа к этой минуте сделана, и объявленный отказ
|
||||
засчитался бы воркеру сбоем и лёг бы владельцу записью отказа — то есть соврал бы
|
||||
про исход дважды. Повтор делу не помогает: ни бот, ни адресат от ожидания не
|
||||
появятся. Поэтому запись остаётся на достигнутом рубеже, в повтор не уходит и
|
||||
**признака остановки не получает**, а причина недоставки живёт в записи журнала,
|
||||
а не в рубеже записи.
|
||||
|
||||
То же MUST относиться к недоставке сообщения об **остановке**: остановка уже
|
||||
сохранена, и недоставка её MUST не отменять.
|
||||
|
||||
Идентификатор записи в этой строке обязателен: без него владелец видит, что
|
||||
ответ не ушёл, но не может найти, чей. Текст расшифровки и сообщение отправителя
|
||||
в эту запись MUST не попадать — приватность содержимого записи требование не
|
||||
ослабляет.
|
||||
|
||||
Отложенной доставки это требование не заводит: ответ, не ушедший сегодня, не
|
||||
уходит и потом. Забрать расшифровку можно там же, где лежат остальные.
|
||||
|
||||
#### Scenario: Вход отправителя не поднят
|
||||
|
||||
- **GIVEN** запись принята входом Telegram прошлым запуском сервиса
|
||||
- **AND** сервис поднялся без этого входа
|
||||
- **WHEN** шаг конвейера доходит до ответа отправителю
|
||||
- **THEN** шаг завершается без отказа, и воркер не считает прогон сбоем
|
||||
- **AND** запись остаётся на достигнутом рубеже, в повтор не уходит и признака
|
||||
остановки не получает
|
||||
- **AND** в журнале есть запись уровня `WARN` о недоставке с идентификатором
|
||||
записи и причиной
|
||||
- **AND** счётчик недоставленных ответов вырос с этой причиной меткой
|
||||
- **AND** ни текста расшифровки, ни сообщения отправителя в этой записи нет
|
||||
|
||||
#### Scenario: Адресат у записи не назван
|
||||
|
||||
- **GIVEN** у записи источником значится Telegram, а чат не назван
|
||||
- **WHEN** шаг конвейера доходит до ответа отправителю
|
||||
- **THEN** шаг завершается без отказа, и воркер не считает прогон сбоем
|
||||
- **AND** запись остаётся на достигнутом рубеже
|
||||
- **AND** в журнале есть запись уровня `ERROR` о недоставке с идентификатором
|
||||
записи и причиной: неназванный адресат — симптом порчи записи
|
||||
|
||||
#### Scenario: Не доехало сообщение об остановке
|
||||
|
||||
- **GIVEN** запись остановлена признаком
|
||||
- **AND** вход отправителя не поднят
|
||||
- **WHEN** шаг доходит до ответа отправителю
|
||||
- **THEN** признак остановки у записи остаётся
|
||||
- **AND** в журнале есть запись о недоставке с идентификатором записи и причиной
|
||||
|
||||
#### Scenario: Отвечать некуда, потому что запись пришла не из Telegram
|
||||
|
||||
- **GIVEN** запись принята по HTTP
|
||||
- **WHEN** шаг конвейера доходит до ответа отправителю
|
||||
- **THEN** шаг завершается без отказа и без записи о недоставке
|
||||
|
||||
### Requirement: Выборка воркера владельцем не сужается
|
||||
|
||||
Воркер SHALL брать записи всех владельцев подряд и MUST не учитывать владельца
|
||||
при выборе очередной записи. Запись без владельца — принятая ботом — MUST
|
||||
обрабатываться наравне с прочими.
|
||||
при выборе очередной записи.
|
||||
|
||||
Владелец решает, кому запись показывать, а не кому её считать. Сужение выборки
|
||||
владельцем остановило бы расшифровку записей бота вовсе, а записи остальных
|
||||
поставило бы в зависимость от того, кто первым завёл учётную запись.
|
||||
владельцем поставило бы записи одних людей в зависимость от того, кто первым
|
||||
завёл учётную запись.
|
||||
|
||||
Оговорка про записи без владельца из требования ушла: заводить их стало нечем —
|
||||
колонка владельца пустого значения не принимает, и норму держит capability
|
||||
`storage`.
|
||||
|
||||
Владелец записи MUST переживать работу конвейера: шаг, сохраняющий свой
|
||||
результат, владельца не трогает и не затирает.
|
||||
@@ -356,12 +288,6 @@ MUST расти с числом её отказов до объявленног
|
||||
- **WHEN** воркер забирает работу
|
||||
- **THEN** ему достаются обе, в порядке заведения
|
||||
|
||||
#### Scenario: Запись без владельца обрабатывается
|
||||
|
||||
- **GIVEN** заведена запись, принятая ботом, — без владельца
|
||||
- **WHEN** воркер забирает работу
|
||||
- **THEN** она достаётся ему наравне с прочими
|
||||
|
||||
#### Scenario: Шаг конвейера владельца не затирает
|
||||
|
||||
- **GIVEN** запись с владельцем прошла шаг конвейера
|
||||
@@ -463,38 +389,6 @@ MUST расти с числом её отказов до объявленног
|
||||
- **THEN** число отказов, пауза и время входа в рубеж сброшены
|
||||
- **AND** ближайший захват выдаёт запись, а не останавливает её снова
|
||||
|
||||
### Requirement: Всякая остановка сообщает отправителю
|
||||
|
||||
Остановка записи по любой причине SHALL сообщать отправителю о неудаче ровно
|
||||
так же, как сообщает о ней отказ шага, и MUST быть видна владельцу сервиса
|
||||
записью в журнале.
|
||||
|
||||
Требование стоит на инварианте проекта «Принятая запись не теряется молча»:
|
||||
инвариант допускает два исхода — запись пригодна к повтору либо об отказе
|
||||
сказано, — а остановленная запись захвату не выдаётся, значит первый исход
|
||||
исключён.
|
||||
|
||||
Причин остановки больше одной, и обязанность общая для всех: исчерпанные
|
||||
отказы, застревание в рубеже, приговор шага. Обязанность, записанная у одной
|
||||
причины, у остальных читалась бы как снятая.
|
||||
|
||||
Ответ уходит **после** того, как признак остановки сохранён, и недоставка этого
|
||||
ответа MUST не отменять остановку: её нормирует требование «Недоставленный ответ
|
||||
не роняет шаг».
|
||||
|
||||
#### Scenario: Остановка по отказам сообщает отправителю
|
||||
|
||||
- **GIVEN** запись остановлена по исчерпании отказов
|
||||
- **WHEN** шаг доходит до ответа отправителю
|
||||
- **THEN** отправитель получает сообщение о неудаче
|
||||
|
||||
#### Scenario: Остановка по времени сообщает отправителю
|
||||
|
||||
- **GIVEN** запись остановлена по пределу времени в рубеже
|
||||
- **WHEN** шаг доходит до ответа отправителю
|
||||
- **THEN** отправитель получает сообщение о неудаче
|
||||
- **AND** в журнале владельца есть запись об остановке с причиной
|
||||
|
||||
### Requirement: Время в рубеже ограничено
|
||||
|
||||
У аудиозаписи SHALL быть время входа в рубеж, и оно MUST ставиться только при
|
||||
@@ -674,8 +568,8 @@ MUST не быть привязаны к отдельному шагу: кажд
|
||||
|
||||
Запись, захваченная с числом отказов сверх заданного предела, MUST
|
||||
останавливаться признаком тем, кто её захватил, и MUST не отдаваться шагу в
|
||||
работу. Об этой остановке отправителю сообщается наравне с прочими — норму
|
||||
держит требование «Всякая остановка сообщает отправителю».
|
||||
работу. Остановка эта видна отправителю опросом готовности наравне с прочими —
|
||||
норму держит capability `intake`.
|
||||
|
||||
Этот сторож MUST отвечать только за повторы внутри шага. Время, проведённое
|
||||
записью в рубеже, MUST мериться отдельным сторожем: одно число не справляется ни
|
||||
@@ -689,7 +583,7 @@ MUST не быть привязаны к отдельному шагу: кажд
|
||||
- **WHEN** запись проходит заданное число отказов
|
||||
- **THEN** у неё появляется признак остановки
|
||||
- **AND** следующий захват её не выдаёт
|
||||
- **AND** отправитель получает сообщение о неудаче
|
||||
- **AND** опрос готовности отдаёт владельцу записи признак остановки
|
||||
|
||||
#### Scenario: Шаг уносит процесс, не объявив отказа
|
||||
|
||||
@@ -710,3 +604,39 @@ MUST не быть привязаны к отдельному шагу: кажд
|
||||
- **WHEN** смотрят её число отказов
|
||||
- **THEN** оно не приблизилось к пределу
|
||||
|
||||
### Requirement: Конвейер ответа отправителю не шлёт
|
||||
|
||||
Шаг конвейера SHALL доводить запись до достигнутого рубежа и MUST не обращаться
|
||||
к отправителю вовсе — ни с готовым текстом, ни с сообщением о неудаче. Исход
|
||||
своей записи отправитель узнаёт опросом готовности и в панели владельца; адрес
|
||||
опроса и содержимое ответа нормирует capability `intake`.
|
||||
|
||||
Требование заведено взамен доставки в чат, убранной вместе с входом Telegram.
|
||||
Без него молчание конвейера читалось бы как недоделка: прежде ответ уходил, и
|
||||
всякий, кто помнит это, ищет в шаге отправку, а её отсутствие принимает за
|
||||
потерянную ветку.
|
||||
|
||||
Инвариант проекта «Принятая запись не теряется молча» держится теперь опросом
|
||||
готовности — там остановка видна признаком — и журналом владельца, где у неё
|
||||
стоит причина. Обязанность при этом сменила направление: прежде об отказе
|
||||
сообщали, теперь отказ доступен спросившему. Отправитель, который не
|
||||
спрашивает, об остановке не узнаёт.
|
||||
|
||||
Записи, которой этот канал недоступен, не бывает: у каждой записи есть владелец,
|
||||
и опрос отдаёт ему её исход. Держится это обязательностью владельца в схеме
|
||||
хранилища — норму держит capability `storage`.
|
||||
|
||||
#### Scenario: Готовый текст отправителю не уходит
|
||||
|
||||
- **GIVEN** запись дошла до конечного рубежа
|
||||
- **WHEN** шаг конвейера её завершает
|
||||
- **THEN** ни одного обращения наружу с текстом расшифровки не уходит
|
||||
- **AND** текст достаётся опросом готовности
|
||||
|
||||
#### Scenario: Остановка видна опросом, а не сообщением
|
||||
|
||||
- **GIVEN** запись остановлена по исчерпании отказов
|
||||
- **WHEN** владелец записи спрашивает её рубеж
|
||||
- **THEN** ответ несёт достигнутый рубеж и признак остановки
|
||||
- **AND** в журнале владельца сервиса есть запись об остановке с причиной
|
||||
|
||||
|
||||
@@ -17,8 +17,8 @@
|
||||
### Requirement: Сервис поднимается на чистом каталоге данных
|
||||
|
||||
Сервис SHALL приводить хранилище в рабочий вид сам: на пустом каталоге данных он
|
||||
MUST завести свою схему и принимать записи обоими входами без единого ручного
|
||||
шага до первого запуска.
|
||||
MUST завести свою схему и принимать записи своим входом — приёмом по HTTP — без
|
||||
единого ручного шага до первого запуска.
|
||||
|
||||
Прежние данные не переносятся. Каталог, оставшийся от прежней раскладки, MUST не
|
||||
читаться и не считаться источником: сервис начинает с чистого листа, и это
|
||||
@@ -267,14 +267,14 @@ MUST завести свою схему и принимать записи об
|
||||
печатается оно в журнал контейнера, откуда строку не убрать: бессрочное отдало бы
|
||||
панель всякому читателю логов навсегда.
|
||||
|
||||
Пока владелец пароля не задал, сервис MUST работать обоими входами: панель без
|
||||
владельца не мешает принимать записи.
|
||||
Пока владелец пароля не задал, сервис MUST принимать записи: панель без владельца
|
||||
приёму не мешает.
|
||||
|
||||
#### Scenario: Владелец пароля ещё не задал
|
||||
|
||||
- **GIVEN** каталог данных пуст и владелец панели не заведён
|
||||
- **WHEN** сервис запускается
|
||||
- **THEN** он принимает записи обоими входами
|
||||
- **THEN** он принимает записи
|
||||
- **AND** ни один ключ конфигурации не несёт пароля от панели
|
||||
|
||||
#### Scenario: Владелец заведён, приглашение больше не печатается
|
||||
@@ -289,10 +289,13 @@ MUST завести свою схему и принимать записи об
|
||||
учётной записью, — и эта колонка MUST не иметь умолчания: запись, чей владелец
|
||||
не назван, не достаётся никому по недосмотру схемы.
|
||||
|
||||
Колонка MUST допускать пустое значение, и это решение с названной ценой: записи,
|
||||
принятые ботом, владельца не имеют, потому что связи чата Telegram с учётной
|
||||
записью сервис не ведёт. Обязательность для приёма по HTTP держит сама
|
||||
capability `intake`, а не схема.
|
||||
Колонка MUST не допускать пустого значения. Прежде допускала, и цену платили за
|
||||
записи, принятые ботом: связи чата с учётной записью сервис не вёл. С убранным
|
||||
входом заводить ничью запись стало некому, и обязательность переезжает из одного
|
||||
лишь приёма в схему — туда, где её держит хранилище, а не договорённость. Разница
|
||||
не косметическая: пока обязательность жила в приёме, ничью запись заводили руками
|
||||
в панели, и она уходила в конвейер, стоила денег на распознавание и не доставалась
|
||||
потом никому.
|
||||
|
||||
Владелец MUST не назначаться и не меняться конвейером.
|
||||
|
||||
@@ -301,6 +304,14 @@ capability `intake`, а не схема.
|
||||
- **WHEN** сервис поднимается на чистом каталоге данных
|
||||
- **THEN** у аудиозаписи есть колонка владельца
|
||||
- **AND** умолчания у неё нет
|
||||
- **AND** пустого значения она не принимает
|
||||
|
||||
#### Scenario: Запись без владельца не сохраняется
|
||||
|
||||
- **GIVEN** сервис поднят
|
||||
- **WHEN** аудиозапись пытаются сохранить с пустым владельцем — приёмом,
|
||||
конвейером или руками в панели
|
||||
- **THEN** хранилище её не сохраняет
|
||||
|
||||
#### Scenario: Конвейер владельца не назначает
|
||||
|
||||
@@ -313,9 +324,16 @@ capability `intake`, а не схема.
|
||||
Хранилище SHALL держать владельца и у файла записи — той же связью с учётной
|
||||
записью, — и правило просмотра файлов MUST пускать к файлу только его владельца.
|
||||
|
||||
Владелец файла MUST назначаться там же, где владелец записи, — при приёме, из
|
||||
предъявленной сессии, — и MUST оставаться пустым у файлов, заведённых конвейером
|
||||
для записи без владельца.
|
||||
Владелец файла MUST назначаться при приёме, из предъявленной сессии, а колонка
|
||||
файла MUST не допускать пустого значения наравне с колонкой записи. Прежде пустое
|
||||
значение оставалось у файлов, заведённых конвейером для записи без владельца;
|
||||
таких записей больше не заводится, и разное правило у записи и у её файла
|
||||
читалось бы как недосмотр.
|
||||
|
||||
Файл, заведённый шагом конвейера, — приведённую копию заводит именно он —
|
||||
MUST получать владельца своей записи. Иного источника владельца у файла нет, и
|
||||
шаг, оставивший его пустым, упрётся в отказ сохранения: запись накопит отказы и
|
||||
остановится признаком на первом же приведении.
|
||||
|
||||
Ссылки на файлы у записи две — на принятую копию и на приведённую, — и обе живут
|
||||
до конца, но владелец файла MUST по-прежнему лежать своей колонкой, а не
|
||||
@@ -340,12 +358,18 @@ capability `intake`, а не схема.
|
||||
- **WHEN** он идёт по ссылке на файл своей записи со своим токеном
|
||||
- **THEN** содержимое отдаётся
|
||||
|
||||
#### Scenario: Файл записи из Telegram не отдаётся по API
|
||||
#### Scenario: Файл без владельца не сохраняется
|
||||
|
||||
- **GIVEN** запись принята ботом, и владельца у неё нет
|
||||
- **WHEN** вошедший человек идёт по ссылке на её файл со своим токеном
|
||||
- **THEN** содержимого он не получает
|
||||
- **GIVEN** сервис поднят
|
||||
- **WHEN** файл записи пытаются сохранить с пустым владельцем
|
||||
- **THEN** хранилище его не сохраняет
|
||||
|
||||
#### Scenario: Приведённая копия получает владельца записи
|
||||
|
||||
- **GIVEN** запись с владельцем дошла до приведения
|
||||
- **WHEN** шаг заводит приведённую копию файла
|
||||
- **THEN** владельцем копии стоит владелец записи
|
||||
- **AND** шаг завершается без отказа
|
||||
### Requirement: Учётная запись с записями не удаляется
|
||||
|
||||
Хранилище SHALL отвергать удаление учётной записи, у которой остались
|
||||
@@ -551,3 +575,31 @@ MUST быть помечено защищённым.
|
||||
- **WHEN** записи назначают шестую тему
|
||||
- **THEN** назначение не проходит
|
||||
|
||||
### Requirement: Пустой результат не кладётся поверх сохранённого
|
||||
|
||||
Хранилище SHALL не заменять сохранённое содержимое приложения записи — текст и
|
||||
структуру реплик — пустым. Замена пустым MUST оставлять прежнее значение и
|
||||
считаться сделанной работой, а не отказом.
|
||||
|
||||
Требование стоит на повторном опросе одной и той же операции распознавания.
|
||||
Повтор — обычное дело: держатель захвата умер, сохранение рубежа отказало,
|
||||
человек снял признак остановки в панели. Провайдер при этом вправе ответить
|
||||
пустым потоком, отказом это не считается, и безусловная замена стирала бы
|
||||
расшифровку живого человека — без следа и без возврата, потому что сервис
|
||||
объявлен архивом и удаления по требованию не знает.
|
||||
|
||||
Та же защита MUST стоять у сырого ответа провайдера: разное правило у двух
|
||||
хранителей одного результата читается как недосмотр, и один из них молча теряет
|
||||
то, ради чего второй заведён.
|
||||
|
||||
Норма записана со стороны **хранилища**, а не шага: шагов, кладущих текст,
|
||||
больше одного, и правило, записанное у одного из них, у остальных читалось бы
|
||||
как снятое.
|
||||
|
||||
#### Scenario: Пустой второй ответ не стирает расшифровку
|
||||
|
||||
- **GIVEN** расшифровка записи сохранена
|
||||
- **WHEN** ту же операцию опрашивают снова, и провайдер отвечает пустым
|
||||
- **THEN** сохранённая расшифровка остаётся прежней
|
||||
- **AND** шаг завершается без отказа
|
||||
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
|
||||
"git.vakhrushev.me/av/transcriber/internal/adapter/telegram"
|
||||
"git.vakhrushev.me/av/transcriber/internal/config"
|
||||
"git.vakhrushev.me/av/transcriber/internal/contract"
|
||||
"git.vakhrushev.me/av/transcriber/internal/metrics"
|
||||
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
|
||||
)
|
||||
|
||||
// buildTelegram заводит вход Telegram при старте. Клиент собирается **один
|
||||
// раз** и достаётся обоим — отправителю ответов и транспорту бота. Пока его
|
||||
// строили порознь, два пути одного старта разошлись: один ронял процесс на
|
||||
// негодном токене, другой терпел, и согласовывать их приходилось руками.
|
||||
func buildTelegram(cfg config.TelegramConfig, logger *slog.Logger) (*tgbotapi.BotAPI, contract.TelegramMessageSender, error) {
|
||||
return telegramFromConfig(cfg, telegram.NewBot, logger)
|
||||
}
|
||||
|
||||
// telegramFromConfig решает, поднимать ли вход вообще, и делает это **до**
|
||||
// всякого обращения к Telegram. Намерение объявляет признак включения; ключ
|
||||
// доступа при выключенном входе не смотрится вовсе.
|
||||
//
|
||||
// Сборка клиента приходит параметром, и это не украшение. Главное утверждение
|
||||
// выключенного входа — «обращения не уходит ни одного», — иначе непроверяемо:
|
||||
// адрес Bot API живёт внутри `telegram.NewBot`, и проверка, судящая по исходу,
|
||||
// осталась бы зелёной и тогда, когда ветка выключенного входа встала **после**
|
||||
// обращения. Прогон с заполненным ключом ушёл бы в живой Telegram боевым
|
||||
// токеном, а заметить это было бы нечем.
|
||||
//
|
||||
// Шов — подпись, а не интерфейс: реализация у него одна, и заводить тип ради
|
||||
// неё значит заводить понятие там, где хватает функции.
|
||||
func telegramFromConfig(
|
||||
cfg config.TelegramConfig,
|
||||
newBot func(string, *slog.Logger) (*tgbotapi.BotAPI, error),
|
||||
logger *slog.Logger,
|
||||
) (*tgbotapi.BotAPI, contract.TelegramMessageSender, error) {
|
||||
if !cfg.Enabled {
|
||||
metrics.IntakeUpGauge.WithLabelValues("telegram").Set(0)
|
||||
// Уровень «к сведению», а не «может стать проблемой»: это выбор
|
||||
// владельца, а не отклонение. Предупреждение остаётся за тем, чего
|
||||
// владелец не выбирал, — недоступностью Telegram.
|
||||
logger.Info("Telegram bot is not started", "reason", "telegram intake is disabled in configuration")
|
||||
return nil, telegram.NewAbsentMessageSender(), nil
|
||||
}
|
||||
|
||||
bot, err := newBot(cfg.BotToken, logger)
|
||||
return telegramFromBot(bot, err, logger)
|
||||
}
|
||||
|
||||
// telegramFromBot решает, чем обернулась сборка клиента, и это решение —
|
||||
// единственное содержательное здесь. Оно отделено от самого обращения к
|
||||
// Telegram намеренно: обращение ходит в сеть и в проверке недоступно, а
|
||||
// разрез проверять надо, иначе его молча вернут к прежнему виду.
|
||||
//
|
||||
// Разрез проходит по тому, **ответил ли Telegram**, и это решение владельца
|
||||
// от 2026-08-13: недоступность Telegram на старт сервиса не влияет. Сюда
|
||||
// доходит только включённый вход: выключенный отсеян выше, до обращения.
|
||||
//
|
||||
// - Telegram ответил отказом либо ключ доступа пуст — ошибка настройки: бота
|
||||
// по такому ключу не существует, ждать нечего, и старт роняется. Молча
|
||||
// потерянный бот перестаёт отвечать отправителям, а узнать об этом было бы
|
||||
// неоткуда;
|
||||
// - до Telegram не дошли — недоступность: сеть, DNS, авария Bot API,
|
||||
// истёкший срок ожидания. Сервис поднимается без Telegram, потому что
|
||||
// основной вход у него другой, и класть его из-за чужой аварии нельзя.
|
||||
//
|
||||
// Ветка пустого ключа по построению недостижима — его ловит проверка настроек
|
||||
// раньше, — но исход у неё **тот же**, что у проверки, и потому она оставлена.
|
||||
// Убрав её, мы отправили бы пустой ключ в общий случай `err != nil`, то есть в
|
||||
// «недоступность»: обход проверки настроек дал бы тихий подъём без бота — ровно
|
||||
// то, против чего написано это изменение.
|
||||
//
|
||||
// Ядро в обоих мягких случаях получает непустого отправителя: необязательная
|
||||
// зависимость, доехавшая до него нулём, уронила бы первую же задачу из
|
||||
// Telegram.
|
||||
func telegramFromBot(
|
||||
bot *tgbotapi.BotAPI,
|
||||
err error,
|
||||
logger *slog.Logger,
|
||||
) (*tgbotapi.BotAPI, contract.TelegramMessageSender, error) {
|
||||
var apiErr *tgbotapi.Error
|
||||
|
||||
switch {
|
||||
case errors.Is(err, telegram.ErrEmptyToken), errors.As(err, &apiErr):
|
||||
// Отказ токена не несёт: его чистит единая точка `telegram.NewBot`.
|
||||
return nil, nil, err
|
||||
|
||||
case err != nil:
|
||||
metrics.IntakeUpGauge.WithLabelValues("telegram").Set(0)
|
||||
logger.Warn("Telegram bot is not started", "reason", "telegram is unreachable", "error", err)
|
||||
return nil, telegram.NewAbsentMessageSender(), nil
|
||||
|
||||
default:
|
||||
metrics.IntakeUpGauge.WithLabelValues("telegram").Set(1)
|
||||
return bot, telegram.NewTelegramMessageSender(bot, logger), nil
|
||||
}
|
||||
}
|
||||
@@ -1,208 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
|
||||
"github.com/prometheus/client_golang/prometheus/testutil"
|
||||
|
||||
"git.vakhrushev.me/av/transcriber/internal/adapter/telegram"
|
||||
"git.vakhrushev.me/av/transcriber/internal/config"
|
||||
"git.vakhrushev.me/av/transcriber/internal/contract"
|
||||
"git.vakhrushev.me/av/transcriber/internal/metrics"
|
||||
)
|
||||
|
||||
// Разрез сборки — то, ради чего написано изменение, — до этих проверок не
|
||||
// держался ничем: инвертируй его, и весь набор оставался зелёным.
|
||||
//
|
||||
// Судится решение, а не обращение к Telegram: обращение ходит в сеть, а
|
||||
// боевым токеном запускаться запрещено.
|
||||
|
||||
func journalLogger() (*slog.Logger, *bytes.Buffer) {
|
||||
journal := &bytes.Buffer{}
|
||||
return slog.New(slog.NewTextHandler(journal, &slog.HandlerOptions{Level: slog.LevelDebug})), journal
|
||||
}
|
||||
|
||||
// Пустой ключ доступа при включённом входе — ошибка настройки, а не режим.
|
||||
// Прежде он давал мягкий подъём без бота, и это был тот самый второй смысл,
|
||||
// который изменение разводит с первым: «вход выключен» объявляет признак.
|
||||
//
|
||||
// Ветка по построению недостижима — пустой ключ ловит проверка настроек, — но
|
||||
// исход у неё тот же, и потому она проверяется: убрав её, мы отправили бы
|
||||
// пустой ключ в общий случай, то есть в «недоступность», и обход проверки дал
|
||||
// бы тихий подъём без бота.
|
||||
func TestTelegramFromBotOnEmptyTokenReturnsError(t *testing.T) {
|
||||
logger, journal := journalLogger()
|
||||
|
||||
bot, sender, err := telegramFromBot(nil, telegram.ErrEmptyToken, logger)
|
||||
|
||||
require.ErrorIs(t, err, telegram.ErrEmptyToken, "отказ поднят вызывающему нетронутым")
|
||||
assert.Nil(t, bot)
|
||||
assert.Nil(t, sender, "заглушка тут не подставляется: это ошибка, а не режим")
|
||||
|
||||
assert.NotContains(t, journal.String(), "Telegram bot is not started",
|
||||
"о законном отсутствии входа речи нет: вход заявлен и не обеспечен ключом")
|
||||
}
|
||||
|
||||
// Выключенный вход — решение владельца: сервис встаёт одним входом, ядро
|
||||
// получает заглушку, а владелец узнаёт об этом одной записью «к сведению».
|
||||
func TestTelegramFromConfigDisabledKeepsStarting(t *testing.T) {
|
||||
logger, journal := journalLogger()
|
||||
cfg := config.TelegramConfig{Enabled: false, BotToken: ""}
|
||||
|
||||
bot, sender, err := telegramFromConfig(cfg, failingBuild(t), logger)
|
||||
|
||||
require.NoError(t, err, "выключенный вход старт не роняет")
|
||||
assert.Nil(t, bot, "клиента нет — транспорт не поднимется")
|
||||
require.NotNil(t, sender, "ядро получает отправителя всегда, а не ноль")
|
||||
require.ErrorIs(t, sender.Send("любой ответ", 1, nil), contract.ErrDeliveryChannelDown,
|
||||
"заглушка говорит, что канал не поднят")
|
||||
|
||||
written := journal.String()
|
||||
assert.Contains(t, written, "Telegram bot is not started", "о неподнятом входе сказано")
|
||||
assert.Contains(t, written, "disabled in configuration", "причина названа")
|
||||
assert.Contains(t, written, "level=INFO",
|
||||
"уровень «к сведению»: это выбор владельца, а не отклонение")
|
||||
assert.Equal(t, 1, strings.Count(written, "Telegram bot is not started"),
|
||||
"запись ровно одна: вторая была бы записью о том же факте")
|
||||
|
||||
// Ряд метрики заводится первым обращением к нему. Пропади эта строка из
|
||||
// ветки — на `/metrics` не появится ряда вовсе, и правило наблюдения вида
|
||||
// «равен нулю» не сработает на отсутствующем ряде: потерянный вход снова
|
||||
// станет невидимым.
|
||||
assert.Zero(t, testutil.ToFloat64(metrics.IntakeUpGauge.WithLabelValues("telegram")),
|
||||
"признак поднятости входа выставлен в ноль")
|
||||
}
|
||||
|
||||
// Главное утверждение выключенного входа судится **счётчиком обращений**, а не
|
||||
// исходом. «Бот не заведён, отказа нет» остаётся верным и тогда, когда ветка
|
||||
// встала после обращения, а обращение ушло в живой Telegram боевым токеном.
|
||||
func TestTelegramFromConfigDisabledNeverBuildsClient(t *testing.T) {
|
||||
logger, _ := journalLogger()
|
||||
// Ключ заполнен — то самое состояние, ради которого два значения и
|
||||
// разводятся. Значение заведомо ненастоящее.
|
||||
cfg := config.TelegramConfig{Enabled: false, BotToken: "123456:AA-fake"}
|
||||
|
||||
calls := 0
|
||||
build := func(string, *slog.Logger) (*tgbotapi.BotAPI, error) {
|
||||
calls++
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
_, _, err := telegramFromConfig(cfg, build, logger)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Zero(t, calls, "к Telegram не уходит ни одного обращения")
|
||||
}
|
||||
|
||||
// failingBuild роняет проверку, если сборку клиента всё-таки позвали.
|
||||
func failingBuild(t *testing.T) func(string, *slog.Logger) (*tgbotapi.BotAPI, error) {
|
||||
t.Helper()
|
||||
|
||||
return func(string, *slog.Logger) (*tgbotapi.BotAPI, error) {
|
||||
t.Fatal("сборка клиента позвана при выключенном входе")
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Telegram ответил, что такого бота нет, — ошибка настройки, а не режим: бот по
|
||||
// этому токену не появится ни от ожидания, ни от повтора, и старт роняется.
|
||||
func TestTelegramFromBotOnRejectedTokenReturnsError(t *testing.T) {
|
||||
logger, journal := journalLogger()
|
||||
rejected := &tgbotapi.Error{Code: 401, Message: "Unauthorized"}
|
||||
|
||||
bot, sender, err := telegramFromBot(nil, rejected, logger)
|
||||
|
||||
require.ErrorIs(t, err, rejected, "отказ поднят вызывающему нетронутым")
|
||||
assert.Nil(t, bot)
|
||||
assert.Nil(t, sender, "заглушка тут не подставляется: это ошибка, а не режим")
|
||||
|
||||
assert.NotContains(t, journal.String(), "Telegram bot is not started",
|
||||
"о законном отсутствии входа речи нет: вход заявлен и отвергнут")
|
||||
}
|
||||
|
||||
// До Telegram не дошли — недоступность: на подъём сервиса она не влияет.
|
||||
// Решение владельца 2026-08-13; иначе чужая авария кладёт и основной вход, и
|
||||
// панель, и конвейер, которому Telegram не нужен вовсе.
|
||||
func TestTelegramFromBotOnUnreachableTelegramKeepsStarting(t *testing.T) {
|
||||
logger, journal := journalLogger()
|
||||
unreachable := errors.New("dial tcp: connection refused")
|
||||
|
||||
bot, sender, err := telegramFromBot(nil, unreachable, logger)
|
||||
|
||||
require.NoError(t, err, "недоступность Telegram старт не роняет")
|
||||
assert.Nil(t, bot, "клиента нет — транспорт не поднимется")
|
||||
require.NotNil(t, sender, "ядро получает отправителя всегда, а не ноль")
|
||||
require.ErrorIs(t, sender.Send("любой ответ", 1, nil), contract.ErrDeliveryChannelDown)
|
||||
|
||||
written := journal.String()
|
||||
assert.Contains(t, written, "Telegram bot is not started", "о неподнятом входе сказано")
|
||||
assert.Contains(t, written, "unreachable", "причина названа")
|
||||
assert.Contains(t, written, "level=WARN")
|
||||
}
|
||||
|
||||
// Годный токен даёт настоящего отправителя и клиента для транспорта: прежний
|
||||
// путь сохранён, и о неподнятом боте не говорится ничего.
|
||||
func TestTelegramFromBotOnLiveBotGivesRealSender(t *testing.T) {
|
||||
logger, journal := journalLogger()
|
||||
live := &tgbotapi.BotAPI{}
|
||||
|
||||
bot, sender, err := telegramFromBot(live, nil, logger)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Same(t, live, bot, "транспорт получит того же клиента, что и отправитель")
|
||||
require.NotNil(t, sender)
|
||||
_, stub := sender.(*telegram.AbsentMessageSender)
|
||||
assert.False(t, stub, "это настоящий отправитель, а не заглушка")
|
||||
|
||||
assert.NotContains(t, journal.String(), "Telegram bot is not started")
|
||||
}
|
||||
|
||||
// Включённый вход — основной путь нового кода, и связка «собрать клиента →
|
||||
// разобрать исход» покрывается только целиком: обе её половины по отдельности
|
||||
// проверены, а переданное не то поле или потерянный результат видны лишь здесь.
|
||||
func TestTelegramFromConfigEnabledPassesTokenAndOutcome(t *testing.T) {
|
||||
logger, _ := journalLogger()
|
||||
cfg := config.TelegramConfig{Enabled: true, BotToken: "123456:AA-fake"}
|
||||
live := &tgbotapi.BotAPI{}
|
||||
|
||||
var seen string
|
||||
build := func(token string, _ *slog.Logger) (*tgbotapi.BotAPI, error) {
|
||||
seen = token
|
||||
return live, nil
|
||||
}
|
||||
|
||||
bot, sender, err := telegramFromConfig(cfg, build, logger)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, cfg.BotToken, seen, "в сборку уходит ключ доступа из настроек")
|
||||
assert.Same(t, live, bot, "собранный клиент доезжает до транспорта")
|
||||
require.NotNil(t, sender)
|
||||
_, stub := sender.(*telegram.AbsentMessageSender)
|
||||
assert.False(t, stub, "это настоящий отправитель, а не заглушка")
|
||||
}
|
||||
|
||||
// Обратная сторона той же связки: отказ сборки доезжает до разбора исхода, а не
|
||||
// теряется по дороге.
|
||||
func TestTelegramFromConfigEnabledCarriesBuildFailure(t *testing.T) {
|
||||
logger, _ := journalLogger()
|
||||
cfg := config.TelegramConfig{Enabled: true, BotToken: "123456:AA-fake"}
|
||||
rejected := &tgbotapi.Error{Code: 401, Message: "Unauthorized"}
|
||||
|
||||
build := func(string, *slog.Logger) (*tgbotapi.BotAPI, error) {
|
||||
return nil, rejected
|
||||
}
|
||||
|
||||
bot, sender, err := telegramFromConfig(cfg, build, logger)
|
||||
|
||||
require.ErrorIs(t, err, rejected, "отказ сборки доехал до вызывающего")
|
||||
assert.Nil(t, bot)
|
||||
assert.Nil(t, sender, "это ошибка настройки, а не режим")
|
||||
}
|
||||
Reference in New Issue
Block a user