удалён вход Telegram, владелец записи стал обязателен в схеме

- убраны клиент бота, транспорт обновлений, отправитель сообщений, сборка
  входа при старте, секция настроек и зависимость go-telegram-bot-api; из
  конвейера ушла доставка ответа отправителю — исход виден опросом готовности.
  Колонки адресата и значение источника остались в схеме: применённые шаги не
  переписываются
- шаг 202608140003 запрещает пустого владельца у аудиозаписи и у файла;
  существующие строки он не проверяет, и это принято сознательно — искать их
  надо запросом до выкладки
- ревью нашло два пред-существующих дефекта, оба закрыты: пустой второй ответ
  распознавателя стирал сохранённую расшифровку, а пустая расшифровка перестала
  быть заметной вместе с убранной доставкой. Попутно поднят golang.org/x/image
  до v0.45.0 — красный шаг vulns, воспроизводился и на чистом master
This commit is contained in:
av
2026-08-15 07:24:35 +03:00
parent 97ceb7bb69
commit 8f7c3a057a
74 changed files with 2453 additions and 2733 deletions
-2
View File
@@ -154,8 +154,6 @@ linters:
- (*os.File).Close - (*os.File).Close
- (io.ReadCloser).Close - (io.ReadCloser).Close
- os.Remove - os.Remove
# Метод сам логирует ошибку отправки, вызывающему она не нужна
- (*git.vakhrushev.me/av/transcriber/internal/controller/tg.TelegramController).send
exclusions: exclusions:
rules: rules:
+35 -30
View File
@@ -9,11 +9,12 @@
## Что это ## Что это
Сервис расшифровки аудио в текст. Принимает запись двумя входами — Telegram-бот и Сервис расшифровки аудио в текст. Принимает запись одним входом — HTTP API, —
HTTP API, — конвертирует её `ffmpeg` в ogg, отдаёт на отложенное распознавание конвертирует её `ffmpeg` в ogg, отдаёт на отложенное распознавание Yandex
Yandex SpeechKit и возвращает текст туда, откуда пришла запись. Состояние задач, SpeechKit и отдаёт текст тому, кто запись загрузил, по опросу готовности.
метаданные и сами файлы лежат во встроенной PocketBase, и она же даёт владельцу Состояние записей, метаданные и сами файлы лежат во встроенной PocketBase, и она
панель администратора. же даёт владельцу панель администратора. Вход Telegram убран 2026-08-14 —
временно, до задачи, которая свяжет чат с учётной записью.
Чего **не** делает: сам речь не распознаёт и своих моделей не держит, текст Чего **не** делает: сам речь не распознаёт и своих моделей не держит, текст
руками не правит и в форматы документов не экспортирует, учётных записей не руками не правит и в форматы документов не экспортирует, учётных записей не
@@ -25,7 +26,7 @@ Yandex SpeechKit и возвращает текст туда, откуда пр
## Стек ## Стек
Go 1.26 (сборке CGO не нужен; детектору гонок в гейте — нужен), встроенная PocketBase — хранилище, файлы записей и 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`. Сборка — Storage, gRPC-клиент Yandex SpeechKit v3, Prometheus, `slog`. Сборка —
Taskfile, образ — Docker, выкладка — Ansible из `pet-project-server`. Taskfile, образ — Docker, выкладка — Ansible из `pet-project-server`.
@@ -33,7 +34,7 @@ Taskfile, образ — Docker, выкладка — Ansible из `pet-project-
Что нарушать нельзя. Что нарушать нельзя.
- **Секрет не покидает конфиг.** Токен бота, ключ SpeechKit, пара ключей Object - **Секрет не покидает конфиг.** Ключ SpeechKit, пара ключей Object
Storage и секрет клиента OIDC не попадают в git, в лог, в ответ пользователю и Storage и секрет клиента OIDC не попадают в git, в лог, в ответ пользователю и
в колонку `error_text`. Нарушение необратимо: утёкший ключ отзывают и меняют в колонку `error_text`. Нарушение необратимо: утёкший ключ отзывают и меняют
вручную во всех местах выкладки. **critical** вручную во всех местах выкладки. **critical**
@@ -53,14 +54,13 @@ Taskfile, образ — Docker, выкладка — Ansible из `pet-project-
приведённым к перечню известных форматов. Границу держит спека `intake`, приведённым к перечню известных форматов. Границу держит спека `intake`,
цена — [adr/ADR-2026-08-11-known-format-label.md](docs/adr/ADR-2026-08-11-known-format-label.md), цена — [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). остаток — [docs/security.md](docs/security.md).
- **Бот отвечает только тем, кто в белом списке.** Бот проверяет отправителя до
любой работы, включая скачивание файла. Нарушение обратимо правкой конфига, но
чужие записи к тому моменту уже обработаны за наши деньги. **critical**
- **Принятая запись не теряется молча.** Отказ на любом шаге либо оставляет - **Принятая запись не теряется молча.** Отказ на любом шаге либо оставляет
задачу пригодной к повтору, либо переводит её в `failed` и сообщает запись пригодной к повтору, либо ставит на неё признак остановки с причиной —
пользователю. Молчаливый выход из шага без записи в лог и без смены состояния и тогда причина видна её владельцу опросом готовности, а владельцу сервиса
запрещён. Обратимо повторной отправкой, но пользователь об этом не узнает. журналом. Молчаливый выход из шага без записи в лог и без смены состояния
**major** запрещён. Обязанность сменила направление 2026-08-14 вместе с убранным входом
Telegram: прежде об отказе сообщали, теперь отказ доступен спросившему, и
отправитель, который не спрашивает, о нём не узнаёт. **major**
- **`NoopJobError` — не ошибка.** Значение «задач в этом состоянии нет» не - **`NoopJobError` — не ошибка.** Значение «задач в этом состоянии нет» не
логируется, не считается в метрику и не поднимает уровень. Нарушение даёт логируется, не считается в метрику и не поднимает уровень. Нарушение даёт
запись раз в секунду на каждый воркер. **major** запись раз в секунду на каждый воркер. **major**
@@ -89,12 +89,21 @@ Taskfile, образ — Docker, выкладка — Ansible из `pet-project-
Признак захвата уникален для каждого захвата, и запись результата условна по Признак захвата уникален для каждого захвата, и запись результата условна по
нему, а не по занятости записи. Шаг, чей захват за время работы достался нему, а не по занятости записи. Шаг, чей захват за время работы достался
другому — по протуханию срока или после того, как человек снял признак другому — по протуханию срока или после того, как человек снял признак
остановки в панели, — завершается без записи и без ответа отправителю. Условие остановки в панели, — завершается без записи результата. Условие
по непустоте признака пропустило бы обоих: два воркера писали бы в одну запись по непустоте признака пропустило бы обоих: два воркера писали бы в одну запись
по очереди, а отправитель получал бы два ответа. **major** по очереди, портя её результат. **major**
- **Остановленная запись сообщает отправителю, какой бы ни была причина.** - **У записи есть владелец, и колонка пустого значения не принимает.** Ничья
Причин три — приговор шага, исчерпанные отказы, застревание. Остановленная запись не заводится ничем — ни приёмом, ни конвейером, ни рукой в панели, — и
запись захвату не выдаётся, значит исход «пригодна к повтору» исключён. держит это схема хранилища, а не договорённость. Пока обязательность жила в
одном приёме, ничью запись заводили в панели, она уходила в конвейер, стоила
денег на распознавание и не доставалась потом никому. Правило со стороны
спрашивающего при этом остаётся: пустой владелец не совпадает ни с одной
записью, потому что схема запрещает **заводить** ничью, а это правило —
**спрашивать** ничьим именем. **major**
- **Остановленная запись несёт причину, какой бы та ни была.** Причин три —
приговор шага, исчерпанные отказы, застревание, — и каждая записывается в саму
запись и в её журнал событий. Остановленная запись захвату не выдаётся, значит
исход «пригодна к повтору» исключён, и другого следа у неё не будет.
Обязанность, записанная у одной причины, у остальных читалась бы как снятая. Обязанность, записанная у одной причины, у остальных читалась бы как снятая.
**major** **major**
@@ -200,16 +209,12 @@ task gate # весь набор проверок разом
база (`data/data.db`), и записи живых людей база (`data/data.db`), и записи живых людей
(`data/storage/<коллекция>/<запись>/`). Локальный каталог данных — свой, его (`data/storage/<коллекция>/<запись>/`). Локальный каталог данных — свой, его
ронять и пересоздавать можно свободно. ронять и пересоздавать можно свободно.
- **Боевым токеном бота не запускаться.** Второй процесс с тем же токеном - **Локальный запуск не ходит наружу.** Секции `[auth]` и `[yandex]`
перехватывает обновления у работающего, и пользователь теряет ответы. Запускай проверяются на старте, но наружу при этом не обращаются, так что годятся
с `telegram.enabled = false`: сервис поднимается без Telegram, к нему не уходит выдуманные непустые значения — адреса `[auth]` должны лишь разбираться как
ни одного обращения, и работает он одним входом, по HTTP. Пустого ссылки. Расшифровка при выдуманных ключах не работает: её подменяют
`bot_token` для этого мало и больше не значит ничего: включён вход или нет, `internal/adapter/recognizer/memory.go`. Подробности строками в
решает отдельный признак `telegram.enabled`, а пустой ключ при `enabled = true` `config.example.toml`.
роняет старт. Выключенного входа
для подъёма тоже мало: секции `[auth]` и `[yandex]` проверяются на старте, но
наружу при этом не ходят, так что годятся выдуманные непустые значения;
подробности строками в `config.example.toml`.
- **Yandex Cloud за деньги.** Распознавание и хранение в Object Storage - **Yandex Cloud за деньги.** Распознавание и хранение в Object Storage
оплачиваются по факту. Прогон на реальных ключах ради проверки кода запрещён — оплачиваются по факту. Прогон на реальных ключах ради проверки кода запрещён —
подставляй `internal/adapter/recognizer/memory.go`. подставляй `internal/adapter/recognizer/memory.go`.
@@ -249,7 +254,7 @@ task gate # весь набор проверок разом
- Документация, комментарии, сообщения коммитов — русский. - Документация, комментарии, сообщения коммитов — русский.
- Код и идентификаторы — английский. - Код и идентификаторы — английский.
- Текст, который видит пользователь Telegram, — русский. - Текст, который видит пользователь сервиса, — русский.
- **Точного числа накопленного в документах нет.** «Три capability», «пять - **Точного числа накопленного в документах нет.** «Три capability», «пять
прогонов ревью», «две типизированные ошибки» расходятся с действительностью на прогонов ревью», «две типизированные ошибки» расходятся с действительностью на
первой же задаче, которая прибавит четвёртую, — и расходятся молча: машина первой же задаче, которая прибавит четвёртую, — и расходятся молча: машина
+6 -11
View File
@@ -1,10 +1,9 @@
# Transcriber Service # Transcriber Service
Сервис расшифровки аудиозаписей. Два входа — Telegram-бот и HTTP API. Сервис расшифровки аудиозаписей. Вход один — HTTP API.
## Возможности ## Возможности
- Приём аудио из Telegram: голосовые сообщения, аудиофайлы и документы с аудио
- Приём аудиофайлов через HTTP API - Приём аудиофайлов через HTTP API
- Конвертация в ogg через ffmpeg - Конвертация в ogg через ffmpeg
- Распознавание речи через Yandex SpeechKit - Распознавание речи через Yandex SpeechKit
@@ -15,7 +14,6 @@
- **Язык**: Go 1.26, CGO не нужен - **Язык**: Go 1.26, CGO не нужен
- **Веб-фреймворк**: gin-gonic/gin - **Веб-фреймворк**: gin-gonic/gin
- **Telegram**: go-telegram-bot-api
- **Распознавание**: Yandex SpeechKit + Yandex Object Storage (S3) - **Распознавание**: Yandex SpeechKit + Yandex Object Storage (S3)
- **Конвертация**: ffmpeg - **Конвертация**: ffmpeg
- **Хранилище, файлы и панель**: встроенная PocketBase - **Хранилище, файлы и панель**: встроенная PocketBase
@@ -41,12 +39,11 @@
Сервер запустится на порту из `[server] port`, по умолчанию 8080. Нужен Сервер запустится на порту из `[server] port`, по умолчанию 8080. Нужен
установленный `ffmpeg`. установленный `ffmpeg`.
### Белый список Telegram ### Кого пускают
Бот отвечает только тем, кто перечислен в конфиге. Кого и по какому признаку он Приём и опрос закрыты сессией OIDC. Что её выдаёт и чем она предъявляется —
пускает — [docs/security.md](docs/security.md), «Что разграничивает доступ»; [docs/security.md](docs/security.md), «Что разграничивает доступ»; известные
известные прорехи образца конфига, включая недостающий ключ белого списка, — прорехи образца конфига — [docs/conventions/config.md](docs/conventions/config.md).
[docs/conventions/config.md](docs/conventions/config.md).
## Деплой ## Деплой
@@ -94,13 +91,11 @@ transcriber/
│ ├── service/ # Конвейер расшифровки │ ├── service/ # Конвейер расшифровки
│ ├── controller/ │ ├── controller/
│ │ ├── http/ # HTTP-обработчики │ │ ├── http/ # HTTP-обработчики
│ │ ├── tg/ # Telegram-бот
│ │ └── worker/ # Фоновые воркеры │ │ └── worker/ # Фоновые воркеры
│ └── adapter/ │ └── adapter/
│ ├── converter/ffmpeg/ # Конвертация аудио │ ├── converter/ffmpeg/ # Конвертация аудио
│ ├── metaviewer/ffmpeg/ # Длительность аудио │ ├── metaviewer/ffmpeg/ # Длительность аудио
│ ├── recognizer/yandex/ # SpeechKit + Object Storage │ ├── recognizer/yandex/ # SpeechKit + Object Storage
│ ├── telegram/ # Отправка сообщений
│ └── repo/pocketbase/ # Репозитории, схема коллекций, правила панели │ └── repo/pocketbase/ # Репозитории, схема коллекций, правила панели
└── data/ # Каталог данных: база и файлы записей вместе └── data/ # Каталог данных: база и файлы записей вместе
├── data.db # База хранилища (создаётся автоматически) ├── data.db # База хранилища (создаётся автоматически)
@@ -109,7 +104,7 @@ transcriber/
## Хранилище ## Хранилище
Две коллекции, `files` и `transcribe_jobs`. Поля, ключи, правило времени и Коллекции хранилища — аудиозапись и её приложения. Поля, ключи, правило времени и
идентификаторов, а также механика захвата задачи воркером — идентификаторов, а также механика захвата задачи воркером —
[docs/database.md](docs/database.md). Панель владельца — по адресу `/_/` того же [docs/database.md](docs/database.md). Панель владельца — по адресу `/_/` того же
порта; пароль от неё задаёт сам владелец по приглашению, которое сервис печатает порта; пароль от неё задаёт сам владелец по приглашению, которое сервис печатает
-38
View File
@@ -85,41 +85,3 @@ redirect_url = "https://transcriber.example.com/auth/callback"
# Признак `Secure` у куки сессии. Умолчание true; false только для локального # Признак `Secure` у куки сессии. Умолчание true; false только для локального
# запуска по http://localhost, где браузер такую куку не сохранит # запуска по http://localhost, где браузер такую куку не сохранит
secure_cookie = true 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 - **Дата:** 2026-08-13
- **Источник:** openspec/changes/archive/2026-08-13-telegram-enabled-flag/design.md - **Источник:** 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 - **Дата:** 2026-08-13
- **Источник:** openspec/changes/archive/2026-08-13-start-without-telegram-token/design.md - **Источник:** 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
View File
@@ -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-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-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-halt-is-a-flag-not-a-stage.md) | |
| 2026-08-14 | [Учётная запись с записями не удаляется, и это осознанный тупик](ADR-2026-08-14-account-with-records-is-not-deleted.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 | [Намерение объявляется признаком, а не выводится из ключа доступа](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) | | | 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-protected-file-behind-session.md) | |
| 2026-08-12 | [Сессия живёт семь суток и не продлевает саму себя](ADR-2026-08-12-session-without-refresh.md) | | | 2026-08-12 | [Сессия живёт семь суток и не продлевает саму себя](ADR-2026-08-12-session-without-refresh.md) | |
| 2026-08-12 | [Кого пускать в сервис, решает правило провайдера, а не сервис](ADR-2026-08-12-access-delegated-to-provider.md) | | | 2026-08-12 | [Кого пускать в сервис, решает правило провайдера, а не сервис](ADR-2026-08-12-access-delegated-to-provider.md) | |
+37 -57
View File
@@ -17,16 +17,17 @@
- [intake](../openspec/specs/intake/spec.md) — **приём по HTTP плюс наличие - [intake](../openspec/specs/intake/spec.md) — **приём по HTTP плюс наличие
входов**: приём и опрос за сессией, имя отправителя не доходит ни до входов**: приём и опрос за сессией, имя отправителя не доходит ни до
хранилища, ни до журнала, метка метрики несёт только известное расширение, а хранилища, ни до журнала, метка метрики несёт только известное расширение, а
выключенный вход Telegram не мешает подъёму. Задачи наблюдатель видит единственный поднятый вход. Задачи
`http-handler-tests-never-green` и `no-user-filename-in-log` 2026-08-11, `http-handler-tests-never-green` и `no-user-filename-in-log` 2026-08-11,
`pocketbase-storage` и `oidc-login` 2026-08-12, `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) — пустой прогон воркера, захват - [pipeline](../openspec/specs/pipeline/spec.md) — пустой прогон воркера, захват
задачи и срок его протухания, число попыток, состояние «мертва», пауза перед задачи и срок его протухания, число попыток, остановка признаком, пауза перед
повтором и недоставленный ответ отправителю: задачи повтором и молчание конвейера наружу: задачи
`errors-as-instead-of-typecast` 2026-08-11, `pocketbase-storage` 2026-08-12 и `errors-as-instead-of-typecast` 2026-08-11, `pocketbase-storage` 2026-08-12,
`local-run-without-telegram-token` 2026-08-13. Переходы состояний и отмена `local-run-without-telegram-token` 2026-08-13 и `remove-telegram-intake`
2026-08-14. Переходы состояний и отмена
контекста посреди шага остаются контекста посреди шага остаются
долгом; что именно не описано, перечисляет раздел `Purpose` самой спеки; долгом; что именно не описано, перечисляет раздел `Purpose` самой спеки;
- [storage](../openspec/specs/storage/spec.md) — где живут запись, её метаданные - [storage](../openspec/specs/storage/spec.md) — где живут запись, её метаданные
@@ -40,17 +41,17 @@
- [access](../openspec/specs/access/spec.md) — кто пришёл в сервис и пускают ли - [access](../openspec/specs/access/spec.md) — кто пришёл в сервис и пускают ли
его дальше: вход через внешнего провайдера OIDC, чем предъявляется сессия, что его дальше: вход через внешнего провайдера OIDC, чем предъявляется сессия, что
её прекращает и какие адреса остаются открытыми. Задача `oidc-login` её прекращает и какие адреса остаются открытыми. Задача `oidc-login`
2026-08-12. Здесь же разграничение записей по владельцу: запись из веба 2026-08-12. Здесь же разграничение записей по владельцу: принятая запись
принадлежит тому, кто её принёс, чужая неотличима от несуществующей, а запись принадлежит тому, кто её принёс, чужая неотличима от несуществующей, а ничьей
из Telegram владельца не имеет и по API не достаётся никому. Задача записи не бывает вовсе — колонка владельца пустого значения не принимает.
`record-ownership` 2026-08-14. Задачи `record-ownership` и `remove-telegram-intake` 2026-08-14.
Поведение прочих узлов, включая приём из Telegram, по-прежнему живёт только в Поведение узла, которого нет в перечне выше, по-прежнему живёт только в коде.
коде. Задача, которая его трогает, дописывает спеку своей capability. Задача, которая его трогает, дописывает спеку своей capability.
## Принципы ## Принципы
- **Один процесс.** Бот, HTTP-сервер и фоновые воркеры живут в одном бинарнике и - **Один процесс.** HTTP-сервер и фоновые воркеры живут в одном бинарнике и
делят одну базу. Отдельного воркер-процесса нет намеренно. делят одну базу. Отдельного воркер-процесса нет намеренно.
- **Очередь таблицей.** Состояние задачи лежит коллекцией хранилища; неделимость - **Очередь таблицей.** Состояние задачи лежит коллекцией хранилища; неделимость
захвата и порядок выборки нормирует захвата и порядок выборки нормирует
@@ -64,7 +65,7 @@
[pipeline](../openspec/specs/pipeline/spec.md), «Брошенная задача возвращается [pipeline](../openspec/specs/pipeline/spec.md), «Брошенная задача возвращается
в работу»; здесь это принцип письма шага, а не описание поведения. в работу»; здесь это принцип письма шага, а не описание поведения.
- **Ядро зависит от интерфейсов.** `internal/service` знает только - **Ядро зависит от интерфейсов.** `internal/service` знает только
`internal/contract`; ffmpeg, Yandex, Telegram и хранилище подставляются в `internal/contract`; ffmpeg, Yandex и хранилище подставляются в
`main.go`. Правило механизировано тестами-сканерами `internal/archrules`, и `main.go`. Правило механизировано тестами-сканерами `internal/archrules`, и
они же держат обратные направления: транспорты не знают друг о друге, адаптер они же держат обратные направления: транспорты не знают друг о друге, адаптер
не знает ни ядра, ни транспортов. не знает ни ядра, ни транспортов.
@@ -77,17 +78,15 @@
Каждый — строкой со ссылкой на capability, а не пересказом её требований. Каждый — строкой со ссылкой на capability, а не пересказом её требований.
<!-- канон: поведение → openspec/specs/intake, pipeline, storage; ещё НЕ переехало: приём из Telegram, деление длинного текста по словам --> <!-- канон: поведение → openspec/specs/intake, pipeline, storage; ещё НЕ переехало: приведение записи к рабочему формату -->
| Компонент | Где | Что делает | | Компонент | Где | Что делает |
| --- | --- | --- | | --- | --- | --- |
| Telegram-бот | `internal/controller/tg` | Принимает голосовые, аудиофайлы и документы с аудио, скачивает их, заводит задачу |
| HTTP API | `internal/controller/http` | Приём файла и опрос статуса задачи | | HTTP API | `internal/controller/http` | Приём файла и опрос статуса задачи |
| Воркеры | `internal/controller/worker` | Пул одинаковых потоков: каждый берёт любую пригодную запись и опрашивает базу. Число — настройкой, ноль законен | | Воркеры | `internal/controller/worker` | Пул одинаковых потоков: каждый берёт любую пригодную запись и опрашивает базу. Число — настройкой, ноль законен |
| Сервис расшифровки | `internal/service` | Конвейер: приём, приведение, отправка, опрос, завершение. Шаг выбирается по рубежу записи | | Сервис расшифровки | `internal/service` | Конвейер: приём, приведение, отправка, опрос, завершение. Шаг выбирается по рубежу записи |
| Конвертер и метаданные | `internal/adapter/{converter,metaviewer}/ffmpeg` | `ffmpeg` в ogg/vorbis, `ffprobe` для длительности | | Конвертер и метаданные | `internal/adapter/{converter,metaviewer}/ffmpeg` | `ffmpeg` в ogg/vorbis, `ffprobe` для длительности |
| Распознаватель | `internal/adapter/recognizer/yandex` | Заливка в Object Storage и отложенное распознавание SpeechKit; разбор ответа в реплики со временем | | Распознаватель | `internal/adapter/recognizer/yandex` | Заливка в Object Storage и отложенное распознавание SpeechKit; разбор ответа в реплики со временем |
| Отправитель Telegram | `internal/adapter/telegram` | Отправка текста, деление длинного по словам |
| Репозитории | `internal/adapter/repo/pocketbase` | Записи, файлы, тексты, структура, попытки распознавания и журнал событий — коллекциями хранилища; захват — сырым запросом | | Репозитории | `internal/adapter/repo/pocketbase` | Записи, файлы, тексты, структура, попытки распознавания и журнал событий — коллекциями хранилища; захват — сырым запросом |
| Шаги схемы | `internal/adapter/repo/pocketbase/migrations` | Файл на шаг, имя файла — имя шага; там же имена коллекций | | Шаги схемы | `internal/adapter/repo/pocketbase/migrations` | Файл на шаг, имя файла — имя шага; там же имена коллекций |
| Панель владельца | `internal/adapter/repo/pocketbase`, `panel.go` | Панель хранилища; правила правки записи нормирует [storage](../openspec/specs/storage/spec.md), «Владелец видит записи в панели» | | Панель владельца | `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` с - **Yandex Object Storage.** S3-совместимый, клиент `aws-sdk-go-v2` с
`UsePathStyle`. Ключ объекта — имя файла, то есть UUID с расширением. `UsePathStyle`. Ключ объекта — имя файла, то есть UUID с расширением.
- **Yandex SpeechKit v3.** gRPC, `stt.api.cloud.yandex.net:443`, модель - **Yandex SpeechKit v3.** gRPC, `stt.api.cloud.yandex.net:443`, модель
@@ -121,26 +114,12 @@
- **Где работает, что рядом, кто перезапускает:** один контейнер на личном - **Где работает, что рядом, кто перезапускает:** один контейнер на личном
сервере, разворачивает и перезапускает Ansible из `pet-project-server`. Рядом — сервере, разворачивает и перезапускает Ansible из `pet-project-server`. Рядом —
обратный прокси, который публикует HTTP-порт наружу. обратный прокси, который публикует HTTP-порт наружу.
- **Порядок выкладки задаётся по ключу, а не по файлу целиком.** Общего правила - **Порядок выкладки: конфиг после образа.** Прежде здесь стояло правило,
«сперва образ» или «сперва конфиг» нет: два ключа секции Telegram требуют разное для двух ключей секции Telegram; с убранным входом оно потеряло предмет
противоположного, и оба правила действуют одновременно. целиком. Оставшиеся ключи, которых новый образ ждёт, в конфиге уже есть.
- **Признак включения `telegram.enabled` едет в конфиг раньше образа.** Он Секцию `[telegram]` и ключ `server.users_while_list` человек убирает из боевого
обязателен с 2026-08-13, умолчания у него нет, и образ, который его ждёт, файла после выкладки: незнакомые ключи разбор настроек не судит, и файл с ними
без него выходит с кодом 1 **до** открытия порта — вместе с HTTP, панелью и сервис поднимает молча.
конвейером. Прежний образ лишний ключ TOML просто не читает, поэтому ранняя
правка конфига безопасна, а поздняя роняет сервис.
- **Пустой ключ доступа `telegram.bot_token` едет позже образа.** Образы
старше 2026-08-13 роняли старт на пустом ключе, тоже до открытия порта.
- **Откат при выключенном входе** допустим только на образ от 2026-08-13 и
новее. На более старом состояния «сервис поднят, бот опущен» не существует
вовсе: пустой ключ роняет старт, негодный роняет старт, годный поднимает
бота. Откат туда делают с непустым годным ключом, приняв, что бот поднимется.
- **Откат образа при `enabled = false` и заполненном ключе** отменяет решение
владельца молча: прежний образ признака не видит и поднимает бота. Если вход
был выключен потому, что бот с этим токеном поднят где-то ещё, два процесса
поделят один длинный опрос и часть ответов до людей не дойдёт.
Ревью кода воспроизвело порядок на прежней версии, живой прогон — на нынешней.
- **Откат образа через шаг схемы `202608140002` не работает и не говорит об - **Откат образа через шаг схемы `202608140002` не работает и не говорит об
этом.** Шаг удаляет прежнюю коллекцию задач, а библиотека накатывает только этом.** Шаг удаляет прежнюю коллекцию задач, а библиотека накатывает только
те шаги, которые знает сам бинарь: прежний образ шагов новее не видит, те шаги, которые знает сам бинарь: прежний образ шагов новее не видит,
@@ -160,16 +139,15 @@
| Зависимость | Падает | Отвечает медленно | Молчит | Отдаёт мусор | | Зависимость | Падает | Отвечает медленно | Молчит | Отдаёт мусор |
| --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- |
| Telegram Bot API | Сервис поднимается без Telegram и работает по HTTP; старт роняют только ошибки настройки — ответ «такого бота нет» и включённый вход с пустым ключом доступа. Норму держит [intake](../openspec/specs/intake/spec.md), «Признак включения решает, поднимается ли вход Telegram» | На старте — ждём не дольше срока, дальше поднимаемся без Telegram. У поднятого сервиса скачивание файла висит бесконечно: там срока нет | То же, что «отвечает медленно»: на старте — подъём без Telegram по истечении срока, у поднятого — длинный опрос пуст и новые задачи не заводятся | Файл скачался битым, отказ вылезет на конвертации | | Yandex SpeechKit | Шаг возвращает ошибку, запись остаётся на повтор | Захват держится час, запись не двигается; по истечении предела простоя она останавливается с причиной «застряла», не теряя идентификатора операции | Операция вечно `in progress`, повтор каждые 5 секунд — до предела простоя в сутки | Пустой текст — запись доходит до конечного рубежа без расшифровки, и в журнале стоит запись «может стать проблемой» с идентификатором записи; опрос готовности отдаёт рубеж `done` без поля текста |
| Yandex SpeechKit | Шаг возвращает ошибку, запись остаётся на повтор | Захват держится час, запись не двигается; по истечении предела простоя она останавливается с причиной «застряла», не теряя идентификатора операции | Операция вечно `in progress`, повтор каждые 5 секунд — до предела простоя в сутки | Пустой текст — запись завершается заглушкой «на записи нет текста» |
| ↳ *остановка сервиса* | Принятие операции от отмены защищено своим пределом в 10 секунд: операцию там могли принять и начать считать деньги, а потерянный идентификатор заставил бы повтор оплатить ту же запись второй раз. Заливка в Object Storage отменяется штатно — её повтор бесплатен, объект ложится под тем же ключом | — | — | — | | ↳ *остановка сервиса* | Принятие операции от отмены защищено своим пределом в 10 секунд: операцию там могли принять и начать считать деньги, а потерянный идентификатор заставил бы повтор оплатить ту же запись второй раз. Заливка в Object Storage отменяется штатно — её повтор бесплатен, объект ложится под тем же ключом | — | — | — |
| Yandex Object Storage | Заливка падает, запись остаётся на рубеже `normalized` | То же, что падение: висит до конца захвата | — | SpeechKit не прочитает объект и вернёт отказ операции | | Yandex Object Storage | Заливка падает, запись остаётся на рубеже `normalized` | То же, что падение: висит до конца захвата | — | SpeechKit не прочитает объект и вернёт отказ операции |
| ffmpeg, ffprobe | Запись останавливается признаком с текстом «сбой конвертации файла» — рубеж при этом сохраняется, и снятие признака продолжает с него. Остановка сервиса — исход другой: процесс убивают контекстом, запись остаётся на повтор и отказа не тратит | Конвейер стоит: вызов синхронный | — | Выходной файл пуст, отказ вылезет на распознавании | | ffmpeg, ffprobe | Запись останавливается признаком с текстом «сбой конвертации файла» — рубеж при этом сохраняется, и снятие признака продолжает с него. Остановка сервиса — исход другой: процесс убивают контекстом, запись остаётся на повтор и отказа не тратит | Конвейер стоит: вызов синхронный | — | Выходной файл пуст, отказ вылезет на распознавании |
| Хранилище (файл на диске) | Приложение не стартует либо шаг падает на каждом запросе | Блокировка записи держит воркеры | — | — | | Хранилище (файл на диске) | Приложение не стартует либо шаг падает на каждом запросе | Блокировка записи держит воркеры | — | — |
| Диск | Запись файла падает, задача не заводится | — | — | — | | Диск | Запись файла падает, задача не заводится | — | — | — |
- **Кто заметит отказ и когда:** пользователь Telegram — сразу, по молчанию бота - **Кто заметит отказ и когда:** тот, кто загрузил запись, — опросом готовности:
или по сообщению об ошибке. Владелец — по метрике остановленная запись отдаёт признак остановки. Владелец — по метрике
`transcriber_worker_job_count` с меткой `error="true"`, и метка `stage` `transcriber_worker_job_count` с меткой `error="true"`, и метка `stage`
называет рубеж, с которого запись взята: с появлением пула одинаковых воркеров называет рубеж, с которого запись взята: с появлением пула одинаковых воркеров
имя потока перестало что-либо значить, а разрез по шагу — единственное, чем имя потока перестало что-либо значить, а разрез по шагу — единственное, чем
@@ -178,15 +156,15 @@
- **Журнал событий записи** — второй канал наблюдения, `record_events`. Пишется - **Журнал событий записи** — второй канал наблюдения, `record_events`. Пишется
на смену рубежа, на остановку и на снятие остановки; читает его человек в на смену рубежа, на остановку и на снятие остановки; читает его человек в
панели, ни один шаг конвейера на него не смотрит. Экрана у него пока нет. панели, ни один шаг конвейера на него не смотрит. Экрана у него пока нет.
- **Характер потока:** непрерывный, но разреженный. Бот держит длинный опрос, - **Характер потока:** непрерывный, но разреженный. Воркеры опрашивают базу
воркеры опрашивают базу вхолостую с паузой из вхолостую с паузой из
[database.md](database.md), «Настройки с числовым значением». [database.md](database.md), «Настройки с числовым значением».
## Единые точки проекта ## Единые точки проекта
| Что | Где | | Что | Где |
| --- | --- | | --- | --- |
| Приём аудио и заведение записи | `TranscribeService.createRecord`через него идут оба входа | | Приём аудио и заведение записи | `TranscribeService.createRecord`единственный путь, которым запись появляется в хранилище |
| Правка записи владельцем | панель хранилища; правка запросом проходит правила перехода (`pocketbase.BindPanelRules`), а шаг конвейера пишет только свои поля и правку владельца не стирает | | Правка записи владельцем | панель хранилища; правка запросом проходит правила перехода (`pocketbase.BindPanelRules`), а шаг конвейера пишет только свои поля и правку владельца не стирает |
| Захват записи воркером | `AudioRecordRepository.FindAndAcquire` — один запрос с `RETURNING`, отдаёт идентификатор и признак захвата | | Захват записи воркером | `AudioRecordRepository.FindAndAcquire` — один запрос с `RETURNING`, отдаёт идентификатор и признак захвата |
| Объявление рубежа | `internal/entity/stage.go` — выбор шага, отбор захвата, срок протухания и предел простоя выводятся отсюда | | Объявление рубежа | `internal/entity/stage.go` — выбор шага, отбор захвата, срок протухания и предел простоя выводятся отсюда |
@@ -194,7 +172,7 @@
| Рабочая копия файла на диске | `FileRepository.Localize`, `Stage`, `StageEmpty` — они же дают единственный способ её убрать (`WorkFile.Close`); зовёт его шаг | | Рабочая копия файла на диске | `FileRepository.Localize`, `Stage`, `StageEmpty` — они же дают единственный способ её убрать (`WorkFile.Close`); зовёт его шаг |
| Переход записи на рубеж | `entity.AudioRecord.MoveToState` — чистит служебные поля прошлого рубежа и ставит время входа | | Переход записи на рубеж | `entity.AudioRecord.MoveToState` — чистит служебные поля прошлого рубежа и ставит время входа |
| Откладывание работы | `entity.AudioRecord.Postpone` — ставит паузу и снимает захват, рубежа не трогая | | Откладывание работы | `entity.AudioRecord.Postpone` — ставит паузу и снимает захват, рубежа не трогая |
| Остановка и перезапуск | `entity.AudioRecord.Halt` и `Resume`; ответ отправителю`TranscribeService.halt`, одно место на все причины | | Остановка и перезапуск | `entity.AudioRecord.Halt` и `Resume`; запись причины и события`TranscribeService.halt`, одно место на все причины |
| Разбор конфигурации | `internal/config.LoadConfig` | | Разбор конфигурации | `internal/config.LoadConfig` |
| Чтение времени | `internal/clock``Now` даёт метку в UTC, `Start` — начало измерения длительности; `time.Now` вне пакета запрещён правилом линтера | | Чтение времени | `internal/clock``Now` даёт метку в UTC, `Start` — начало измерения длительности; `time.Now` вне пакета запрещён правилом линтера |
| Метрики | `internal/metrics`, префикс имени `transcriber_` | | Метрики | `internal/metrics`, префикс имени `transcriber_` |
@@ -224,7 +202,8 @@
[access](../openspec/specs/access/spec.md), решения — [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-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). и [ADR-2026-08-12-oidc-exchange-via-own-route](adr/ADR-2026-08-12-oidc-exchange-via-own-route.md).
**Не решено одно:** как связываются пользователь Telegram и пользователь веба. **Не решено одно:** как связать чат Telegram с учётной записью — от этого
зависит возвращение убранного входа.
Панель администратора при этом Authelia не закрывает: у неё свой пароль Панель администратора при этом Authelia не закрывает: у неё свой пароль
суперпользователя. суперпользователя.
- **Приложение.** Экранов нет вовсе, есть только API. Решено делать SPA, - **Приложение.** Экранов нет вовсе, есть только API. Решено делать SPA,
@@ -240,8 +219,8 @@
Push с VAPID отвергнут. Появляется внешняя зависимость, которой сегодня нет, и Push с VAPID отвергнут. Появляется внешняя зависимость, которой сегодня нет, и
текст расшифровки начинает уходить на сторону — сдвиг периметра текст расшифровки начинает уходить на сторону — сдвиг периметра
[security.md](security.md). [security.md](security.md).
- **Долгие записи.** Потолок сегодня неизвестен и не замерялся: 20 МиБ на приём - **Долгие записи.** Потолок сегодня неизвестен и не замерялся: ограничения
из Telegram — точно, ограничения `deferred-general` по длине — нет. Расчётные `deferred-general` по длине не выяснены. Расчётные
шесть часов нормирует [storage](../openspec/specs/storage/spec.md), «Файл шесть часов нормирует [storage](../openspec/specs/storage/spec.md), «Файл
записи живёт в хранилище»; откуда взято число — записи живёт в хранилище»; откуда взято число —
[research/pocketbase-defaults.md](research/pocketbase-defaults.md), «Чего эта [research/pocketbase-defaults.md](research/pocketbase-defaults.md), «Чего эта
@@ -265,8 +244,9 @@
- **Формат для распознавания.** Конвертер отдаёт ogg/vorbis (`libvorbis`), а - **Формат для распознавания.** Конвертер отдаёт ogg/vorbis (`libvorbis`), а
SpeechKit получает `ContainerAudio_OGG_OPUS`. Расхождение не разобрано: то ли SpeechKit получает `ContainerAudio_OGG_OPUS`. Расхождение не разобрано: то ли
сервис определяет содержимое сам, то ли часть записей теряется на этом. сервис определяет содержимое сам, то ли часть записей теряется на этом.
- **Видео.** Дорожку из видеофайла бот принимает по MIME-типу `video/`, но - **Видео.** Дорожка из видеофайла к приёму допускается — расширение он берёт из
конвертер этот случай не проверялся. имени и о годности содержимого спрашивает источник метаданных, — но конвертер
на этом случае не проверялся.
- **Очередь.** Модель очереди сделана задачей `pocketbase-storage` 2026-08-12 - **Очередь.** Модель очереди сделана задачей `pocketbase-storage` 2026-08-12
([ADR](adr/ADR-2026-08-11-queue-as-pocketbase-collection.md)), перестроена ([ADR](adr/ADR-2026-08-11-queue-as-pocketbase-collection.md)), перестроена
вокруг аудиозаписи задачей `record-centric-model` 2026-08-14 и нормирована вокруг аудиозаписи задачей `record-centric-model` 2026-08-14 и нормирована
+7 -18
View File
@@ -5,7 +5,7 @@
**Взято из проекта jellybit.** Расхождения с сегодняшним кодом названы по месту. **Взято из проекта jellybit.** Расхождения с сегодняшним кодом названы по месту.
Главные: комментариями снабжена половина полей; единого места проверки на старте Главные: комментариями снабжена половина полей; единого места проверки на старте
нет: у секций `[auth]` и `[telegram]` свой `Validate()` в `main.go`, а пустые нет: у секций `[auth]` и `[pipeline]` свой `Validate()` в `main.go`, а пустые
ключи `[yandex]` ловит конструктор распознавателя. ключи `[yandex]` ловит конструктор распознавателя.
**Механизировано:** запрет `os.Getenv``forbidigo` в `.golangci.yml` **Механизировано:** запрет `os.Getenv``forbidigo` в `.golangci.yml`
@@ -46,7 +46,6 @@
port = <N> # порт HTTP-сервера port = <N> # порт HTTP-сервера
shutdown_timeout = <N> # ждать мягкой остановки сервера, секунды shutdown_timeout = <N> # ждать мягкой остановки сервера, секунды
force_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]` образца заполнены примерами *Расхождение:* адреса провайдера в секции `[auth]` образца заполнены примерами
вида `https://auth.example.com/...`, а не оставлены пустыми: пустой адрес не вида `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_access_key_id`,
`yandex.object_storage_secret_access_key`, `auth.client_secret`. `yandex.object_storage_secret_access_key`, `auth.client_secret`.
- Отрендеренный `config.toml` (с секретами) **не коммитится**; права `0600`, - Отрендеренный `config.toml` (с секретами) **не коммитится**; права `0600`,
@@ -130,16 +126,8 @@ Ansible из `pet-project-server`). Приложение просто читае
TOML. Пустые ключи Yandex ловятся в конструкторе распознавателя, и там процесс TOML. Пустые ключи Yandex ловятся в конструкторе распознавателя, и там процесс
выходит с кодом 1. Единого места проверки нет. выходит с кодом 1. Единого места проверки нет.
Под это расхождение больше не подпадают два ключа секции `[telegram]` — признак Два ключа секции `[telegram]`, стоявшие здесь исключением, ушли вместе с самим
включения и ключ доступа, — и проверок у них две. Третий ключ секции, входом 2026-08-14: секции больше нет, и своей проверки у неё тоже.
`update_timeout`, границ по-прежнему не проверяет никто, и ноль в нём обращает
длинный опрос в непрерывный. Обязательность признака включения судит загрузчик — только разбор отличает
«ключ не задан» от «ключ задан ложным», потому что нулевое значение `bool` у
обоих одинаковое. Заполненность ключа доступа судит `TelegramConfig.Validate()` из
`main.go`, рядом с проверкой `[auth]`: пустой `bot_token` при `enabled = true`
ошибка настройки и отказ старта. Непустой негодный по-прежнему судится при сборке
клиента, до подъёма сервера. Нормирует это `openspec/specs/intake`, «Признак
включения решает, поднимается ли вход Telegram».
Секция `[auth]` — первая, у которой проверка своя и стоит на старте: Секция `[auth]` — первая, у которой проверка своя и стоит на старте:
`AuthConfig.Validate()` зовётся из `main.go` сразу после загрузки и роняет `AuthConfig.Validate()` зовётся из `main.go` сразу после загрузки и роняет
@@ -165,5 +153,6 @@ TOML. Пустые ключи Yandex ловятся в конструкторе
комментария у самого поля), ни по нулевому значению типа; присутствие ключа комментария у самого поля), ни по нулевому значению типа; присутствие ключа
судит **разбор**`MetaData.IsDefined` из `toml.DecodeFile`, — потому что судит **разбор**`MetaData.IsDefined` из `toml.DecodeFile`, — потому что
значение отличить «не задано» от «задано нулём» не позволяет. В значение отличить «не задано» от «задано нулём» не позволяет. В
`config.example.toml` у поля стоит значение свежей установки. Первое такое `config.example.toml` у поля стоит значение свежей установки. Первым таким
поле `telegram.enabled`. полем был `telegram.enabled`; секция убрана 2026-08-14, и живого примера у
правила сейчас нет.
+12 -14
View File
@@ -69,11 +69,10 @@ transcriber — **приложение, а не библиотека**: внеш
`contract.JobNotFoundError` (состояние и сообщение), `contract.NoopJobError` `contract.JobNotFoundError` (состояние и сообщение), `contract.NoopJobError`
(состояние), `contract.LostAcquisitionError` (идентификатор задачи). (состояние), `contract.LostAcquisitionError` (идентификатор задачи).
`tg.EmptyBotTokenError` был ровно тем случаем, против которого написано правило — Правило это однажды нарушал `tg.EmptyBotTokenError` — тип без полей, — и был
тип без полей, — и снят задачей `local-run-without-telegram-token` 2026-08-13; снят задачей `local-run-without-telegram-token` 2026-08-13 в пользу sentinel'а.
его место занял sentinel `telegram.ErrEmptyToken`. Рядом живёт Оба ушли из проекта 2026-08-14 вместе с входом Telegram; пример остаётся здесь
`contract.ErrDeliveryChannelDown` — тоже sentinel и по той же причине: заглушка как случай, а не как живой код.
отправителя не знает ни задачи, ни чата, и нести ей нечего.
## Граница и трансляция: приватный и публичный канал ## Граница и трансляция: приватный и публичный канал
@@ -83,8 +82,8 @@ transcriber — **приложение, а не библиотека**: внеш
- **Приватный канал — логи** (владелец сервиса). Полная ошибка со всей цепочкой - **Приватный канал — логи** (владелец сервиса). Полная ошибка со всей цепочкой
`%w` и контекстом. Пишется один раз на доменной границе — см. `%w` и контекстом. Пишется один раз на доменной границе — см.
[logging.md](logging.md). [logging.md](logging.md).
- **Публичный канал — пользовательские поверхности** (Telegram, веб-UI, HTTP - **Публичный канал — пользовательские поверхности** (веб-UI, HTTP API). Сюда
API). Сюда отдаём: отдаём:
- **человекочитаемое сообщение** по доменной ошибке — не сырой `err.Error()` и - **человекочитаемое сообщение** по доменной ошибке — не сырой `err.Error()` и
не детали реализации (`database/sql`, пути на диске, имена внешних сервисов); не детали реализации (`database/sql`, пути на диске, имена внешних сервисов);
- **корреляционный ключ** для владельца — идентификатор задачи, чтобы по нему - **корреляционный ключ** для владельца — идентификатор задачи, чтобы по нему
@@ -118,8 +117,7 @@ transcriber — **приложение, а не библиотека**: внеш
У публичной границы две поверхности, и правило сырого текста для них разное. У публичной границы две поверхности, и правило сырого текста для них разное.
- **Разовый ответ на действие** (тело HTTP-ответа, сообщение бота по результату - **Разовый ответ на действие** (тело HTTP-ответа) — строго нейтральный: отображение выше, `err.Error()` наружу не идёт,
команды) — строго нейтральный: отображение выше, `err.Error()` наружу не идёт,
полная ошибка остаётся в логах по идентификатору задачи. полная ошибка остаётся в логах по идентификатору задачи.
- **Сохранённая диагностика состояния** — колонка `error_text` задачи. Это - **Сохранённая диагностика состояния** — колонка `error_text` задачи. Это
**поверхность владельца**, а не пользователя: сюда сырой текст ошибки допустим **поверхность владельца**, а не пользователя: сюда сырой текст ошибки допустим
@@ -131,9 +129,9 @@ transcriber — **приложение, а не библиотека**: внеш
- **внешнее значение в тексте усекается на границе, а его размер называется - **внешнее значение в тексте усекается на границе, а его размер называется
числом рядом**: без этого непонятно, насколько сокращать. числом рядом**: без этого непонятно, насколько сокращать.
*Расхождение:* `error_text` пишется целиком, без вычистки и без усечения, а *Расхождение:* `error_text` пишется целиком, без вычистки и без усечения.
пользователь Telegram видит отдельный человекочитаемый текст — это часть Наружу он при этом не выходит: опрос готовности отдаёт признак остановки без
правила соблюдена. машинного текста — эту часть правила держит спека `intake`.
## panic ## panic
@@ -145,8 +143,8 @@ transcriber — **приложение, а не библиотека**: внеш
ронял процесс. В transcriber его вешает роутер хранилища сам ронял процесс. В transcriber его вешает роутер хранилища сам
(`apis.panicRecover`, слой с идентификатором `DefaultPanicRecoverMiddlewareId` (`apis.panicRecover`, слой с идентификатором `DefaultPanicRecoverMiddlewareId`
на каждом роутере PocketBase): паникующий обработчик отдаёт `500`, процесс на каждом роутере PocketBase): паникующий обработчик отдаёт `500`, процесс
живёт. Своего слоя мы не пишем. У воркеров и у бота такой границы **нет**: живёт. Своего слоя мы не пишем. У воркеров такой границы **нет**: паника в
паника в шаге конвейера роняет процесс целиком. шаге конвейера роняет процесс целиком.
## Несколько ошибок ## Несколько ошибок
+4 -4
View File
@@ -78,7 +78,7 @@
| --- | --- | | --- | --- |
| Сравнение ошибок через `errors.Is` и `errors.As`, не `==` и не приведением типа | `.golangci.yml``errorlint` | | Сравнение ошибок через `errors.Is` и `errors.As`, не `==` и не приведением типа | `.golangci.yml``errorlint` |
| Ошибка не узнаётся сравнением текста сообщения (`strings.Contains(err.Error(), …)`, `err.Error() == …`) | `internal/archrules``TestОшибкаНеУзнаётсяПоТексту` | | Ошибка не узнаётся сравнением текста сообщения (`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` его не видит | | Непроверенное приведение типа (`v := x.(T)`) | `.golangci.yml``errcheck` с `check-type-assertions`. Отдельная настройка, потому что такое приведение паникует, а не возвращает ошибку, и `check-blank` его не видит |
| Проверенный отказ не оборачивается в `return nil` | `.golangci.yml``nilerr`. Механизирует половину инварианта «принятая запись не теряется молча»: молчаливый успех после отказа | | Проверенный отказ не оборачивается в `return nil` | `.golangci.yml``nilerr`. Механизирует половину инварианта «принятая запись не теряется молча»: молчаливый успех после отказа |
| Отказ выборки из хранилища не теряется (`rows.Err()`), а сама выборка закрывается | `.golangci.yml``rowserrcheck`, `sqlclosecheck`. **Профилактические: предмета в коде сегодня нет** — выборки идут через `dbx` хранилища, а из `database/sql` употребляются только `sql.NullString` и `sql.ErrNoRows`. Правила заведены на будущий сырой запрос; мутацией проверены на пробе, а не на своём коде | | Отказ выборки из хранилища не теряется (`rows.Err()`), а сама выборка закрывается | `.golangci.yml``rowserrcheck`, `sqlclosecheck`. **Профилактические: предмета в коде сегодня нет** — выборки идут через `dbx` хранилища, а из `database/sql` употребляются только `sql.NullString` и `sql.ErrNoRows`. Правила заведены на будущий сырой запрос; мутацией проверены на пробе, а не на своём коде |
@@ -89,7 +89,7 @@
| Правило | Где механизировано | | Правило | Где механизировано |
| --- | --- | | --- | --- |
| Ядро (`internal/service`) не знает ни адаптеров, ни транспортов | `internal/archrules``TestЯдроНеЗнаетОбАдаптерах`, `TestЯдроНеЗнаетОТранспортах` | | Ядро (`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``TestАдаптерыНеЗнаютНиЯдра_НиТранспортов` |
| Колонки записи согласованы: что пишет отображение ↔ что читает обратное ↔ что заводит шаг схемы | `internal/archrules` → правила о колонках. Закрывает инвариант «колонки записи правятся в двух местах» (CLAUDE.md, major), которого компилятор не держит. Литерал колонки ищется в телах нужных функций, а не в файле целиком | | Колонки записи согласованы: что пишет отображение ↔ что читает обратное ↔ что заводит шаг схемы | `internal/archrules` → правила о колонках. Закрывает инвариант «колонки записи правятся в двух местах» (CLAUDE.md, major), которого компилятор не держит. Литерал колонки ищется в телах нужных функций, а не в файле целиком |
| Рубежи согласованы: дескриптор ↔ таблица выбора шага, в обе стороны | `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` | | Проверка судит ответ по готовому ответу (`Result()`), а не по живой карте заголовков обработчика | `.golangci.yml``forbidigo` с `analyze-types`, находки только в `*_test.go`. Судит по типу приёмника (`httptest.ResponseRecorder`), поэтому ловит любую форму: цепочкой, через переменную, по индексу карты, обходом, полем `HeaderMap`. Остаётся ревью проверка, идущая мимо recorder — через свой `http.ResponseWriter` |
| Форма утверждения в проверках: «ожидалось» и «получено» не перепутаны местами, отказ судится `NoError`, а не `Nil`, `require` не зовут из горутины | `.golangci.yml``testifylint` | | Форма утверждения в проверках: «ожидалось» и «получено» не перепутаны местами, отказ судится `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`) | | Строчное подавление называет линтер и причину, а протухшее краснеет | `.golangci.yml``nolintlint` (`require-explanation`, `require-specific`, `allow-unused: false`) |
### Форма кода и файлов вне Go ### Форма кода и файлов вне 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()` и есть способ отдать заголовок | | Правило о заголовках вне `*_test.go` | `.golangci.yml`, `exclusions` | В рабочем коде `Header()` и есть способ отдать заголовок |
| `time.Now` внутри `internal/clock` | там же | Единой точке чтения времени нечем читать время иначе | | `time.Now` внутри `internal/clock` | там же | Единой точке чтения времени нечем читать время иначе |
| Чтение времени и окружения в `*_test.go` | там же | Проверка строит вход прогона — фикстуру времени, `PATH`, окружение дочернего процесса, — а не метку домена и не настройки приложения. Исключение объявлено по тексту сообщения: правило называет четыре имени, и исключение обязано покрывать те же четыре | | Чтение времени и окружения в `*_test.go` | там же | Проверка строит вход прогона — фикстуру времени, `PATH`, окружение дочернего процесса, — а не метку домена и не настройки приложения. Исключение объявлено по тексту сообщения: правило называет четыре имени, и исключение обязано покрывать те же четыре |
+16 -29
View File
@@ -28,7 +28,7 @@ OpenSpec.
`jq` без регулярных выражений. `jq` без регулярных выражений.
```json ```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, …)`. *Расхождение:* `main.go` ставит `slog.NewTextHandler(os.Stdout, …)`.
@@ -37,7 +37,7 @@ OpenSpec.
- `msg` — короткая константа в нижнем регистре: `record accepted`, - `msg` — короткая константа в нижнем регистре: `record accepted`,
`recognition done`, `conversion failed`. Данные — в атрибутах: `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`, а не - `msg` — чистая категория без префикса подсистемы: `recognition done`, а не
`recognize: done`. Подсистему выносим в поле `capability`, не в текст. `recognize: done`. Подсистему выносим в поле `capability`, не в текст.
- **Смена состояния задачи — единая категория `state transition`** с полями - **Смена состояния задачи — единая категория `state transition`** с полями
@@ -60,7 +60,7 @@ OpenSpec.
| `DEBUG` | разработчику при отладке; в продакшене выключен | `GET /health`, пустой прогон воркера, проверка готовности операции распознавания, тела запросов и ответов внешних сервисов | | `DEBUG` | разработчику при отладке; в продакшене выключен | `GET /health`, пустой прогон воркера, проверка готовности операции распознавания, тела запросов и ответов внешних сервисов |
| `INFO` | владельцу, разбор постфактум | приём записи, переход задачи, конвертация выполнена, текст отправлен, старт и остановка процессов, **событийный вызов внешнего сервиса** | | `INFO` | владельцу, разбор постфактум | приём записи, переход задачи, конвертация выполнена, текст отправлен, старт и остановка процессов, **событийный вызов внешнего сервиса** |
| `WARN` | владельцу, «может стать проблемой» | повтор внешнего вызова, задача досталась повторно по истечении захвата, пустой текст распознавания | | `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` | | на задачу | `capability` (значения — по именам заведённых capability в `openspec/specs/`), `record_id`, `file_id`, `source` |
| на запись об ошибке | `error` | | на запись об ошибке | `error` |
| на вызов внешнего сервиса | `ext.service`, `ext.operation`, `ext.status_code`, `duration_ms`, `retry` | | на вызов внешнего сервиса | `ext.service`, `ext.operation`, `ext.status_code`, `duration_ms`, `retry` |
@@ -137,7 +137,7 @@ log := log.With("record_id", record.Id, "capability", "conversion")
- Логируем ошибку **один раз — на границе доменного слоя**, которая определяет - Логируем ошибку **один раз — на границе доменного слоя**, которая определяет
исход операции. Логирует эта единая точка, а не каждый транспорт — так исход операции. Логирует эта единая точка, а не каждый транспорт — так
транспорты остаются тонкими. Границы в transcriber: транспорты остаются тонкими. Границы в transcriber:
- приём записи (`CreateJobFromTelegram`, `CreateJobFromApi`); - приём записи (`CreateJobFromApi`);
- **шаг конвейера** (`FindAndRunConversionJob`, `FindAndRunTranscribeJob`, - **шаг конвейера** (`FindAndRunConversionJob`, `FindAndRunTranscribeJob`,
`FindAndRunTranscribeCheckJob`) — исход шага, вызванного циклом воркера; `FindAndRunTranscribeCheckJob`) — исход шага, вызванного циклом воркера;
- завершение и отказ задачи (`completeJob`, `failJob`). - завершение и отказ задачи (`completeJob`, `failJob`).
@@ -169,9 +169,9 @@ log := log.With("record_id", record.Id, "capability", "conversion")
**Каждый** вызов внешнего сервиса логируется. Поля: **Каждый** вызов внешнего сервиса логируется. Поля:
- `ext.service``telegram`, `speechkit`, `object-storage`, `ffmpeg`; - `ext.service``speechkit`, `object-storage`, `ffmpeg`;
- `ext.operation` — логическая операция (`getFile`, `sendMessage`, - `ext.operation` — логическая операция (`RecognizeFile`, `GetOperation`,
`RecognizeFile`, `GetOperation`, `PutObject`, `convert`); `PutObject`, `convert`);
- `ext.status_code` — код ответа, если применим; - `ext.status_code` — код ответа, если применим;
- `duration_ms` — длительность вызова; - `duration_ms` — длительность вызова;
- `retry` — номер попытки, если повторы были. - `retry` — номер попытки, если повторы были.
@@ -191,8 +191,7 @@ log := log.With("record_id", record.Id, "capability", "conversion")
*Расхождение:* обёртки `ext.*` нет. Из внешних вызовов логируется только *Расхождение:* обёртки `ext.*` нет. Из внешних вызовов логируется только
конвертация (через метрику длительности) и запуск распознавания; заливка в конвертация (через метрику длительности) и запуск распознавания; заливка в
Object Storage, скачивание файла из Telegram и опрос операции не логируются Object Storage и опрос операции не логируются никак.
никак.
## HTTP и проверка здоровья ## HTTP и проверка здоровья
@@ -218,7 +217,6 @@ Object Storage, скачивание файла из Telegram и опрос оп
Никаких секретов в полях и сообщениях. Под запретом: Никаких секретов в полях и сообщениях. Под запретом:
- токен бота Telegram;
- ключ SpeechKit и заголовок `Authorization`; - ключ SpeechKit и заголовок `Authorization`;
- пара ключей Object Storage; - пара ключей Object Storage;
- **сам текст расшифровки и имена файлов пользователя** — это содержимое личной - **сам текст расшифровки и имена файлов пользователя** — это содержимое личной
@@ -234,28 +232,17 @@ Object Storage, скачивание файла из Telegram и опрос оп
- При сомнении не логируем значение, логируем факт его наличия - При сомнении не логируем значение, логируем факт его наличия
(`"has_api_key", true`). (`"has_api_key", true`).
- **Ошибка HTTP-транспорта несёт URL — возможный носитель секрета.** - **Ошибка HTTP-транспорта несёт URL — возможный носитель секрета.**
`*url.Error` из `net/http` встраивает полный URL запроса, а токен Telegram `*url.Error` из `net/http` встраивает полный URL запроса, а секрет иногда
живёт прямо в пути (`…/bot<TOKEN>/…`). Такую ошибку разворачивают в живёт прямо в пути. Такую ошибку разворачивают в
первопричину на границе клиента **до** лога и до обёртки: URL отбрасывается, первопричину на границе клиента **до** лога и до обёртки: URL отбрасывается,
проверка `errors.Is` на причину сохраняется. Общее правило: **секрет не кладём проверка `errors.Is` на причину сохраняется. Общее правило: **секрет не кладём
в URL, если у сервиса есть заголовок** — тогда его нет и в ошибке транспорта. в URL, если у сервиса есть заголовок** — тогда его нет и в ошибке транспорта.
Обращения к Telegram этому правилу следуют, и точка чистки одна на все вызовы — Живого случая у этого правила сейчас нет: единственный секрет, стоявший в пути
`internal/adapter/telegram`, `NewBot`. Токен стоит в пути **каждого** обращения к обращения, — токен бота, и он ушёл вместе с входом Telegram 2026-08-14. Разбор
Bot API, поэтому чистка на месте употребления закрывала бы один вызов из всех: случая и цена промаха записаны в [../review.md](../review.md), 2026-08-13:
конвенция числила утечку расхождением с оценкой «не логируется», и оценка была
- отказ транспорта разворачивает в первопричину клиент бота (`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`, судящие по тексту
отказа и строке журнала.
*Изъятие, а не расхождение:* расширение берётся из имени отправителя дословно *Изъятие, а не расхождение:* расширение берётся из имени отправителя дословно
(`filepath.Ext`), поэтому имя `запись.тайное-слово` отдаёт приватный хвост (`filepath.Ext`), поэтому имя `запись.тайное-слово` отдаёт приватный хвост
+1 -1
View File
@@ -108,5 +108,5 @@
узнала»). узнала»).
- **Устройство service worker и версионирование статики** — задача - **Устройство service worker и версионирование статики** — задача
[installable-pwa](../../tasks/items/installable-pwa.md). [installable-pwa](../../tasks/items/installable-pwa.md).
- **Как связываются пользователь Telegram и пользователь веба** — открытый вопрос - **Как связать чат Telegram с учётной записью** — открытый вопрос; от него зависит возвращение убранного 2026-08-14 входа
«Учётные записи» в [../architecture.md](../architecture.md). «Учётные записи» в [../architecture.md](../architecture.md).
+15 -13
View File
@@ -47,7 +47,7 @@ CGO сборке не нужен.
| --- | --- | --- | | --- | --- | --- |
| `id` | TEXT PK | Идентификатор записи, выдаёт хранилище | | `id` | TEXT PK | Идентификатор записи, выдаёт хранилище |
| `file` | file | Сам файл | | `file` | file | Сам файл |
| `owner` | relation → `users` | Владелец файла; пусто у файлов записи, принятой ботом | | `owner` | relation → `users` | Владелец файла; пустого значения не принимает |
| `location` | select | `local` или `s3` | | `location` | select | `local` или `s3` |
| `object_key` | TEXT | Ключ объекта; заведён прежним шагом и новым путём не заполняется | | `object_key` | TEXT | Ключ объекта; заведён прежним шагом и новым путём не заполняется |
| `size` | INTEGER | Размер в байтах | | `size` | INTEGER | Размер в байтах |
@@ -66,8 +66,8 @@ capability, и третий смысл развёл бы одно слово п
| Поле | Тип | Что | | Поле | Тип | Что |
| --- | --- | --- | | --- | --- | --- |
| `id` | TEXT PK | Идентификатор записи, выдаёт хранилище | | `id` | TEXT PK | Идентификатор записи, выдаёт хранилище |
| `owner` | relation → `users` | Владелец записи; пусто у записей, принятых ботом | | `owner` | relation → `users` | Владелец записи; пустого значения не принимает |
| `source` | select | `api`, `telegram`, `unknown` | | `source` | select | `api`, `unknown`; значение `telegram` осталось историческим — вход убран, новых записей с ним не появляется |
| `title`, `brief` | TEXT | Заголовок и краткое описание: читаются вместе со списком | | `title`, `brief` | TEXT | Заголовок и краткое описание: читаются вместе со списком |
| `state` | select | Рубеж: `uploaded`, `normalized`, `submitted`, `transcribed`, `done`; перечень закрыт схемой | | `state` | select | Рубеж: `uploaded`, `normalized`, `submitted`, `transcribed`, `done`; перечень закрыт схемой |
| `state_entered_at` | DATETIME | Время входа в рубеж — сторож застревания | | `state_entered_at` | DATETIME | Время входа в рубеж — сторож застревания |
@@ -84,8 +84,8 @@ capability, и третий смысл развёл бы одно слово п
| `structure` | relation → `structures` | Структура реплик | | `structure` | relation → `structures` | Структура реплик |
| `recognition` | relation → `recognitions` | Попытка распознавания | | `recognition` | relation → `recognitions` | Попытка распознавания |
| `topics` | relation → `topics`, до 5 | Темы записи | | `topics` | relation → `topics`, до 5 | Темы записи |
| `tg_chat_id` | INTEGER | Куда отправить результат | | `tg_chat_id` | INTEGER | Адресат ответа у записи убранного входа; кодом не читается |
| `tg_reply_message_id` | INTEGER | С каким сообщением связать | | `tg_reply_message_id` | INTEGER | Ответное сообщение у неё же; кодом не читается |
| `created`, `updated` | DATETIME | Проставляет хранилище | | `created`, `updated` | DATETIME | Проставляет хранилище |
Индекс один — по паре «рубеж и признак остановки»: выборка захвата идёт по ним, Индекс один — по паре «рубеж и признак остановки»: выборка захвата идёт по ним,
@@ -188,10 +188,15 @@ capability, и третий смысл развёл бы одно слово п
висела бы в панели вторым домом для понятия, которого больше нет. висела бы в панели вторым домом для понятия, которого больше нет.
**Владелец записи** заведён шагом `202608140001` — связью с коллекцией `users` в **Владелец записи** заведён шагом `202608140001` — связью с коллекцией `users` в
обеих таблицах. Пустое значение допустимо, и это решение с ценой: записи, обеих таблицах, — и шагом `202608140003` пустого значения больше не принимает.
принятые ботом, владельца не имеют вовсе, потому что связи чата Telegram с Прежде принимал, и цену за это платили записи входа Telegram: связи чата с
учётной записью сервис не ведёт. Обязательность для приёма по HTTP держит поэтому учётной записью сервис не вёл. Вход убран 2026-08-14, ничью запись заводить стало
сам приём, а не схема. некому, и обязательность переехала из приёма в схему — туда, где её держит
хранилище, а не договорённость.
**Колонки `tg_chat_id` и `tg_reply_message_id`** остались от убранного входа и
кодом больше не читаются. Из схемы они не убираются: заводили их применённые
шаги `202608110001` и `202608140002`, а применённый шаг не переписывается.
Выборка по владельцу сужает **чтение записи**: чужая, ничья и несуществующая Выборка по владельцу сужает **чтение записи**: чужая, ничья и несуществующая
дают один и тот же отказ. Выборку воркера владелец не сужает — конвейер дают один и тот же отказ. Выборку воркера владелец не сужает — конвейер
@@ -288,11 +293,8 @@ capability, и третий смысл развёл бы одно слово п
| Задержка перед первой проверкой операции | 10 секунд | `service/transcribe.go` | как было | | Задержка перед первой проверкой операции | 10 секунд | `service/transcribe.go` | как было |
| Задержка между проверками операции | 5 секунд | там же | как было | | Задержка между проверками операции | 5 секунд | там же | как было |
| Пауза воркера между прогонами | 1 секунда | `controller/worker/worker.go` | как было | | Пауза воркера между прогонами | 1 секунда | `controller/worker/worker.go` | как было |
| Предел длины сообщения Telegram | 4000 символов | `adapter/telegram/sender.go` | предел Telegram |
| Таймаут мягкой остановки | 5 секунд | конфиг, `[server] shutdown_timeout` | — | | Таймаут мягкой остановки | 5 секунд | конфиг, `[server] shutdown_timeout` | — |
| Таймаут жёсткой остановки | 20 секунд | конфиг, `[server] force_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` | — | | Качество кодирования vorbis | `-q:a 4` | `adapter/converter/ffmpeg/ffmpeg.go` | — |
| Жизнь приглашения завести владельца панели | 30 минут | умолчание PocketBase | — | | Жизнь приглашения завести владельца панели | 30 минут | умолчание PocketBase | — |
| Потолок размера одной записи | 8 ГиБ | `entity.MaxRecordSize` | расчётный потолок в шесть часов с запасом на видео | | Потолок размера одной записи | 8 ГиБ | `entity.MaxRecordSize` | расчётный потолок в шесть часов с запасом на видео |
@@ -320,4 +322,4 @@ capability, и третий смысл развёл бы одно слово п
Чего среди настроек **нет**: режим журналирования, таймаут занятости и размер Чего среди настроек **нет**: режим журналирования, таймаут занятости и размер
пула соединений задаёт хранилище своими умолчаниями, а не мы; срока хранения пула соединений задаёт хранилище своими умолчаниями, а не мы; срока хранения
файлов и объектов нет вовсе. Таймаутов у файлов и объектов нет вовсе. Таймаутов у
обращений к Telegram, S3 и SpeechKit тоже нет — ни одного. обращений к S3 и SpeechKit тоже нет — ни одного.
+13 -16
View File
@@ -21,12 +21,14 @@
| --- | --- | | --- | --- |
| Владелец сервиса | Загрузить диктофонную запись или видео из семейного архива с телефона и получить текст. Видеть, кто сколько загрузил и во что это обошлось | | Владелец сервиса | Загрузить диктофонную запись или видео из семейного архива с телефона и получить текст. Видеть, кто сколько загрузил и во что это обошлось |
| Приглашённый пользователь | Войти в приложение через свою учётную запись, загрузить запись, забрать текст, вернуться к ней через месяц. Приложение ставится на телефон; каждый видит только свои записи | | Приглашённый пользователь | Войти в приложение через свою учётную запись, загрузить запись, забрать текст, вернуться к ней через месяц. Приложение ставится на телефон; каждый видит только свои записи |
| Пользователь Telegram | Отправить боту голосовое сообщение и получить текст ответом. Работает сегодня |
| Внешняя программа | Отдать файл по HTTP, представившись своим токеном, и опросить готовность. Сегодня почти не работает: приём и опрос закрыты сессией OIDC, а своего токена у программы нет — годится только чужая сессия, снятая из браузера и предъявленная кукой либо заголовком `Authorization`. Токен приносит `api-tokens` | | Внешняя программа | Отдать файл по HTTP, представившись своим токеном, и опросить готовность. Сегодня почти не работает: приём и опрос закрыты сессией OIDC, а своего токена у программы нет — годится только чужая сессия, снятая из браузера и предъявленная кукой либо заголовком `Authorization`. Токен приносит `api-tokens` |
**Основной вход — приложение**, бот и HTTP API дополняют его. До 2026-08-11 **Вход у сервиса один — HTTP API**, и приложение строится поверх него. До
основным был бот, и порядок здесь перевёрнут сознательно: диктофонная запись на 2026-08-11 основным входом был Telegram-бот. 2026-08-11 основным объявили
несколько часов через Telegram не проходит вовсе. приложение: диктофонная запись на несколько часов через Telegram не проходит
вовсе. 2026-08-14 бот убран целиком — временно, до задачи, которая свяжет чат с
учётной записью. Вместе с ним из потребителей ушёл пользователь
Telegram.
Цель достигнута, когда: Цель достигнута, когда:
@@ -35,8 +37,8 @@
- запись расчётного потолка — шести часов — доходит до текста, а не прерывается - запись расчётного потолка — шести часов — доходит до текста, а не прерывается
ошибкой при достижении предела (норма — `openspec/specs/storage`); ошибкой при достижении предела (норма — `openspec/specs/storage`);
- сервисом пользуются несколько человек, и записи одного не видны другому; - сервисом пользуются несколько человек, и записи одного не видны другому;
- текст доступен там же, где загружали, — в приложении и в Telegram. Человек - текст доступен там же, где загружали. Человек узнаёт о его готовности, не
узнаёт о его готовности, не держа приложение открытым; держа приложение открытым;
- расшифровка не теряется: к записи возвращаются через месяц и находят её по - расшифровка не теряется: к записи возвращаются через месяц и находят её по
заголовку и темам; заголовку и темам;
- владелец видит расход по каждому пользователю и понимает, во что обходится - владелец видит расход по каждому пользователю и понимает, во что обходится
@@ -90,21 +92,16 @@
2. **Возвращение к записи.** Через месяц человек открывает список, находит 2. **Возвращение к записи.** Через месяц человек открывает список, находит
запись по заголовку или теме и читает вычитанный текст, а при нужде — сырую запись по заголовку или теме и читает вычитанный текст, а при нужде — сырую
расшифровку. расшифровку.
3. **Голосовое из Telegram.** Пользователь шлёт боту голосовое сообщение, бот 3. **Загрузка по HTTP.** Программа шлёт `POST /api/audio` со своим токеном,
отвечает «обрабатываю», через минуту приходит текст ответом на то же
сообщение. Записи, чей текст длиннее предела сообщения Telegram, приходят
несколькими частями. Работает сегодня.
4. **Файл через Telegram.** То же для аудиофайла или документа с аудио: бот
отличает их по MIME-типу и расширению. Работает сегодня.
5. **Загрузка по HTTP.** Программа шлёт `POST /api/audio` со своим токеном,
получает идентификатор задачи и опрашивает `GET /api/status/:id`, пока не получает идентификатор задачи и опрашивает `GET /api/status/:id`, пока не
увидит `done` и текст. Сегодня доступно только предъявившему сессию OIDC: увидит `done` и текст. Сегодня доступно только предъявившему сессию OIDC:
анонимный запрос обоими адресами отклоняется. Своего входа у программы нет — анонимный запрос обоими адресами отклоняется. Своего входа у программы нет —
его заводит `api-tokens`. Записи при этом разграничены: программа с чужой его заводит `api-tokens`. Записи при этом разграничены: программа с чужой
сессией видит только записи того, чью сессию предъявила. сессией видит только записи того, чью сессию предъявила.
6. **Отказ на середине.** Конвертация или распознавание не удались — задача 4. **Отказ на середине.** Конвертация или распознавание не удались — запись
переходит в `failed`, а пользователь получает сообщение о том, что именно не получает признак остановки с причиной, и опрос готовности отдаёт этот признак
вышло, и предложение повторить. тому, кто её загрузил. Сообщения о неудаче сервис никому не шлёт: доставка
ушла вместе с ботом, а уведомления заводит задача `ntfy-delivery`.
## Референсы ## Референсы
+36 -3
View File
@@ -4,14 +4,16 @@
[записки разведки](pocketbase.md) отличаются предметом: та мерила, **что даёт [записки разведки](pocketbase.md) отличаются предметом: та мерила, **что даёт
панель**, эта — **что библиотека делает молча**, если её не переубедить. панель**, эта — **что библиотека делает молча**, если её не переубедить.
Все четыре наблюдения нашлись ревью, а не чтением документации: три из них Наблюдения нашлись ревью, а не чтением документации, и все об одном роде промаха:
выглядят как «значение по умолчанию — нет ограничения», а значат обратное. объявление библиотеки выглядит как «ограничения нет» либо «ограничение есть», а
значит обратное.
## Как снималось ## Как снималось
Версия **0.39.10**, та же, что у первой записки. Прогоны — на пустом каталоге Версия **0.39.10**, та же, что у первой записки. Прогоны — на пустом каталоге
данных во временном каталоге и на поднятом сервере `127.0.0.1:18099`; боевые данных во временном каталоге и на поднятом сервере `127.0.0.1:18099`; боевые
данные и ключи не участвовали. Числа ниже сняты 2026-08-11 и 2026-08-12. данные и ключи не участвовали. Числа сняты 2026-08-11 и 2026-08-12, последнее
наблюдение — 2026-08-15.
## Нулевой потолок у поля файла значит 5 МиБ, а не «без предела» ## Нулевой потолок у поля файла значит 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 ГиБ на диске.** Число выбрано расчётом из - **Во что обходится потолок в 8 ГиБ на диске.** Число выбрано расчётом из
@@ -96,3 +126,6 @@ core/field_file.go:310 if f.MaxSize <= 0 { return DefaultFileFieldMaxSize }
— нет. — нет.
- **Поведение под одновременной правкой панели и конвейера в бою.** Проверено - **Поведение под одновременной правкой панели и конвейера в бою.** Проверено
тестом на одной машине, не живой нагрузкой. тестом на одной машине, не живой нагрузкой.
- **Сколько строк с пустой связью выдерживает смена признака обязательности.**
Проверено на одной строке: суть наблюдения — сам факт отсутствия проверки, а не
её цена на объёме.
+91 -38
View File
@@ -51,14 +51,14 @@
- повтор шага на той же задаче не создаёт лишних файлов и записей; - повтор шага на той же задаче не создаёт лишних файлов и записей;
- отвечает пользователю ровно один раз. - отвечает пользователю ровно один раз.
**Транспорт** (`internal/controller/tg`, `internal/controller/http`): **Транспорт** (`internal/controller/http`):
- проверяет право отправителя до всякой работы; - проверяет право отправителя до всякой работы;
- не логирует ошибку, которую уже залогировал доменный слой; - не логирует ошибку, которую уже залогировал доменный слой;
- переводит доменную ошибку в свой ответ, а не отдаёт сырой текст; - переводит доменную ошибку в свой ответ, а не отдаёт сырой текст;
- закрывает то, что открыл, на всех ветках выхода. - закрывает то, что открыл, на всех ветках выхода.
**Клиент внешнего сервиса** (`adapter/recognizer/yandex`, `adapter/telegram`): **Клиент внешнего сервиса** (`adapter/recognizer/yandex`):
- имеет таймаут и не виснет, когда внешний сервис не отвечает; - имеет таймаут и не виснет, когда внешний сервис не отвечает;
- не кладёт секрет в URL и не даёт ему утечь через ошибку транспорта; - не кладёт секрет в URL и не даёт ему утечь через ошибку транспорта;
@@ -121,17 +121,19 @@
- **«Файлы и объекты не удаляются, диск растёт».** Факт верный и записан в - **«Файлы и объекты не удаляются, диск растёт».** Факт верный и записан в
[database.md](database.md); срок хранения не задан сознательно, задачи на него нет. [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` и делает с задачей, деньгами и ответом отправителю» (чтение `worker.go` и
`transcribe.go`, 2026-08-13; прежняя запись от 2026-08-10 устарела вместе с `transcribe.go`, 2026-08-13; прежняя запись от 2026-08-10 устарела вместе с
дефектом «остановка хоронила запись»). дефектом «остановка хоронила запись»).
- `operations`: появился ли таймаут у обращения к Telegram, S3 и SpeechKit — ни у - `operations`: появился ли таймаут у обращения к S3 и SpeechKit — ни у одного
одного из них таймаута нет, и проброс контекста на этот вопрос **не отвечает**: из них таймаута нет, и проброс контекста на этот вопрос **не отвечает**:
контекст здесь несёт жизнь процесса, а не дедлайн вызова (чтение `tg.go`, контекст здесь несёт жизнь процесса, а не дедлайн вызова (чтение `s3.go`,
`s3.go`, `speechkit.go`, 2026-08-13). `speechkit.go`, 2026-08-13).
- `operations`: не удвоилась ли запись об одном сбое — шаг логирует ошибку и - `operations`: не удвоилась ли запись об одном сбое — шаг логирует ошибку и
возвращает её воркеру, который логирует снова (чтение `transcribe.go`, возвращает её воркеру, который логирует снова (чтение `transcribe.go`,
2026-08-10). 2026-08-10).
@@ -161,14 +163,12 @@
- `security`: не уходит ли значение, пришедшее снаружи, меткой метрики — страница - `security`: не уходит ли значение, пришедшее снаружи, меткой метрики — страница
метрик отдаётся без проверки отправителя, и метка это поверхность пошире метрик отдаётся без проверки отправителя, и метка это поверхность пошире
журнала (журнал, запись 2026-08-11 про хвост имени). журнала (журнал, запись 2026-08-11 про хвост имени).
- `architecture`: не появился ли второй путь приёма мимо - `architecture`: не появился ли второй путь приёма мимо `createRecord` — сегодня
`createTranscribeJob` — сегодня через него идут оба входа он единственный, которым запись попадает в хранилище
([architecture.md](architecture.md), «Единые точки проекта»). ([architecture.md](architecture.md), «Единые точки проекта»).
- `architecture`: не поехало ли поведение в `architecture.md` вместо спеки — - `architecture`: не поехало ли поведение в `architecture.md` вместо спеки —
заведены четыре capability (`intake`, `pipeline`, `storage`, `access`), и заведённые capability описывают поведение не целиком, и остаток живёт в обзоре
первые две описаны частично. Поведение прочих узлов, включая под маркерами долга, а соблазн дописать туда ещё — самый большой.
приём из Telegram, живёт в обзоре под маркерами долга, а соблазн дописать туда
ещё — самый большой.
- `conventions`: новая колонка правится в обоих местах репозитория, а новый - `conventions`: новая колонка правится в обоих местах репозитория, а новый
рубеж — одним дескриптором рубеж — одним дескриптором
(CLAUDE.md, «Инварианты»). (CLAUDE.md, «Инварианты»).
@@ -205,12 +205,12 @@
- замена хранилища или переход на PocketBase — любой её кусок; - замена хранилища или переход на PocketBase — любой её кусок;
- смена модели очереди: захват, повторы и воркеры разом; - смена модели очереди: захват, повторы и воркеры разом;
- каркас приложения: сборка фронтенда, раздача статики и шаг гейта разом; - каркас приложения: сборка фронтенда, раздача статики и шаг гейта разом;
- изменение, трогающее оба входа сразу — Telegram и HTTP. - изменение, убирающее или возвращающее вход приёма целиком.
**Незнакомое здесь** (поднимает до `large`, ось формы решения): **Незнакомое здесь** (поднимает до `large`, ось формы решения):
- вход через OIDC и разграничение доступа: как связаны пользователь Telegram и - вход через OIDC и разграничение доступа: как связать чат Telegram с учётной
пользователь приложения, до начала работы назвать нельзя; записью, до начала работы назвать нельзя;
- всё, что делается на выбранном фреймворке впервые: правила - всё, что делается на выбранном фреймворке впервые: правила
[conventions/web-ui.md](conventions/web-ui.md) выведены из выбора и из замера [conventions/web-ui.md](conventions/web-ui.md) выведены из выбора и из замера
на пробном экране, а не из написанного кода, и первая же задача проверяет их на пробном экране, а не из написанного кода, и первая же задача проверяет их
@@ -224,7 +224,6 @@
**Мелкое здесь** (опускает до `small`): **Мелкое здесь** (опускает до `small`):
- правка текста, который видит пользователь Telegram;
- новая метрика в `internal/metrics`; - новая метрика в `internal/metrics`;
- правка `config.example.toml` и умолчаний `defaultConfig()` без нового поля; - правка `config.example.toml` и умолчаний `defaultConfig()` без нового поля;
- правка документов канона. - правка документов канона.
@@ -259,19 +258,19 @@ API и имя не откатываются обратной правкой по
`adapter/metaviewer/ffmpeg` нет; решение и его цена — в `adapter/metaviewer/ffmpeg` нет; решение и его цена — в
[adr/ADR-2026-08-11-stub-adapters-in-tests.md](adr/ADR-2026-08-11-stub-adapters-in-tests.md); [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`, «Признак включения решает, поднимается ли вход секций `[auth]` и `[yandex]` — наружу они на старте не ходят. Живой прогон —
Telegram»). Живой прогон — осмотр HTTP, панели, журнала и остановки — доступен осмотр HTTP, панели, журнала, метрик и остановки — доступен любой задаче.
теперь любой задаче. Прежняя формулировка «всё, что требует поднять сервис целиком» Прежняя формулировка «всё, что требует поднять сервис целиком» снята задачей
снята задачей `local-run-without-telegram-token` 2026-08-13; рецепт прогона `local-run-without-telegram-token` 2026-08-13; рецепт прогона менялся дважды —
сменился с пустого ключа доступа на выключенный вход задачей с пустого ключа доступа на выключенный вход (`telegram-enabled-flag` того же
`telegram-enabled-flag` того же дня. дня), а 2026-08-14 признак включения ушёл вместе с самим входом.
**Остаток**: за настоящий Telegram, SpeechKit и Object Storage живой прогон **Остаток**: за настоящие SpeechKit и Object Storage живой прогон по-прежнему
по-прежнему не отвечает — боевым токеном запускаться запрещено, ключи Yandex в не отвечает — ключи Yandex в прогоне выдуманные, а распознавание подменяют в
прогоне выдуманные, а распознавание подменяют в коде. Проверить живьём можно коде. Проверить живьём можно подъём, отказ старта, маршруты, метрики и
подъём, отказ старта, маршруты и остановку; нельзя — приём из Telegram, остановку; нельзя — расшифровку и заливку. Вход через живого провайдера OIDC
расшифровку и заливку. тоже недоступен: сессию в прогоне выдать нечем.
## Журнал дефектов ## Журнал дефектов
@@ -281,6 +280,60 @@ API и имя не откатываются обратной правкой по
истории git 2026-08-10: поле «Чем воспроизведён» называет у них коммит, а не истории 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 — сторож инварианта про секрет искал подстроку, которой не бывает [пойман ревью] ## 2026-08-13 — сторож инварианта про секрет искал подстроку, которой не бывает [пойман ревью]
- **Где:** `internal/config/config_test.go`, проверка «значение ключа доступа не - **Где:** `internal/config/config_test.go`, проверка «значение ключа доступа не
+19 -44
View File
@@ -15,11 +15,10 @@
записи, а чужая отвечает «не найдено». Целевому периметру недостаёт теперь второго уровня записи, а чужая отвечает «не найдено». Целевому периметру недостаёт теперь второго уровня
доступа — страницы расхода для владельца сервиса. доступа — страницы расхода для владельца сервиса.
Записи, принятые ботом, владельца не имеют и по API не достаются никому: связи Ничьих записей у сервиса больше не бывает: колонка владельца пустого значения
чата с учётной записью приложения нет, её заводит `telegram-account-link`. не принимает, и держит это схема хранилища. Прежде такие записи заводил вход
Telegram — связи чата с учётной записью сервис не вёл, — и 2026-08-14 вход убран
Разграничение доступа в Telegram осталось прежним — белым списком, и с учётной вместе с этим исключением.
записью приложения он не связан.
**Целевой периметр шире сегодняшнего не только входом.** Содержимое записи **Целевой периметр шире сегодняшнего не только входом.** Содержимое записи
начинает уходить на три новые стороны — языковой модели, в канал уведомлений и начинает уходить на три новые стороны — языковой модели, в канал уведомлений и
@@ -59,9 +58,7 @@
| --- | --- | --- | | --- | --- | --- |
| Аудиофайл и его имя | `POST /api/audio`, multipart-поле `audio` | Любой вошедший через OIDC; без сессии — `401` до чтения тела | | Аудиофайл и его имя | `POST /api/audio`, multipart-поле `audio` | Любой вошедший через OIDC; без сессии — `401` до чтения тела |
| Идентификатор задачи | `GET /api/status/:id` | Любой вошедший через OIDC; без сессии — `401`, одинаковый для заведённой и незаведённой задачи | | Идентификатор задачи | `GET /api/status/:id` | Любой вошедший через OIDC; без сессии — `401`, одинаковый для заведённой и незаведённой задачи |
| Голосовое, аудио, документ | Telegram, длинный опрос | Любой пользователь Telegram; обрабатывается только из белого списка | | Содержимое аудио | Файл, скармливаемый `ffmpeg` и `ffprobe` | Отправитель |
| Имя файла в Telegram | Поле `file_path` ответа Bot API | Telegram, а через него — отправитель |
| Содержимое аудио | Файл, скармливаемый `ffmpeg` и `ffprobe` | Отправитель по любому из каналов |
| Текст расшифровки | Поток gRPC от SpeechKit | Yandex, а через него — содержимое записи | | Текст расшифровки | Поток gRPC от SpeechKit | Yandex, а через него — содержимое записи |
Что добавится вместе с целевым периметром — каждый вход появляется своей Что добавится вместе с целевым периметром — каждый вход появляется своей
@@ -82,9 +79,10 @@
## Куда уходит содержимое записи ## Куда уходит содержимое записи
Сегодня запись и её текст покидают наш сервер тремя путями: файл уезжает в Сегодня запись покидает наш сервер двумя путями: файл уезжает в Yandex Object
Yandex Object Storage, оттуда его читает SpeechKit, а текст возвращается в Storage, оттуда его читает SpeechKit. Третий путь — ответ в Telegram — исчез
Telegram отправителю. 2026-08-14 вместе с убранным входом: текст теперь достаётся только по опросу
готовности и в панели владельца.
Целевой периметр добавляет три пути, каждый — своей задачей: Целевой периметр добавляет три пути, каждый — своей задачей:
@@ -156,10 +154,6 @@ Telegram отправителю.
## Что разграничивает доступ ## Что разграничивает доступ
- **Telegram** — белый список `[server] users_while_list`. Сверяется со строкой
автора сообщения (`update.Message.From.String()`, то есть `@username` либо имя
с фамилией), а не с числовым идентификатором. Имя пользователя Telegram
меняется владельцем в любой момент: список привязан к изменяемому значению.
- **HTTP API** — сессия, заведённая входом через OIDC у Authelia. Предъявляется - **HTTP API** — сессия, заведённая входом через OIDC у Authelia. Предъявляется
кукой `transcriber_session`, обесценивается выходом, срок жизни назначен числом кукой `transcriber_session`, обесценивается выходом, срок жизни назначен числом
([database.md](database.md), «Настройки с числовым значением»). ([database.md](database.md), «Настройки с числовым значением»).
@@ -203,9 +197,6 @@ Telegram отправителю.
| Личный токен | Права своего владельца программе, без браузерной сессии | `api-tokens` | | Личный токен | Права своего владельца программе, без браузерной сессии | `api-tokens` |
| Признак владельца сервиса | Страницу расхода и сводку по всем пользователям | `admin-stats-screen` | | Признак владельца сервиса | Страницу расхода и сводку по всем пользователям | `admin-stats-screen` |
Белый список Telegram при этом перестаёт быть отдельным механизмом: право
писать боту выводится из учётной записи (`telegram-account-link`).
Признак владельца сервиса — **второй уровень доступа**, которого в сегодняшней Признак владельца сервиса — **второй уровень доступа**, которого в сегодняшней
модели нет вовсе: до него всё разграничение сводилось к «свой или чужой». модели нет вовсе: до него всё разграничение сводилось к «свой или чужой».
Откуда он берётся — из группы OIDC или из конфигурации — не решено Откуда он берётся — из группы OIDC или из конфигурации — не решено
@@ -229,10 +220,10 @@ Telegram отправителю.
`record_events` (журнал событий, содержимого не несёт) и `topics` (словарь `record_events` (журнал событий, содержимого не несёт) и `topics` (словарь
тем человека). Всякая новая коллекция, куда содержимое переезжает, закрывается тем человека). Всякая новая коллекция, куда содержимое переезжает, закрывается
наравне с записью — норму держит спека `storage`. наравне с записью — норму держит спека `storage`.
2. **Токен бота Telegram.** Даёт полный доступ к боту и к перепискам с ним. 2. **Ключи Yandex Cloud**`speech_kit_api_key` и пара ключей Object Storage.
3. **Ключи Yandex Cloud**`speech_kit_api_key` и пара ключей Object Storage.
Утечка оплачивается деньгами и доступом к бакету. Утечка оплачивается деньгами и доступом к бакету.
4. **Белый список пользователей**сам по себе перечень имён. 3. **Секрет клиента OIDC** — вместе с адресами провайдера открывает вход в
приложение от чужого имени.
Всё перечисленное лежит в `config.toml`. Файл в `.gitignore`, на сервер его Всё перечисленное лежит в `config.toml`. Файл в `.gitignore`, на сервер его
кладёт Ansible; `gitleaks` на pre-commit смотрит только индекс коммита. кладёт Ansible; `gitleaks` на pre-commit смотрит только индекс коммита.
@@ -288,30 +279,14 @@ Telegram отправителю.
приведения хвост читал бы кто угодно из интернета, а множеством значений метки приведения хвост читал бы кто угодно из интернета, а множеством значений метки
распоряжался бы анонимный отправитель. распоряжался бы анонимный отправитель.
Приём из Telegram имени, данного человеком, до сервиса не доводит: оттуда Два пути утечки токена бота — адрес Bot API в отказе транспорта и отказ сборки
приходит путь, выданный самим Telegram. Настоящее имя документа дальше проверки клиента — закрыты задачами `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`, Путь, который остался, закрыт задачей `telegram-enabled-flag` 2026-08-13, и он
`…/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, и он **шире
токена бота**: до неё утечь мог любой секрет конфига. Отказ разбора файла
настроек пересказывался как есть, а библиотека разбора собирает текст отказа из настроек пересказывался как есть, а библиотека разбора собирает текст отказа из
разбираемого куска — `toml.ParseError` кладёт в сообщение само значение. Строка разбираемого куска — `toml.ParseError` кладёт в сообщение само значение. Строка
секретного ключа с оборванной кавычкой — типовая поломка криво собранного секретного ключа с оборванной кавычкой — типовая поломка криво собранного
+2 -4
View File
@@ -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/feature/s3/manager v1.18.3
github.com/aws/aws-sdk-go-v2/service/s3 v1.97.3 github.com/aws/aws-sdk-go-v2/service/s3 v1.97.3
github.com/aws/smithy-go v1.27.7 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/google/uuid v1.6.0
github.com/joho/godotenv v1.5.1 github.com/joho/godotenv v1.5.1
github.com/pocketbase/dbx v1.12.0 github.com/pocketbase/dbx v1.12.0
@@ -50,7 +49,6 @@ require (
github.com/go-sql-driver/mysql v1.9.2 // indirect github.com/go-sql-driver/mysql v1.9.2 // indirect
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // 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-colorable v0.1.15 // indirect
github.com/mattn/go-isatty v0.0.23 // indirect github.com/mattn/go-isatty v0.0.23 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // 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/cobra v1.10.2 // indirect
github.com/spf13/pflag v1.0.10 // indirect github.com/spf13/pflag v1.0.10 // indirect
golang.org/x/crypto v0.54.0 // 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/net v0.57.0 // indirect
golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/oauth2 v0.36.0 // indirect
golang.org/x/sync v0.22.0 // indirect golang.org/x/sync v0.22.0 // indirect
golang.org/x/sys v0.47.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/api v0.0.0-20260414002931-afd174a4e478 // indirect
google.golang.org/genproto/googleapis/rpc 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 gopkg.in/yaml.v3 v3.0.1 // indirect
+8 -10
View File
@@ -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.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 h1:4cNKDYQ1I84SXslGddlsrMhc8k4LeDVj6Ad6WRjiHuU=
github.com/go-sql-driver/mysql v1.9.2/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= 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 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= 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= 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 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= 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.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.45.0 h1:FMb1nTbH5H9vF55SriQHgFw5GnNL9Jg6L25BwXKzhB0=
golang.org/x/image v0.44.0/go.mod h1:V8K3KE9KKKE+pLpQDOeN18w9oacNSvy1tDOirTu4xtY= golang.org/x/image v0.45.0/go.mod h1:n62x/7RqlwXDvGsSU4u6IUTUf6KghUZ9Bt7cG/T9Fx4=
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= 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.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 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= 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/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.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.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.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= 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.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.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= 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 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= 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(up202608120001, down202608120001, "202608120001_oidc_login.go")
pbmigrations.Register(up202608140001, down202608140001, "202608140001_record_owner.go") pbmigrations.Register(up202608140001, down202608140001, "202608140001_record_owner.go")
pbmigrations.Register(up202608140002, down202608140002, "202608140002_record_centric_model.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 } func ptr[T any](v T) *T { return &v }
+46 -16
View File
@@ -42,9 +42,7 @@ func newRecordOf(t *testing.T, app core.App, ownerID string) *entity.AudioRecord
State: entity.StateUploaded, State: entity.StateUploaded,
StateEnteredAt: clock.Now(), StateEnteredAt: clock.Now(),
Source: entity.SourceApi, Source: entity.SourceApi,
} OwnerID: ownerID,
if ownerID != "" {
record.OwnerID = &ownerID
} }
require.NoError(t, NewAudioRecordRepository(app).Create(record)) require.NoError(t, NewAudioRecordRepository(app).Create(record))
return record return record
@@ -65,8 +63,7 @@ func TestGuardOwnerDeletion(t *testing.T) {
after, err := NewAudioRecordRepository(app).Get(record.Id) after, err := NewAudioRecordRepository(app).Get(record.Id)
require.NoError(t, err, "запись на месте") 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), "пустая учётная запись удаляется") require.NoError(t, app.Delete(account), "пустая учётная запись удаляется")
} }
// Умолчания у колонки владельца нет: запись, чей владелец не назван, не // Колонка владельца пустого значения не принимает и умолчания не имеет:
// достаётся никому по недосмотру схемы. // ничьей записи в хранилище не бывает, и завести её нечем — ни приёмом, ни
func TestOwnerColumnHasNoDefault(t *testing.T) { // конвейером, ни рукой в панели.
func TestOwnerColumnRefusesEmptyValue(t *testing.T) {
app := newTestStorage(t) app := newTestStorage(t)
records, err := app.FindCollectionByNameOrId(migrations.RecordsCollection) for _, name := range []string{migrations.RecordsCollection, migrations.FilesCollection} {
require.NoError(t, err) t.Run(name, func(t *testing.T) {
collection, err := app.FindCollectionByNameOrId(name)
require.NoError(t, err)
field := records.Fields.GetByName("owner") field := collection.Fields.GetByName("owner")
require.NotNil(t, field, "колонка владельца заведена") require.NotNil(t, field, "колонка владельца заведена")
relation, ok := field.(*core.RelationField) relation, ok := field.(*core.RelationField)
require.True(t, ok, "владелец — связь с учётной записью, а не строка") require.True(t, ok, "владелец — связь с учётной записью, а не строка")
assert.False(t, relation.Required, "пустое значение допустимо ради записей бота") assert.True(t, relation.Required, "пустое значение колонка не принимает")
assert.False(t, relation.CascadeDelete, "удаление учётной записи не уносит архив следом") 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 { func haltedRecord(t *testing.T, app core.App) *entity.AudioRecord {
t.Helper() t.Helper()
record := newRecordOf(t, app, "") record := newRecordOf(t, app, newAccount(t, app).Id)
record.MoveToState(entity.StateNormalized) record.MoveToState(entity.StateNormalized)
record.Attempts = 4 record.Attempts = 4
record.AcquisitionID = ptrOf("прежний-захват") record.AcquisitionID = ptrOf("прежний-захват")
@@ -143,7 +143,7 @@ func TestPanelResumeIsLogged(t *testing.T) {
func TestPanelStateEditClearsGuards(t *testing.T) { func TestPanelStateEditClearsGuards(t *testing.T) {
app := newPanelStorage(t) app := newPanelStorage(t)
record := newRecordOf(t, app, "") record := newRecordOf(t, app, newAccount(t, app).Id)
record.Attempts = 4 record.Attempts = 4
record.AcquisitionID = ptrOf("прежний-захват") record.AcquisitionID = ptrOf("прежний-захват")
record.AcquireExpiresAt = ptrOf(clock.Now().Add(8 * time.Hour)) record.AcquireExpiresAt = ptrOf(clock.Now().Add(8 * time.Hour))
@@ -162,7 +162,7 @@ func TestPanelStateEditClearsGuards(t *testing.T) {
func TestPanelKeepsGuardsOnUnrelatedEdit(t *testing.T) { func TestPanelKeepsGuardsOnUnrelatedEdit(t *testing.T) {
app := newPanelStorage(t) app := newPanelStorage(t)
record := newRecordOf(t, app, "") record := newRecordOf(t, app, newAccount(t, app).Id)
record.Attempts = 3 record.Attempts = 3
record.AcquisitionID = ptrOf("живой-захват") record.AcquisitionID = ptrOf("живой-захват")
require.NoError(t, NewAudioRecordRepository(app).Save(record, "")) require.NoError(t, NewAudioRecordRepository(app).Save(record, ""))
@@ -181,7 +181,7 @@ func TestAcquireCarriesStageDeadline(t *testing.T) {
app := newTestStorage(t) app := newTestStorage(t)
repo := NewAudioRecordRepository(app) repo := NewAudioRecordRepository(app)
record := newRecordOf(t, app, "") record := newRecordOf(t, app, newAccount(t, app).Id)
acquired, err := repo.FindAndAcquire(entity.WorkingStages()) acquired, err := repo.FindAndAcquire(entity.WorkingStages())
require.NoError(t, err) require.NoError(t, err)
@@ -205,7 +205,7 @@ func TestAcquireHandsRecordToExactlyOne(t *testing.T) {
app := newTestStorage(t) app := newTestStorage(t)
repo := NewAudioRecordRepository(app) repo := NewAudioRecordRepository(app)
newRecordOf(t, app, "") newRecordOf(t, app, newAccount(t, app).Id)
first, err := repo.FindAndAcquire(entity.WorkingStages()) first, err := repo.FindAndAcquire(entity.WorkingStages())
require.NoError(t, err, "первому запись досталась") require.NoError(t, err, "первому запись досталась")
@@ -223,7 +223,7 @@ func TestRottenAcquisitionIsHandedOutAgain(t *testing.T) {
app := newTestStorage(t) app := newTestStorage(t)
repo := NewAudioRecordRepository(app) repo := NewAudioRecordRepository(app)
record := newRecordOf(t, app, "") record := newRecordOf(t, app, newAccount(t, app).Id)
first, err := repo.FindAndAcquire(entity.WorkingStages()) first, err := repo.FindAndAcquire(entity.WorkingStages())
require.NoError(t, err) require.NoError(t, err)
@@ -50,18 +50,16 @@ func applyToRecord(record *core.Record, r *entity.AudioRecord) {
// Владелец кладётся только здесь, при заведении. В applyOwnedByPipeline его // Владелец кладётся только здесь, при заведении. В applyOwnedByPipeline его
// нет намеренно: конвейер владельца не назначает и не меняет, а снимок шага, // нет намеренно: конвейер владельца не назначает и не меняет, а снимок шага,
// записанный поверх, стёр бы его молча. // записанный поверх, стёр бы его молча.
record.Set("owner", derefString(r.OwnerID)) record.Set("owner", r.OwnerID)
record.Set("source", r.Source) record.Set("source", r.Source)
record.Set("title", derefString(r.Title)) record.Set("title", derefString(r.Title))
record.Set("brief", derefString(r.Brief)) 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 { func recordToAudioRecord(record *core.Record) *entity.AudioRecord {
return &entity.AudioRecord{ return &entity.AudioRecord{
Id: record.Id, Id: record.Id,
OwnerID: nilIfEmpty(record.GetString("owner")), OwnerID: record.GetString("owner"),
Source: record.GetString("source"), Source: record.GetString("source"),
Title: nilIfEmpty(record.GetString("title")), Title: nilIfEmpty(record.GetString("title")),
Brief: nilIfEmpty(record.GetString("brief")), Brief: nilIfEmpty(record.GetString("brief")),
@@ -80,8 +78,6 @@ func recordToAudioRecord(record *core.Record) *entity.AudioRecord {
LiteraryTextID: nilIfEmpty(record.GetString("literary_text")), LiteraryTextID: nilIfEmpty(record.GetString("literary_text")),
StructureID: nilIfEmpty(record.GetString("structure")), StructureID: nilIfEmpty(record.GetString("structure")),
RecognitionID: nilIfEmpty(record.GetString("recognition")), 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(), CreatedAt: record.GetDateTime("created").Time(),
UpdatedAt: record.GetDateTime("updated").Time(), UpdatedAt: record.GetDateTime("updated").Time(),
} }
@@ -94,20 +90,6 @@ func derefString(v *string) string {
return *v 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 отдаёт пустое значение вместо нулевой даты: пустая колонка даты в // dateOrEmpty отдаёт пустое значение вместо нулевой даты: пустая колонка даты в
// хранилище это пустая строка, и она же значит «времени нет». // хранилище это пустая строка, и она же значит «времени нет».
func dateOrEmpty(v *time.Time) any { func dateOrEmpty(v *time.Time) any {
@@ -135,17 +117,3 @@ func timeOrNil(v types.DateTime) *time.Time {
t := v.Time() t := v.Time()
return &t 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 { func (repo *AudioRecordRepository) Save(r *entity.AudioRecord, holder string) error {
return repo.app.RunInTransaction(func(txApp core.App) error { return repo.app.RunInTransaction(func(txApp core.App) error {
record, err := txApp.FindRecordById(migrations.RecordsCollection, r.Id) 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) { func (repo *AudioRecordRepository) GetByID(id, ownerID string) (*entity.AudioRecord, error) {
if ownerID == "" { if ownerID == "" {
return nil, &contract.JobNotFoundError{Message: "record not found"} return nil, &contract.JobNotFoundError{Message: "record not found"}
+20 -1
View File
@@ -26,6 +26,15 @@ func NewTextRepository(app core.App) *TextRepository {
// Замена, а не вставка: пара «запись и вид» уникальна, и повтор прерванного шага // Замена, а не вставка: пара «запись и вид» уникальна, и повтор прерванного шага
// иначе завёл бы второй комплект строк — тогда вопрос «какой текст отдавать // иначе завёл бы второй комплект строк — тогда вопрос «какой текст отдавать
// человеку» стал бы вопросом порядка записи, а не состояния. // человеку» стал бы вопросом порядка записи, а не состояния.
//
// **Пустое не кладётся поверх непустого**, и это не осторожность, а защита
// архива. Повторный опрос той же операции — обычное дело: держатель захвата
// умер, сохранение рубежа отказало, человек снял остановку в панели. Провайдер
// при этом вправе ответить пустым потоком, отказом это не считается, и
// безусловная замена стирала бы сохранённую расшифровку живого человека без
// следа и без возврата. Та же защита стоит у сырого ответа провайдера
// (`RecognitionRepository.Finish`), и разное правило у двух хранителей одного
// результата читалось бы как недосмотр.
func (repo *TextRepository) Put(recordID, kind, contents string) (*entity.Text, error) { func (repo *TextRepository) Put(recordID, kind, contents string) (*entity.Text, error) {
collection, err := findCollection(repo.app, migrations.TextsCollection) collection, err := findCollection(repo.app, migrations.TextsCollection)
if err != nil { 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) 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) record.Set("contents", contents)
if err := repo.app.Save(record); err != nil { if err := repo.app.Save(record); err != nil {
@@ -106,7 +120,12 @@ func (repo *StructureRepository) Put(recordID string, version int, replicas []en
) )
switch { switch {
case err == nil: case err == nil:
// Строка есть — заменяем содержимое. // Строка есть — заменяем содержимое. Пустой перечень реплик поверх
// непустого не кладётся по тому же доводу, что и у текста: повторный
// опрос с пустым ответом провайдера стирал бы разбор живой записи.
if len(replicas) == 0 && len(record.GetString("contents")) > len("[]") {
return repo.GetByID(record.Id)
}
case errors.Is(err, sql.ErrNoRows): case errors.Is(err, sql.ErrNoRows):
record = core.NewRecord(collection) record = core.NewRecord(collection)
record.Set("record", recordID) record.Set("record", recordID)
-27
View File
@@ -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
}
-37
View File
@@ -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, "отправитель говорит с тем же клиентом, что и транспорт")
}
-134
View File
@@ -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))
}
-172
View File
@@ -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",
"строка библиотеки прошла мимо нашего журнала: логгер не подменён")
}
-66
View File
@@ -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
}
-62
View File
@@ -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
}
-125
View File
@@ -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)
}
}
})
}
}
+1 -2
View File
@@ -28,7 +28,7 @@ const (
) )
// Ядро — `internal/service`: оно знает только интерфейсы `internal/contract`, а // Ядро — `internal/service`: оно знает только интерфейсы `internal/contract`, а
// ffmpeg, Yandex, Telegram и хранилище подставляются в `main.go` // ffmpeg, Yandex и хранилище подставляются в `main.go`
// (docs/architecture.md, «Принципы»). // (docs/architecture.md, «Принципы»).
const core = "internal/service" const core = "internal/service"
@@ -36,7 +36,6 @@ const core = "internal/service"
// одном из них: иначе второй начинает зависеть от первого и тащит его целиком. // одном из них: иначе второй начинает зависеть от первого и тащит его целиком.
var transports = map[string]bool{ var transports = map[string]bool{
"internal/controller/http": true, "internal/controller/http": true,
"internal/controller/tg": true,
"internal/controller/worker": true, "internal/controller/worker": true,
} }
+4 -47
View File
@@ -19,7 +19,6 @@ type Config struct {
Storage StorageConfig `toml:"storage"` Storage StorageConfig `toml:"storage"`
Pipeline PipelineConfig `toml:"pipeline"` Pipeline PipelineConfig `toml:"pipeline"`
Yandex YandexConfig `toml:"yandex"` Yandex YandexConfig `toml:"yandex"`
Telegram TelegramConfig `toml:"telegram"`
Auth AuthConfig `toml:"auth"` Auth AuthConfig `toml:"auth"`
} }
@@ -61,10 +60,9 @@ func (c PipelineConfig) Validate() error {
} }
type ServerConfig struct { type ServerConfig struct {
Port int `toml:"port"` Port int `toml:"port"`
ShutdownTimeout int `toml:"shutdown_timeout"` ShutdownTimeout int `toml:"shutdown_timeout"`
ForceShutdownTimeout int `toml:"force_shutdown_timeout"` ForceShutdownTimeout int `toml:"force_shutdown_timeout"`
UsersWhiteList []string `toml:"users_while_list"`
} }
// StorageConfig — единственный каталог данных: под ним лежат и база, и файлы // StorageConfig — единственный каталог данных: под ним лежат и база, и файлы
@@ -83,33 +81,6 @@ type YandexConfig struct {
ObjStorageEndpoint string `toml:"object_storage_endpoint"` 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. Адреса, идентификатор // AuthConfig — вход через внешнего провайдера OIDC. Адреса, идентификатор
// клиента и секрет приезжают сюда и приводятся к настройкам коллекции // клиента и секрет приезжают сюда и приводятся к настройкам коллекции
// пользователей при каждом подъёме: применённый шаг схемы не переписывается, и // пользователей при каждом подъёме: применённый шаг схемы не переписывается, и
@@ -202,11 +173,6 @@ func defaultConfig() *Config {
ObjStorageRegion: "ru-central1", ObjStorageRegion: "ru-central1",
ObjStorageEndpoint: "https://storage.yandexcloud.net/", ObjStorageEndpoint: "https://storage.yandexcloud.net/",
}, },
// Умолчания у `Enabled` здесь нет намеренно — причина у поля.
Telegram: TelegramConfig{
BotToken: "",
UpdateTimeout: 10,
},
Auth: AuthConfig{ Auth: AuthConfig{
SecureCookie: true, SecureCookie: true,
}, },
@@ -223,19 +189,10 @@ func LoadConfig(path string) (*Config, error) {
config := defaultConfig() config := defaultConfig()
// Load configuration from file // Load configuration from file
meta, err := toml.DecodeFile(path, &config) if _, err := toml.DecodeFile(path, &config); err != nil {
if err != nil {
return nil, decodeError(path, err) return nil, decodeError(path, err)
} }
// Признак включения входа Telegram обязателен: умолчания у него нет, и
// отличить «не задан» от «задан ложным» умеет только разбор — нулевое
// значение `bool` в структуре у обоих одинаковое. Отсюда и `meta`: наружу
// она не отдаётся, приговор выносится здесь.
if !meta.IsDefined("telegram", "enabled") {
return nil, errors.New("telegram: не задан ключ enabled; он объявляет, нужен ли сервису вход Telegram")
}
return config, nil return config, nil
} }
+12 -107
View File
@@ -1,7 +1,6 @@
package config package config
import ( import (
"fmt"
"os" "os"
"path/filepath" "path/filepath"
"strings" "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 { func writeConfig(t *testing.T, body string) string {
t.Helper() t.Helper()
@@ -173,49 +110,17 @@ func writeConfig(t *testing.T, body string) string {
} }
const validConfigBody = ` const validConfigBody = `
[telegram] [storage]
enabled = false data_dir = "data"
bot_token = ""
` `
// Признак обязателен: файл без него негоден. Умолчание было бы угаданным
// намерением, а отличить «не задан» от «задан ложным» умеет только разбор —
// нулевое значение 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) { 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) _, err := LoadConfig(path)
if err == nil { if err == nil {
@@ -223,7 +128,7 @@ func TestLoadConfigMalformedSecretLineHidesValue(t *testing.T) {
} }
message := err.Error() 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) { if strings.Contains(message, part) {
t.Fatalf("значение ключа доступа уехало в отказ: %v", err) t.Fatalf("значение ключа доступа уехало в отказ: %v", err)
} }
@@ -232,7 +137,7 @@ func TestLoadConfigMalformedSecretLineHidesValue(t *testing.T) {
if !strings.Contains(message, "строке 3") { if !strings.Contains(message, "строке 3") {
t.Fatalf("номер строки не назван, чинить нечего: %v", err) t.Fatalf("номер строки не назван, чинить нечего: %v", err)
} }
if !strings.Contains(message, "bot_token") { if !strings.Contains(message, "speech_kit_api_key") {
t.Fatalf("ключ не назван, чинить нечего: %v", err) t.Fatalf("ключ не назван, чинить нечего: %v", err)
} }
} }
@@ -256,9 +161,9 @@ func TestLoadConfigTypeMismatchKeepsDiagnostics(t *testing.T) {
// уязвимая: именно в ней будущая правка легче всего протащит текст библиотеки // уязвимая: именно в ней будущая правка легче всего протащит текст библиотеки
// обратно. // обратно.
func TestLoadConfigMalformedBeforeAnyKeyHidesValue(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) _, err := LoadConfig(path)
if err == nil { if err == nil {
@@ -266,7 +171,7 @@ func TestLoadConfigMalformedBeforeAnyKeyHidesValue(t *testing.T) {
} }
message := err.Error() 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) { if strings.Contains(message, part) {
t.Fatalf("значение ключа доступа уехало в отказ: %v", err) t.Fatalf("значение ключа доступа уехало в отказ: %v", err)
} }
@@ -284,8 +189,8 @@ workers = 7
own_work_limit_minutes = 90 own_work_limit_minutes = 90
foreign_work_limit_minutes = 720 foreign_work_limit_minutes = 720
[telegram] [storage]
enabled = false data_dir = "data"
`) `)
cfg, err := LoadConfig(path) cfg, err := LoadConfig(path)
@@ -309,7 +214,7 @@ enabled = false
// Умолчания есть у всех трёх чисел: файл без секции конвейера годен, и сервис // Умолчания есть у всех трёх чисел: файл без секции конвейера годен, и сервис
// поднимается с рабочими значениями. // поднимается с рабочими значениями.
func TestPipelineSettingsHaveDefaults(t *testing.T) { func TestPipelineSettingsHaveDefaults(t *testing.T) {
path := writeConfig(t, "[telegram]\nenabled = false\n") path := writeConfig(t, "[storage]\ndata_dir = \"data\"\n")
cfg, err := LoadConfig(path) cfg, err := LoadConfig(path)
if err != nil { if err != nil {
-4
View File
@@ -58,7 +58,3 @@ type AudioRecognizer interface {
// обращаясь к нему. По нему архив пересчитывается без единого рубля. // обращаясь к нему. По нему архив пересчитывается без единого рубля.
Parse(raw []byte) (*entity.RecognitionOutcome, error) Parse(raw []byte) (*entity.RecognitionOutcome, error)
} }
type TelegramMessageSender interface {
Send(text string, chatId int64, replyToMessageId *int) error
}
+3 -12
View File
@@ -5,15 +5,6 @@ import (
"fmt" "fmt"
) )
// ErrDeliveryChannelDown — канал, которым отвечают отправителю, не поднят.
// Отдаётся отправителем-заглушкой, которого получает ядро, когда вход не
// настроен.
//
// Значение сентинельное, а не тип: соседям по ряду есть что нести — состояние,
// идентификатор задачи, — а этому нечего. Заглушка не знает ни задачи, ни чата,
// и запись о недоставке делает шаг, у которого задача под рукой.
var ErrDeliveryChannelDown = errors.New("delivery channel is down")
// ErrOwnerRequired — приём по HTTP дошёл до заведения задачи, а владельца ему не // ErrOwnerRequired — приём по HTTP дошёл до заведения задачи, а владельца ему не
// назвали. Значение сентинельное: нести отказу нечего, а имя учётной записи в // назвали. Значение сентинельное: нести отказу нечего, а имя учётной записи в
// него не кладётся никогда. // него не кладётся никогда.
@@ -29,9 +20,9 @@ func (e *JobNotFoundError) Error() string {
} }
// LostAcquisitionError — захват задачи за время работы шага достался другому. // LostAcquisitionError — захват задачи за время работы шага достался другому.
// Шаг, получивший его, завершается без записи результата и без ответа // Шаг, получивший его, завершается без записи результата: иначе два воркера
// отправителю: иначе два воркера пишут в одну задачу по очереди, а отправитель // пишут в одну запись по очереди, портя её результат, и счётчик отказов
// получает два ответа на одну запись. // сбрасывает тот, кто уже не владелец.
type LostAcquisitionError struct { type LostAcquisitionError struct {
JobID string JobID string
} }
+5 -5
View File
@@ -44,11 +44,11 @@ type FileRepository interface {
// файле. Имя задаёт сервис: умолчание хранилища, строящее его из имени // файле. Имя задаёт сервис: умолчание хранилища, строящее его из имени
// отправителя, не применяется. // отправителя, не применяется.
// //
// ownerID — владелец записи, которой файл принадлежит; пустой значит «файл // ownerID — владелец записи, которой файл принадлежит, и он обязателен:
// без владельца», и таков всякий файл записи, принятой ботом. Владелец // колонка владельца пустого значения не принимает, пустой отвергается
// лежит своей колонкой, а не выводится через запись: файл переживает свою // схемой. Владелец лежит своей колонкой, а не выводится через запись: файл
// запись — шаг заводит его до сохранения, и потерянный захват оставляет файл // переживает свою запись — шаг заводит его до сохранения, и потерянный
// с владельцем и без ссылки. // захват оставляет файл с владельцем и без ссылки.
Create(name string, work WorkFile, meta FileMeta, ownerID string) (*entity.File, error) Create(name string, work WorkFile, meta FileMeta, ownerID string) (*entity.File, error)
GetByID(id string) (*entity.File, error) GetByID(id string) (*entity.File, error)
// Open отдаёт содержимое хранимого файла потоком. // Open отдаёт содержимое хранимого файла потоком.
@@ -4,17 +4,13 @@ import (
"encoding/json" "encoding/json"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strings"
"testing" "testing"
"github.com/pocketbase/pocketbase/core" "github.com/pocketbase/pocketbase/core"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "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/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" "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") 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) { func TestCreateTranscribeJob_OwnerIsSession(t *testing.T) {
+1 -9
View File
@@ -60,13 +60,6 @@ type stubConverter struct{}
func (c *stubConverter) Convert(context.Context, string, string) error { return nil } 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 — источник метаданных, который читает любую запись. // readableMetaViewer — источник метаданных, который читает любую запись.
func readableMetaViewer() *stubMetaViewer { func readableMetaViewer() *stubMetaViewer {
return &stubMetaViewer{seconds: 42} return &stubMetaViewer{seconds: 42}
@@ -192,7 +185,6 @@ func setupTestEnv(t *testing.T, metaviewer contract.AudioMetaViewer) *testEnv {
metaviewer, metaviewer,
&stubConverter{}, &stubConverter{},
&recognizer.MemoryAudioRecognizer{}, &recognizer.MemoryAudioRecognizer{},
&TestTgSender{},
entity.StuckLimits{Own: time.Hour, Foreign: 24 * time.Hour}, entity.StuckLimits{Own: time.Hour, Foreign: 24 * time.Hour},
logger, logger,
) )
@@ -293,7 +285,7 @@ func jobWithFile(t *testing.T, env *testEnv) *entity.AudioRecord {
State: entity.StateUploaded, State: entity.StateUploaded,
StateEnteredAt: clock.Now(), StateEnteredAt: clock.Now(),
Source: entity.SourceApi, Source: entity.SourceApi,
OwnerID: &env.account.Id, OwnerID: env.account.Id,
OriginalFileID: &file.Id, OriginalFileID: &file.Id,
} }
require.NoError(t, env.handler.recordRepo.Create(record)) require.NoError(t, env.handler.recordRepo.Create(record))
-111
View File
@@ -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")
}
-326
View File
@@ -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
}
+10 -9
View File
@@ -34,8 +34,11 @@ const (
) )
const ( const (
SourceUnknown = "unknown" SourceUnknown = "unknown"
SourceApi = "api" SourceApi = "api"
// SourceTelegram — историческое значение. Вход Telegram убран, новых записей
// с этим источником не появляется, а константа остаётся: на неё ссылается
// применённый шаг схемы `202608140002`, а применённый шаг не переписывается.
SourceTelegram = "telegram" SourceTelegram = "telegram"
) )
@@ -47,10 +50,11 @@ const (
// `texts`, и чтение очереди её не тянет. // `texts`, и чтение очереди её не тянет.
type AudioRecord struct { type AudioRecord struct {
Id string Id string
// OwnerID — учётная запись, от имени которой запись принята. Пуст у записей // OwnerID — учётная запись, от имени которой запись принята. Обязателен:
// из Telegram: связи чата с учётной записью сервис не ведёт. Назначается // колонка владельца пустого значения не принимает, и ничьей записи в
// один раз, при приёме, и конвейером не меняется. // хранилище не бывает. Назначается один раз, при приёме, и конвейером не
OwnerID *string // меняется.
OwnerID string
Source string Source string
// Title и Brief читаются вместе со списком, сотней штук разом, и потому // Title и Brief читаются вместе со списком, сотней штук разом, и потому
@@ -91,9 +95,6 @@ type AudioRecord struct {
LiteraryTextID *string LiteraryTextID *string
RecognitionID *string RecognitionID *string
TgChatId *int64
TgReplyMessageId *int
CreatedAt time.Time CreatedAt time.Time
UpdatedAt time.Time UpdatedAt time.Time
} }
+5 -7
View File
@@ -10,13 +10,11 @@ const OtherFormatLabel = "other"
// knownFormats — закрытый перечень расширений, которые допускаются меткой. // knownFormats — закрытый перечень расширений, которые допускаются меткой.
// //
// Состав: пути, которые выдаёт Telegram (голосовое приходит как // Состав: форматы, доезжающие приёмом по HTTP, плюс собственное умолчание
// `voice/file_N.oga`, кружок — с `.mp4`), плюс форматы, доезжающие приёмом по // сервиса на случай имени без расширения. Значения `oga` и `mp4` достались от
// HTTP, плюс собственное умолчание сервиса на случай имени без расширения. // убранного входа Telegram — голосовое приходило оттуда как `voice/file_N.oga`,
// Списку, по которому бот отбирает **документы** (`isAudioDocument`), перечень // кружок с `.mp4`, — и остаются: перечень сужает **метку**, а не приём, и
// намеренно не равен: тот судит по типу содержимого и своим списком пользуется // выброшенное из него значение уронило бы прежние записи в `other`.
// лишь когда типа нет, а сюда попадает и то, что приходит другими путями.
// Сведение двух списков в один уронило бы основной вход сервиса в `other`.
var knownFormats = map[string]struct{}{ var knownFormats = map[string]struct{}{
"mp3": {}, "mp3": {},
"wav": {}, "wav": {},
+5 -14
View File
@@ -49,9 +49,11 @@ var (
[]string{"source_format", "target_format", "error"}, []string{"source_format", "target_format", "error"},
) )
// Поднят ли вход приёма. Единственный канал наблюдения, автоматизированный // Поднят ли вход приёма. Метка ставится только тому входу, который у сервиса
// у владельца: потерянный вход иначе виден только строкой журнала при // есть; вход остался один, и метки убранного здесь не появляется — ноль рядом
// старте, а проба здоровья отвечает «ok» и без него. // с ним читался бы как поломка, а вечная единица — как исправность того, чего
// нет. Различать поднятый и неподнятый признак снова станет, когда входов
// снова станет больше одного.
IntakeUpGauge = promauto.NewGaugeVec( IntakeUpGauge = promauto.NewGaugeVec(
prometheus.GaugeOpts{ prometheus.GaugeOpts{
Name: "transcriber_intake_up", Name: "transcriber_intake_up",
@@ -60,17 +62,6 @@ var (
[]string{"channel"}, []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( OutputFileSizeHistogram = promauto.NewHistogramVec(
prometheus.HistogramOpts{ prometheus.HistogramOpts{
+1 -1
View File
@@ -40,7 +40,7 @@ func (r *stubRecordRepo) FindAndAcquire([]entity.Stage) (*contract.AcquiredRecor
func serviceWithRepo(repo contract.AudioRecordRepository) *TranscribeService { func serviceWithRepo(repo contract.AudioRecordRepository) *TranscribeService {
return NewTranscribeService( return NewTranscribeService(
Repositories{Records: repo}, Repositories{Records: repo},
nil, nil, nil, nil, nil, nil, nil,
entity.StuckLimits{}, entity.StuckLimits{},
slog.New(slog.DiscardHandler), slog.New(slog.DiscardHandler),
) )
+2 -2
View File
@@ -55,7 +55,7 @@ func TestFailureIsCountedWithStageLabel(t *testing.T) {
beforeOk := stageCount(t, entity.StateUploaded, "false") beforeOk := stageCount(t, entity.StateUploaded, "false")
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.StateNormalized, readRecord(t, env, record.Id).State) 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{}) failing := newPipelineEnv(t, &okMetaViewer{}, &failingStepConverter{})
beforeErr := stageCount(t, entity.StateUploaded, "true") beforeErr := stageCount(t, entity.StateUploaded, "true")
newTelegramRecord(t, failing) newRecord(t, failing)
require.Error(t, failing.service.RunStep(t.Context())) require.Error(t, failing.service.RunStep(t.Context()))
assert.InDelta(t, beforeErr+1, stageCount(t, entity.StateUploaded, "true"), 0, assert.InDelta(t, beforeErr+1, stageCount(t, entity.StateUploaded, "true"), 0,
+10 -18
View File
@@ -12,9 +12,8 @@ import (
) )
// Выборка воркера владельцем не сужается: владелец решает, кому запись // Выборка воркера владельцем не сужается: владелец решает, кому запись
// показывать, а не кому её считать. Сужение остановило бы расшифровку записей // показывать, а не кому её считать. Сужение поставило бы записи одних людей в
// бота вовсе, а записи остальных поставило бы в зависимость от того, кто первым // зависимость от того, кто первым завёл учётную запись.
// завёл учётную запись.
func TestWorkerTakesRecordsOfEveryOwner(t *testing.T) { func TestWorkerTakesRecordsOfEveryOwner(t *testing.T) {
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{}) env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
@@ -26,8 +25,8 @@ func TestWorkerTakesRecordsOfEveryOwner(t *testing.T) {
strings.NewReader("вторая"), "two.mp3", newOwner(t, env.app)) strings.NewReader("вторая"), "two.mp3", newOwner(t, env.app))
require.NoError(t, err) 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[first.Id], "запись первого владельца досталась воркеру")
assert.True(t, taken[second.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()) _, err = env.recordRepo.FindAndAcquire(entity.WorkingStages())
var missing *contract.JobNotFoundError var missing *contract.JobNotFoundError
@@ -65,8 +64,7 @@ func TestAcquireReturnsIdentifierAndHolder(t *testing.T) {
// Колонки читаются отдельным чтением, и владелец среди них. // Колонки читаются отдельным чтением, и владелец среди них.
read := readRecord(t, env, record.Id) 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, "признак захвата записан в саму запись") assert.Equal(t, acquired.Holder, *read.AcquisitionID, "признак захвата записан в саму запись")
require.NotNil(t, read.AcquireExpiresAt, "срок протухания приехал с рубежом") require.NotNil(t, read.AcquireExpiresAt, "срок протухания приехал с рубежом")
} }
@@ -86,12 +84,12 @@ func TestPipelineStepKeepsOwner(t *testing.T) {
after := readRecord(t, env, record.Id) after := readRecord(t, env, record.Id)
require.True(t, after.IsHalted(), "шаг записал свой приговор") 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) { func TestCreateJobFromApiRequiresOwner(t *testing.T) {
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{}) 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) record, err := env.service.CreateJobFromApi(t.Context(), strings.NewReader("запись"), "one.mp3", owner)
require.NoError(t, err) require.NoError(t, err)
// Запись без владельца — принятая ботом.
orphan := newTelegramRecord(t, env)
var missing *contract.JobNotFoundError var missing *contract.JobNotFoundError
_, err = env.recordRepo.GetByID(record.Id, stranger) _, err = env.recordRepo.GetByID(record.Id, stranger)
require.ErrorAs(t, err, &missing, "чужая запись неотличима от несуществующей") require.ErrorAs(t, err, &missing, "чужая запись неотличима от несуществующей")
_, err = env.recordRepo.GetByID(orphan.Id, stranger)
require.ErrorAs(t, err, &missing, "ничья запись не достаётся никому")
_, err = env.recordRepo.GetByID(record.Id, "") _, err = env.recordRepo.GetByID(record.Id, "")
require.ErrorAs(t, err, &missing, "пустой владелец не совпадает ни с чем") require.ErrorAs(t, err, &missing, "пустой владелец не совпадает ни с чем")
+74 -52
View File
@@ -60,32 +60,12 @@ func (m *failingMetaViewer) GetInfo(context.Context, string) (*contract.AudioInf
return nil, errors.New("запись не читается") 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 { type pipelineEnv struct {
app core.App app core.App
service *TranscribeService service *TranscribeService
repos Repositories repos Repositories
recordRepo *pbrepo.AudioRecordRepository recordRepo *pbrepo.AudioRecordRepository
fileRepo *pbrepo.FileRepository fileRepo *pbrepo.FileRepository
sender *recordingSender
} }
// testLimits — пределы простоя проверок. Числа боевые; проверка застревания // testLimits — пределы простоя проверок. Числа боевые; проверка застревания
@@ -104,6 +84,19 @@ func newPipelineEnvWith(
rec contract.AudioRecognizer, rec contract.AudioRecognizer,
) *pipelineEnv { ) *pipelineEnv {
t.Helper() 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()) app, err := pbrepo.New(t.TempDir())
require.NoError(t, err) require.NoError(t, err)
@@ -128,9 +121,7 @@ func newPipelineEnvWith(
Recognitions: pbrepo.NewRecognitionRepository(app), Recognitions: pbrepo.NewRecognitionRepository(app),
Events: pbrepo.NewRecordEventRepository(app), Events: pbrepo.NewRecordEventRepository(app),
} }
sender := &recordingSender{} svc := NewTranscribeService(repos, metaviewer, converter, rec, testLimits, logger)
svc := NewTranscribeService(repos, metaviewer, converter, rec, sender, testLimits, slog.New(slog.DiscardHandler))
return &pipelineEnv{ return &pipelineEnv{
app: app, app: app,
@@ -138,15 +129,16 @@ func newPipelineEnvWith(
repos: repos, repos: repos,
recordRepo: recordRepo, recordRepo: recordRepo,
fileRepo: fileRepo, fileRepo: fileRepo,
sender: sender,
} }
} }
// newTelegramRecord заводит запись — так, как её завёл бы приём из бота. // newRecord заводит запись — так, как её заводит приём по HTTP: от имени
func newTelegramRecord(t *testing.T, env *pipelineEnv) *entity.AudioRecord { // вошедшего, потому что ничьей записи в хранилище не бывает.
func newRecord(t *testing.T, env *pipelineEnv) *entity.AudioRecord {
t.Helper() 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) require.NoError(t, err)
return record return record
} }
@@ -175,6 +167,17 @@ func enteredStateAt(t *testing.T, env *pipelineEnv, recordID string, moment time
require.NoError(t, env.app.Save(record)) 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 крутит конвейер, пока он двигает записи. Паузы опроса снимаются: они // drain крутит конвейер, пока он двигает записи. Паузы опроса снимаются: они
// проверяются отдельно, а здесь мешают дойти до конца. // проверяются отдельно, а здесь мешают дойти до конца.
func drain(t *testing.T, env *pipelineEnv, recordID string) { 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) { func TestRecordHaltsAfterAttemptLimit(t *testing.T) {
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{}) 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.Equal(t, entity.StateUploaded, after.State, "рубеж пережил остановку")
assert.Greater(t, after.Attempts, maxAttempts, "число отказов сохранено") 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()) _, err = env.recordRepo.FindAndAcquire(entity.WorkingStages())
var missing *contract.JobNotFoundError var missing *contract.JobNotFoundError
@@ -265,7 +265,7 @@ func expireAcquisition(t *testing.T, env *pipelineEnv, recordID string) {
func TestHaltedRecordResumesFromItsStage(t *testing.T) { func TestHaltedRecordResumesFromItsStage(t *testing.T) {
env := newPipelineEnv(t, &okMetaViewer{}, &okConverter{}) env := newPipelineEnv(t, &okMetaViewer{}, &okConverter{})
record := newTelegramRecord(t, env) record := newRecord(t, env)
// Доводим до рубежа приведения и останавливаем на нём. // Доводим до рубежа приведения и останавливаем на нём.
require.NoError(t, env.service.RunStep(t.Context())) require.NoError(t, env.service.RunStep(t.Context()))
@@ -302,7 +302,7 @@ func TestHaltedRecordResumesFromItsStage(t *testing.T) {
func TestBothFileLinksSurvivePipeline(t *testing.T) { func TestBothFileLinksSurvivePipeline(t *testing.T) {
env := newPipelineEnv(t, &okMetaViewer{}, &okConverter{}) env := newPipelineEnv(t, &okMetaViewer{}, &okConverter{})
record := newTelegramRecord(t, env) record := newRecord(t, env)
drain(t, env, record.Id) drain(t, env, record.Id)
after := readRecord(t, env, record.Id) after := readRecord(t, env, record.Id)
@@ -327,7 +327,7 @@ func TestBothFileLinksSurvivePipeline(t *testing.T) {
func TestOutcomeDoesNotDependOnWorkerCount(t *testing.T) { func TestOutcomeDoesNotDependOnWorkerCount(t *testing.T) {
for _, workers := range []int{1, 4} { for _, workers := range []int{1, 4} {
env := newPipelineEnv(t, &okMetaViewer{}, &okConverter{}) env := newPipelineEnv(t, &okMetaViewer{}, &okConverter{})
record := newTelegramRecord(t, env) record := newRecord(t, env)
for range 20 { for range 20 {
clearDelay(t, env, record.Id) clearDelay(t, env, record.Id)
@@ -361,7 +361,7 @@ func TestOutcomeDoesNotDependOnWorkerCount(t *testing.T) {
// Записи принимаются и не двигаются, и это режим, а не поломка. // Записи принимаются и не двигаются, и это режим, а не поломка.
func TestZeroWorkersLeaveRecordUntouched(t *testing.T) { func TestZeroWorkersLeaveRecordUntouched(t *testing.T) {
env := newPipelineEnv(t, &okMetaViewer{}, &okConverter{}) 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) { func TestStuckRecordIsHalted(t *testing.T) {
env := newPipelineEnv(t, &okMetaViewer{}, &okConverter{}) env := newPipelineEnv(t, &okMetaViewer{}, &okConverter{})
record := newTelegramRecord(t, env) record := newRecord(t, env)
enteredStateAt(t, env, record.Id, time.Now().Add(-2*testLimits.Own)) enteredStateAt(t, env, record.Id, time.Now().Add(-2*testLimits.Own))
err := env.service.RunStep(t.Context()) 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.HaltReasonStuck, *after.HaltReason, "причина названа")
assert.Equal(t, entity.StateUploaded, after.State, "рубеж сохранён") 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) { func TestDoneRecordIsNeverStuck(t *testing.T) {
env := newPipelineEnv(t, &okMetaViewer{}, &okConverter{}) env := newPipelineEnv(t, &okMetaViewer{}, &okConverter{})
record := newTelegramRecord(t, env) record := newRecord(t, env)
drain(t, env, record.Id) drain(t, env, record.Id)
enteredStateAt(t, env, record.Id, time.Now().Add(-10*24*time.Hour)) 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) { func TestOnlyHolderWritesResult(t *testing.T) {
env := newPipelineEnv(t, &okMetaViewer{}, &okConverter{}) env := newPipelineEnv(t, &okMetaViewer{}, &okConverter{})
record := newTelegramRecord(t, env) record := newRecord(t, env)
first, err := env.recordRepo.FindAndAcquire(entity.WorkingStages()) first, err := env.recordRepo.FindAndAcquire(entity.WorkingStages())
require.NoError(t, err) require.NoError(t, err)
@@ -457,12 +455,12 @@ func TestOnlyHolderWritesResult(t *testing.T) {
after := readRecord(t, env, record.Id) after := readRecord(t, env, record.Id)
assert.Equal(t, entity.StateUploaded, after.State, "рубеж не сдвинут потерявшим захват") assert.Equal(t, entity.StateUploaded, after.State, "рубеж не сдвинут потерявшим захват")
assert.Empty(t, env.sender.sent(), "и отправителю от него ничего не ушло")
} }
// Критерий приёмки 7. Всякий способ вывести запись из работы сообщает // Критерий приёмки 7. Всякий способ вывести запись из работы оставляет причину
// отправителю: причин остановки больше одной, и обязанность у них общая. // остановки: причин больше одной, и обязанность у них общая. Отправитель узнаёт
func TestEveryHaltReasonNotifiesSender(t *testing.T) { // исход опросом готовности, а владелец сервиса — журналом событий записи.
func TestEveryHaltReasonRecordsItsCause(t *testing.T) {
reasons := []struct { reasons := []struct {
name string name string
halt func(t *testing.T, env *pipelineEnv, recordID 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) 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 { for _, reason := range reasons {
t.Run(reason.name, func(t *testing.T) { t.Run(reason.name, func(t *testing.T) {
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{}) env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
record := newTelegramRecord(t, env) record := newRecord(t, env)
reason.halt(t, env, record.Id) reason.halt(t, env, record.Id)
after := readRecord(t, env, record.Id) after := readRecord(t, env, record.Id)
require.True(t, after.IsHalted(), "запись остановлена") 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) { func TestRepeatedStoreKeepsSingleText(t *testing.T) {
env := newPipelineEnv(t, &okMetaViewer{}, &okConverter{}) env := newPipelineEnv(t, &okMetaViewer{}, &okConverter{})
record := newTelegramRecord(t, env) record := newRecord(t, env)
first, err := env.repos.Texts.Put(record.Id, entity.TextKindTranscript, "первый разбор") first, err := env.repos.Texts.Put(record.Id, entity.TextKindTranscript, "первый разбор")
require.NoError(t, err) require.NoError(t, err)
@@ -535,7 +555,7 @@ func TestRetryDelayGrowsAndCaps(t *testing.T) {
func TestFailedStepSchedulesRetryWithGrowingDelay(t *testing.T) { func TestFailedStepSchedulesRetryWithGrowingDelay(t *testing.T) {
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{}) 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 := core.NewRecord(files)
empty.Set("location", entity.LocationLocal) empty.Set("location", entity.LocationLocal)
empty.Set("size", 1) empty.Set("size", 1)
// Владелец обязателен и у файла: схема ничьих не принимает.
empty.Set("owner", record.OwnerID)
require.NoError(t, env.app.Save(empty)) require.NoError(t, env.app.Save(empty))
stored, err := env.app.FindRecordById(migrations.RecordsCollection, record.Id) stored, err := env.app.FindRecordById(migrations.RecordsCollection, record.Id)
@@ -606,7 +628,7 @@ func TestWorkFilesRemovedAfterConversionFailure(t *testing.T) {
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{}) env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
newTelegramRecord(t, env) newRecord(t, env)
leftovers, err := filepath.Glob(filepath.Join(tempDir, "transcriber-*")) leftovers, err := filepath.Glob(filepath.Join(tempDir, "transcriber-*"))
require.NoError(t, err) require.NoError(t, err)
@@ -624,7 +646,7 @@ func TestWorkFilesRemovedAfterConversionFailure(t *testing.T) {
func TestRecordNeverPointsToMissingFile(t *testing.T) { func TestRecordNeverPointsToMissingFile(t *testing.T) {
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{}) env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
record := newTelegramRecord(t, env) record := newRecord(t, env)
require.NoError(t, env.service.RunStep(t.Context())) require.NoError(t, env.service.RunStep(t.Context()))
@@ -714,7 +736,7 @@ func TestHaltIsCountedAsFailureAndLoggedOnce(t *testing.T) {
beforeOk := stageCount(t, entity.StateUploaded, "false") beforeOk := stageCount(t, entity.StateUploaded, "false")
beforeErr := stageCount(t, entity.StateUploaded, "true") beforeErr := stageCount(t, entity.StateUploaded, "true")
record := newTelegramRecord(t, env) record := newRecord(t, env)
require.NoError(t, env.service.RunStep(t.Context())) require.NoError(t, env.service.RunStep(t.Context()))
after := readRecord(t, env, record.Id) after := readRecord(t, env, record.Id)
@@ -739,7 +761,7 @@ func TestPostponeWritesNoEvent(t *testing.T) {
rec.inProgress.Store(5) rec.inProgress.Store(5)
env := newPipelineEnvWith(t, &okMetaViewer{}, &okConverter{}, rec) 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.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) require.Equal(t, entity.StateSubmitted, readRecord(t, env, record.Id).State)
+90 -3
View File
@@ -1,10 +1,12 @@
package service package service
import ( import (
"bytes"
"context" "context"
"encoding/json" "encoding/json"
"errors" "errors"
"io" "io"
"log/slog"
"sync/atomic" "sync/atomic"
"testing" "testing"
@@ -101,7 +103,7 @@ func TestStructureIsBuiltFromStoredPayload(t *testing.T) {
rec := &countingRecognizer{} rec := &countingRecognizer{}
env := newPipelineEnvWith(t, &okMetaViewer{}, &okConverter{}, rec) env := newPipelineEnvWith(t, &okMetaViewer{}, &okConverter{}, rec)
record := newTelegramRecord(t, env) record := newRecord(t, env)
drain(t, env, record.Id) drain(t, env, record.Id)
after := readRecord(t, env, record.Id) after := readRecord(t, env, record.Id)
@@ -141,7 +143,7 @@ func TestPaidWorkIsNotRepeated(t *testing.T) {
rec := &countingRecognizer{} rec := &countingRecognizer{}
env := newPipelineEnvWith(t, &okMetaViewer{}, &okConverter{}, rec) 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()))
@@ -171,7 +173,7 @@ func TestPollingPostponesWithoutSpendingAttempts(t *testing.T) {
rec.inProgress.Store(3) rec.inProgress.Store(3)
env := newPipelineEnvWith(t, &okMetaViewer{}, &okConverter{}, rec) 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.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, "все три прогона отложили работу") 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,
"пустой ответ провайдера не стирает сохранённую расшифровку")
}
+2 -3
View File
@@ -37,7 +37,7 @@ func TestShutdownDuringConversionKeepsRecordRetryable(t *testing.T) {
converter := &killedConverter{cancel: cancel} converter := &killedConverter{cancel: cancel}
env := newPipelineEnv(t, &okMetaViewer{}, converter) env := newPipelineEnv(t, &okMetaViewer{}, converter)
record := newTelegramRecord(t, env) record := newRecord(t, env)
err := env.service.RunStep(ctx) err := env.service.RunStep(ctx)
@@ -51,14 +51,13 @@ func TestShutdownDuringConversionKeepsRecordRetryable(t *testing.T) {
assert.Nil(t, after.AcquisitionID, "захват снят: запись возьмёт следующий прогон") assert.Nil(t, after.AcquisitionID, "захват снят: запись возьмёт следующий прогон")
assert.Equal(t, 0, after.Attempts, "остановка отказа не тратит") assert.Equal(t, 0, after.Attempts, "остановка отказа не тратит")
assert.Nil(t, after.ErrorText) assert.Nil(t, after.ErrorText)
assert.Empty(t, env.sender.sent(), "отправителю о несуществующем сбое не сообщают")
} }
// Запись, которую шаг не успел взять, потому что нас уже остановили, остаётся // Запись, которую шаг не успел взять, потому что нас уже остановили, остаётся
// нетронутой: захват не случился, отказ не потрачен. // нетронутой: захват не случился, отказ не потрачен.
func TestShutdownBeforeStepLeavesRecordUntouched(t *testing.T) { func TestShutdownBeforeStepLeavesRecordUntouched(t *testing.T) {
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{}) env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
record := newTelegramRecord(t, env) record := newRecord(t, env)
ctx, cancel := context.WithCancel(t.Context()) ctx, cancel := context.WithCancel(t.Context())
cancel() cancel()
+45 -139
View File
@@ -66,7 +66,6 @@ type TranscribeService struct {
metaviewer contract.AudioMetaViewer metaviewer contract.AudioMetaViewer
converter contract.AudioFileConverter converter contract.AudioFileConverter
recognizer contract.AudioRecognizer recognizer contract.AudioRecognizer
tgSender contract.TelegramMessageSender
limits entity.StuckLimits limits entity.StuckLimits
logger *slog.Logger logger *slog.Logger
} }
@@ -76,7 +75,6 @@ func NewTranscribeService(
metaviewer contract.AudioMetaViewer, metaviewer contract.AudioMetaViewer,
converter contract.AudioFileConverter, converter contract.AudioFileConverter,
recognizer contract.AudioRecognizer, recognizer contract.AudioRecognizer,
tgSender contract.TelegramMessageSender,
limits entity.StuckLimits, limits entity.StuckLimits,
logger *slog.Logger, logger *slog.Logger,
) *TranscribeService { ) *TranscribeService {
@@ -88,7 +86,6 @@ func NewTranscribeService(
metaviewer: metaviewer, metaviewer: metaviewer,
converter: converter, converter: converter,
recognizer: recognizer, recognizer: recognizer,
tgSender: tgSender,
limits: limits, limits: limits,
logger: logger, 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) { // CreateJobFromApi заводит запись от имени вошедшего. Владелец обязателен, и
record := &entity.AudioRecord{ // обязательность эту держит схема хранилища: колонка владельца пустого значения
State: entity.StateUploaded, // не принимает. Отказ стоит и здесь, раньше схемы, потому что отвечает
Source: entity.SourceTelegram, // отправителю понятной ошибкой до того, как запись ляжет в хранилище: схема
TgChatId: &chatId, // отказала бы уже после укладки файла, а уборки файлов сервис не умеет.
TgReplyMessageId: &replyMsgId,
}
return s.createRecord(ctx, record, file, fileName)
}
// CreateJobFromApi заводит запись от имени вошедшего. Владелец обязателен:
// пустой отвергается здесь, потому что колонка владельца допускает пустое
// значение ради записей бота, и приём по HTTP — то место, где обязательность
// держится.
// //
// Отказ этот — последний рубеж, а не первый: предъявителя без учётной записи // Первый рубеж при этом ещё раньше: предъявителя без учётной записи пользователя
// пользователя транспорт отвергает раньше, до чтения тела. Здесь он остаётся на // транспорт отвергает до чтения тела.
// случай нового вызывающего, который такой проверки не поставит.
func (s *TranscribeService) CreateJobFromApi(ctx context.Context, file io.Reader, fileName, ownerID string) (*entity.AudioRecord, error) { func (s *TranscribeService) CreateJobFromApi(ctx context.Context, file io.Reader, fileName, ownerID string) (*entity.AudioRecord, error) {
if ownerID == "" { if ownerID == "" {
s.logger.Error("Refusing to create record without owner") 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{ record := &entity.AudioRecord{
State: entity.StateUploaded, State: entity.StateUploaded,
Source: entity.SourceApi, Source: entity.SourceApi,
OwnerID: &ownerID, OwnerID: ownerID,
} }
return s.createRecord(ctx, record, file, fileName) 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, 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 { if err != nil {
s.logger.Error("Failed to create file record", "error", err, "file_ext", ext) s.logger.Error("Failed to create file record", "error", err, "file_ext", ext)
return nil, err return nil, err
@@ -253,8 +239,8 @@ func (s *TranscribeService) createRecord(ctx context.Context, r *entity.AudioRec
// //
// Контекст доходит до шага, а через него — до внешнего собеседника: остановка // Контекст доходит до шага, а через него — до внешнего собеседника: остановка
// сервиса убивает `ffmpeg` и обрывает запрос к распознаванию. Прерванный шаг // сервиса убивает `ffmpeg` и обрывает запрос к распознаванию. Прерванный шаг
// приговора не выносит: запись остаётся пригодной к повтору, отказа не тратит и // приговора не выносит: запись остаётся пригодной к повтору и отказа не
// отправителю о несуществующем сбое не сообщает. // тратит.
func (s *TranscribeService) RunStep(ctx context.Context) error { 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.logger.Error("No step declared for state", "record_id", record.Id, "state", record.State)
s.halt(record, holder, record.State, entity.HaltReasonStepFailed, 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} 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", s.logger.Error("Record exhausted its attempts",
"record_id", record.Id, "state", record.State, "attempts", record.Attempts) "record_id", record.Id, "state", record.State, "attempts", record.Attempts)
s.halt(record, acquired.Holder, record.State, entity.HaltReasonAttempts, 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} 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, "record_id", record.Id, "state", record.State,
"state_entered_at", record.StateEnteredAt) "state_entered_at", record.StateEnteredAt)
s.halt(record, acquired.Holder, record.State, entity.HaltReasonStuck, 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} 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 { if r.OriginalFileID == nil {
s.logger.Error("Record has no original file", "record_id", r.Id) 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) 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", s.logger.Error("File conversion failed",
"error", err, "record_id", r.Id, "duration", conversionDuration) "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() 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") destFileName := fmt.Sprintf("%s%s", uuid.NewString(), ".ogg")
destMeta := contract.FileMeta{Format: "ogg", DurationMs: srcFile.DurationMs} 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 { if err != nil {
s.logger.Error("Failed to create normalized file record", "error", err, "record_id", r.Id) s.logger.Error("Failed to create normalized file record", "error", err, "record_id", r.Id)
return outcomeDone, err return outcomeDone, err
@@ -489,7 +472,7 @@ func (s *TranscribeService) submit(ctx context.Context, r *entity.AudioRecord, h
if r.NormalizedFileID == nil { if r.NormalizedFileID == nil {
s.logger.Error("Record has no normalized file", "record_id", r.Id) 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) 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) { func (s *TranscribeService) poll(ctx context.Context, r *entity.AudioRecord, holder string) (stepOutcome, error) {
if r.RecognitionID == nil { if r.RecognitionID == nil {
s.logger.Error("Record has no recognition attempt", "record_id", r.Id) 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) 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 == "" { if attempt.ExternalID == "" {
s.logger.Error("Recognition attempt has no operation id", "record_id", r.Id) 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() errorText := result.GetError()
s.logger.Error("Operation failed", s.logger.Error("Operation failed",
"record_id", r.Id, "operation_id", attempt.ExternalID, "error_message", errorText) "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) 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), "text_length", len(outcome.PlainText),
"replicas", len(outcome.Replicas)) "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 return nil
} }
// finish отвечает отправителю и доводит запись до конечного рубежа. Доставка // finish доводит запись до конечного рубежа. Наружу шаг не обращается: доставки
// хвост последнего шага, а не отдельный узел конвейера. // ответа отправителю у сервиса нет, и свой исход отправитель узнаёт опросом
// готовности.
func (s *TranscribeService) finish(ctx context.Context, r *entity.AudioRecord, holder string) (stepOutcome, error) { 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) r.MoveToState(entity.StateDone)
if err := s.repos.Records.Save(r, holder); err != nil { if err := s.repos.Records.Save(r, holder); err != nil {
s.logger.Error("Failed to save record", "error", err, "record_id", r.Id) s.logger.Error("Failed to save record", "error", err, "record_id", r.Id)
return outcomeDone, err 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) { func (s *TranscribeService) failStep(r *entity.AudioRecord, holder, step string, stepErr error) (stepOutcome, error) {
s.halt(r, holder, step, entity.HaltReasonStepFailed, stepErr.Error(), s.halt(r, holder, step, entity.HaltReasonStepFailed, stepErr.Error())
fmt.Sprintf("При обработке записи произошла ошибка: %s", humanText))
return outcomeHalted, nil return outcomeHalted, nil
} }
// halt ставит признак остановки, считает её отказом, пишет строку журнала // halt ставит признак остановки, считает её отказом и пишет строку журнала
// событий и сообщает отправителю. // событий.
// //
// Сообщение уходит при **любой** причине остановки: инвариант проекта «Принятая // Отправителю отсюда ничего не уходит: инвариант проекта «Принятая запись не
// запись не теряется молча» допускает два исхода — запись пригодна к повтору // теряется молча» держится теперь опросом готовности — остановка видна там
// либо об отказе сказано, — а остановленная запись захвату не выдаётся, значит // признаком — и журналом владельца, где у неё стоит причина.
// первый исход исключён.
// //
// Счётчик растит **сама остановка**, а не воркер, и это не стилистика. // Счётчик растит **сама остановка**, а не воркер, и это не стилистика.
// Остановка по сторожам наступает в захвате, до всякого шага, и воркер о ней // Остановка по сторожам наступает в захвате, до всякого шага, и воркер о ней
// узнаёт признаком «работы нет» — а считать его в метрику запрещено инвариантом // узнаёт признаком «работы нет» — а считать его в метрику запрещено инвариантом
// «`NoopJobError` — не ошибка». Значит единственное место, где известны и факт // «`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 stage := r.State
@@ -823,8 +804,6 @@ func (s *TranscribeService) halt(r *entity.AudioRecord, holder, step, reason, er
outcome = entity.EventOutcomeFailed outcome = entity.EventOutcomeFailed
} }
s.appendEvent(r, entity.EventOriginPipeline, step, outcome, reason, 0) s.appendEvent(r, entity.EventOriginPipeline, step, outcome, reason, 0)
s.notify(r, humanText+"\nПожалуйста, попробуйте еще раз.")
} }
// scheduleRetry снимает захват с отказавшей записи и ставит нарастающую паузу. // 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 убирает рабочую копию. Отказ уборки не роняет шаг, но и не // closeWork убирает рабочую копию. Отказ уборки не роняет шаг, но и не
// проглатывается: забытая копия это шестичасовая запись во временном каталоге. // проглатывается: забытая копия это шестичасовая запись во временном каталоге.
func (s *TranscribeService) closeWork(work contract.WorkFile) { 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 приводит расширение к виду колонки формата: без точки, в нижнем // formatOf приводит расширение к виду колонки формата: без точки, в нижнем
// регистре. Наружу оно выходит только приведённым к перечню известных форматов — // регистре. Наружу оно выходит только приведённым к перечню известных форматов —
// это делает метка метрики. // это делает метка метрики.
-154
View File
@@ -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", "недоставки не было")
}
+3 -50
View File
@@ -20,7 +20,6 @@ import (
pbrepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase" pbrepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase"
"git.vakhrushev.me/av/transcriber/internal/config" "git.vakhrushev.me/av/transcriber/internal/config"
httpcontroller "git.vakhrushev.me/av/transcriber/internal/controller/http" 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/controller/worker"
"git.vakhrushev.me/av/transcriber/internal/metrics" "git.vakhrushev.me/av/transcriber/internal/metrics"
"git.vakhrushev.me/av/transcriber/internal/service" "git.vakhrushev.me/av/transcriber/internal/service"
@@ -60,14 +59,6 @@ func main() {
os.Exit(1) 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() metaviewer := ffmpegmv.NewFfmpegMetaViewer()
converter := ffmpegconv.NewFfmpegConverter() 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{ recognizer, err := yandex.NewYandexAudioRecognizerService(yandex.YandexAudioRecognizerConfig{
Region: cfg.Yandex.ObjStorageRegion, Region: cfg.Yandex.ObjStorageRegion,
AccessKey: cfg.Yandex.ObjStorageAccessKey, AccessKey: cfg.Yandex.ObjStorageAccessKey,
@@ -147,7 +132,6 @@ func main() {
metaviewer, metaviewer,
converter, converter,
recognizer, recognizer,
tgSender,
cfg.Pipeline.StuckLimits(), cfg.Pipeline.StuckLimits(),
logger, logger,
) )
@@ -159,32 +143,6 @@ func main() {
// Создаем WaitGroup для ожидания завершения всех воркеров // Создаем WaitGroup для ожидания завершения всех воркеров
var wg sync.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) pool := worker.NewPool(cfg.Pipeline.Workers, transcribeService.RunStep, logger)
@@ -194,9 +152,9 @@ func main() {
pool.Start(ctx) pool.Start(ctx)
}() }()
// Вход по HTTP поднимается всегда: он основной, и отдельного разреза у него // Вход у сервиса один — приём по HTTP, — и метка ставится только ему. Метки
// нет. Признак ставится рядом с признаком Telegram, чтобы владелец судил об // убранного входа Telegram здесь нет намеренно: ноль читался бы как поломка,
// обоих входах одним отбором. // а признак существует ради того дня, когда входов снова станет больше.
metrics.IntakeUpGauge.WithLabelValues("http").Set(1) metrics.IntakeUpGauge.WithLabelValues("http").Set(1)
// Наши маршруты живут на роутере хранилища: панель отдаётся тем же портом, // Наши маршруты живут на роутере хранилища: панель отдаётся тем же портом,
@@ -305,11 +263,6 @@ func main() {
logger.Error("HTTP server stopped unexpectedly, shutting down") logger.Error("HTTP server stopped unexpectedly, shutting down")
} }
if tgController != nil {
logger.Info("Shutting down Telegram bot...")
tgController.Stop()
}
// Создаем контекст с таймаутом для graceful shutdown HTTP сервера // Создаем контекст с таймаутом для graceful shutdown HTTP сервера
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), time.Duration(cfg.Server.ShutdownTimeout)*time.Second) shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), time.Duration(cfg.Server.ShutdownTimeout)*time.Second)
defer shutdownCancel() 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` зелёный целиком.
+23 -30
View File
@@ -10,12 +10,10 @@ OIDC, чем предъявляется сессия, что её прекращ
только на вопрос «узнан ли пришедший», но и на «чьё он смотрит». Запись из веба только на вопрос «узнан ли пришедший», но и на «чьё он смотрит». Запись из веба
принадлежит тому, кто её принёс, и чужая неотличима от несуществующей. принадлежит тому, кто её принёс, и чужая неотличима от несуществующей.
Записи, принятые ботом, владельца не имеют вовсе и по API не достаются никому: Записи без владельца у сервиса не бывает: колонка владельца пустого значения не
связи чата Telegram с учётной записью приложения сервис не ведёт, её заводит принимает, и норму эту держит capability `storage`. Прежде такие записи заводил
отдельная задача. вход Telegram — связи чата с учётной записью сервис не вёл, — и 2026-08-14 вход
убран вместе с этим исключением.
Вход из Telegram эта capability не нормирует: бот проверяет отправителя своим
белым списком, и с учётной записью приложения тот список не связан.
## Requirements ## Requirements
### Requirement: Вход через внешнего провайдера ### Requirement: Вход через внешнего провайдера
@@ -350,13 +348,14 @@ MUST не делать. Кто допущен, определяет правил
### Requirement: У записи есть владелец, и чужую ей не отдают ### Requirement: У записи есть владелец, и чужую ей не отдают
Сервис SHALL заводить у каждой записи, принятой **по HTTP**, — владельца, то Сервис SHALL заводить у каждой принятой записи владельца — учётную запись, от
есть учётную запись, от имени которой запись принята, — и MUST отдавать данные имени которой запись принята, — и MUST отдавать данные такой записи только её
такой записи только её владельцу. Владелец назначается один раз, при приёме, и владельцу. Запись без владельца MUST не заводиться ничем — ни приёмом, ни конвейером, ни
MUST не меняться у записи, у которой владелец есть: совместного доступа, ролей и рукой в панели: колонка владельца пустого значения не принимает, и норму эту
передачи записи другому сервис не знает. Оговорка не случайна — назначить держит capability `storage`.
владельца записи, у которой его нет, вправе задача, заводящая связь чата
Telegram с учётной записью. Владелец назначается один раз, при приёме, и MUST не меняться: совместного
доступа, ролей и передачи записи другому сервис не знает.
Владелец MUST браться из предъявленной сессии и ниоткуда больше. Владелец, Владелец MUST браться из предъявленной сессии и ниоткуда больше. Владелец,
пришедший полем запроса, дал бы всякому вошедшему право завести запись на чужое пришедший полем запроса, дал бы всякому вошедшему право завести запись на чужое
@@ -368,16 +367,12 @@ Telegram с учётной записью.
что разграничение прячет. Каким именно ответом это выражено, нормирует что разграничение прячет. Каким именно ответом это выражено, нормирует
capability `intake`: там живёт адрес опроса, и держатель нормы обязан быть один. capability `intake`: там живёт адрес опроса, и держатель нормы обязан быть один.
Пустой владелец MUST не совпадать ни с одной записью — ни со своей, ни с чужой, Пустой владелец MUST не совпадать ни с одной записью. Правило записано со стороны
ни с ничьей. Правило записано со стороны **спрашивающего**, а не со стороны **спрашивающего** и остаётся в силе, хотя записей без владельца в хранилище
записи: обязательность владельца, которую держит одна лишь подпись метода, пустую больше нет: спрашивающий с пустым владельцем — это вызов, у которого нет учётной
строку пропускает, и первый же вызывающий без учётной записи получил бы ровно записи, и отвечать ему надо отказом, а не выборкой. Держится оно отдельно от
множество записей без владельца, то есть все записи бота. схемы намеренно: схема запрещает **заводить** ничью запись, а это правило
запрещает **спрашивать** ничьим именем, и одно другое не заменяет.
Записи, принятые из Telegram, владельца не имеют: связи чата с учётной записью
приложения сервис не ведёт. Такая запись MUST не доставаться по API никому —
ответ на неё тот же, что и на несуществующую, — а её расшифровка уезжает
отправителю в чат, как и прежде.
#### Scenario: Своя запись доступна #### Scenario: Своя запись доступна
@@ -396,15 +391,13 @@ capability `intake`: там живёт адрес опроса, и держат
- **WHEN** запрос на приём записи несёт своё значение владельца - **WHEN** запрос на приём записи несёт своё значение владельца
- **THEN** владельцем принятой записи становится предъявитель сессии - **THEN** владельцем принятой записи становится предъявитель сессии
#### Scenario: Запись из Telegram не достаётся по API #### Scenario: Ничью запись завести нечем
- **GIVEN** запись принята ботом - **WHEN** запись пытаются завести с пустым владельцем
- **WHEN** её состояние спрашивает вошедший человек - **THEN** хранилище её не сохраняет
- **THEN** ответ тот же, что и на неизвестный идентификатор
#### Scenario: Пустой владелец не открывает ничего #### Scenario: Пустой владелец не открывает ничего
- **GIVEN** заведены три задачи: своя, чужая и принятая ботом - **GIVEN** заведены две записи: своя и чужая
- **WHEN** состояние каждой спрашивают с пустым владельцем - **WHEN** состояние каждой спрашивают с пустым владельцем
- **THEN** ответ на все три тот же, что и на неизвестный идентификатор - **THEN** ответ на обе тот же, что и на неизвестный идентификатор
+33 -128
View File
@@ -6,12 +6,9 @@
записью, что уезжает в ответ и что происходит, когда запись не удалось записью, что уезжает в ответ и что происходит, когда запись не удалось
прочитать. Плюс наличие входов: с каким из них сервис вправе подняться. прочитать. Плюс наличие входов: с каким из них сервис вправе подняться.
Приём по существу описан пока **только для HTTP** — того, что нормируют Вход у сервиса один — приём по HTTP, — и описан он тем, что нормируют проверки.
проверки. Про вход Telegram нормировано одно: настроен он или нет и что из этого Второй вход, Telegram, убран 2026-08-14 вместе со своими требованиями; его
следует для подъёма. Кто допущен к боту и как забирается присланная им запись, возвращение заводит их заново, вместе со связью чата и учётной записи.
требованиями по-прежнему не описано — требование, написанное без проверки, это
предположение, а не норма. Первая задача, которая трогает поведение приёма из
Telegram, дописывает его сюда.
## Requirements ## Requirements
### Requirement: Приём записи по HTTP ### Requirement: Приём записи по HTTP
@@ -40,10 +37,12 @@ Telegram, дописывает его сюда.
Куда именно ложится принятая запись, приёму не принадлежит: раскладку выбирает Куда именно ложится принятая запись, приёму не принадлежит: раскладку выбирает
хранилище, и нормирует её capability `storage`. хранилище, и нормирует её capability `storage`.
Владельцем принятой записи приём SHALL назначать предъявителя сессии. Проверка Владельцем принятой записи приём SHALL назначать предъявителя сессии. Обязательность
стоит здесь, а не только в схеме хранилища: колонка владельца допускает пустое владельца при этом MUST держаться и схемой хранилища: колонка владельца пустого
значение ради записей из Telegram, и приём по HTTP — то место, где значения не принимает вовсе, и норму эту держит capability `storage`. Проверка в
обязательность держится. приёме от этого не лишняя — она отвечает отправителю понятным отказом до того, как
запись попадёт в память, а схема отвечала бы отказом сохранения после укладки
файла.
Предъявитель, чья сессия не даёт учётной записи пользователя, MUST получать Предъявитель, чья сессия не даёт учётной записи пользователя, MUST получать
отказ `403` и MUST получать его **до чтения тела** — там же, где стоит отказ по отказ `403` и MUST получать его **до чтения тела** — там же, где стоит отказ по
@@ -154,11 +153,9 @@ Telegram, дописывает его сюда.
нормирует требование ниже; наружу расширение выходит только приведённым к нормирует требование ниже; наружу расширение выходит только приведённым к
известному виду — этому отдано отдельное требование. известному виду — этому отдано отдельное требование.
Сценарии судят приём по HTTP, потому что имя, данное отправителем, доходит до Оговорка про второй вход из требования ушла вместе с ним: имя, данное
сервиса только оттуда: из Telegram приходит путь, выданный самим Telegram, а не отправителем, доходит до сервиса единственным путём — приёмом по HTTP, — и
имя человека. Правка при этом ложится на общий шаг заведения задачи, через сценарии судят именно его.
который идут оба входа, поэтому своей нормы приём из Telegram здесь не получает —
её напишет задача, которая тронет его поведение.
#### Scenario: Имя записи не видно в журнале принятой записи #### Scenario: Имя записи не видно в журнале принятой записи
@@ -265,15 +262,15 @@ Telegram, дописывает его сюда.
Остановленная запись MUST отдавать рубеж, на котором она остановлена, и MUST Остановленная запись MUST отдавать рубеж, на котором она остановлена, и MUST
нести признак остановки отдельным полем `halted` со значением истины. Машинный нести признак остановки отдельным полем `halted` со значением истины. Машинный
текст отказа MUST в ответ не попадать: он принадлежит журналу владельца сервиса, текст отказа MUST в ответ не попадать: он принадлежит журналу владельца сервиса,
а не отправителю. Отправитель узнаёт о неудаче ответом там, откуда пришла а не отправителю. Этот адрес — **единственное** место, где отправитель узнаёт о
запись, — это нормирует capability `pipeline`. неудаче: доставки ответа отправителю у сервиса больше нет, и признак остановки
здесь несёт всю обязанность целиком.
Отказ без сессии MUST не зависеть от того, есть такая запись или нет: иначе по Отказ без сессии MUST не зависеть от того, есть такая запись или нет: иначе по
кодам ответа перебирается список заведённых записей. кодам ответа перебирается список заведённых записей.
Запись, принадлежащая другому, MUST отвечать тем же, чем отвечает неизвестный Запись, принадлежащая другому, MUST отвечать тем же, чем отвечает неизвестный
идентификатор, — кодом `404` и тем же телом. То же MUST относиться к записи без идентификатор, — кодом `404` и тем же телом.
владельца: запись, принятая ботом, по этому адресу не достаётся никому.
#### Scenario: Запись найдена #### Scenario: Запись найдена
@@ -325,117 +322,25 @@ Telegram, дописывает его сюда.
### Requirement: Поднятые входы видны наблюдателю ### Requirement: Поднятые входы видны наблюдателю
Сервис SHALL отдавать признак поднятости по каждому входу приёма отдельной Сервис SHALL отдавать признак поднятости по каждому входу приёма отдельной
метрикой. Признак MUST выставляться при сборке входа и MUST различать поднятый метрикой и MUST выставлять метку только тому входу, который у сервиса есть.
вход и неподнятый. Метки убранного входа в метриках MUST не быть вовсе: признак со значением нуля
читался бы как «вход есть, но не поднялся», то есть как поломка, а вечная
единица рядом с ним — как исправность того, чего нет.
Требование стоит на том, что иначе потерянный вход не виден ничем: проба Проверяемое здесь одно — **набор меток**, и это честнее прежнего. Вход остался
здоровья отвечает «сервис работает» и при неподнятом боте, а запись журнала один, страница метрик отдаётся тем же сервером, что и приём, и значение нуля у
живёт до ротации и вопрос «работает ли вход сейчас» не отвечает. Метрика — единственной метки недостижимо: чтобы прочитать признак, надо дотянуться до
единственный канал наблюдения, который у владельца автоматизирован. входа, о котором он сообщает. Прежнее обоснование — «иначе потерянный вход не
виден ничем» — было верно, пока входов было два; сегодня неподнятый вход виден
неудачей чтения самих метрик.
#### Scenario: Вход Telegram не поднят Различать поднятый и неподнятый вход признак MUST снова, как только входов у
сервиса станет больше одного.
- **GIVEN** сервис поднялся без Telegram #### Scenario: В метриках только оставшийся вход
- **GIVEN** сервис поднялся
- **WHEN** наблюдатель читает метрики - **WHEN** наблюдатель читает метрики
- **THEN** признак поднятости входа Telegram равен нулю - **THEN** признак поднятости несёт метку входа HTTP со значением единицы
- **AND** признак поднятости входа HTTP равен единице - **AND** метки убранного входа Telegram в метриках нет вовсе
### 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** ни журнал, ни текст отказа не несут значения ключа
+57 -127
View File
@@ -3,14 +3,14 @@
## Purpose ## Purpose
Конвейер расшифровки: как аудиозапись движется по рубежам, что делает воркер, Конвейер расшифровки: как аудиозапись движется по рубежам, что делает воркер,
когда работы нет, что считается отказом шага и что бывает с ответом отправителю, когда работы нет, и что считается отказом шага.
когда доставить его некуда.
Описаны цепочка рубежей и смысл рубежа, остановка признаком и её причины, оба Описаны цепочка рубежей и смысл рубежа, остановка признаком и её причины, оба
сторожа — число отказов и время в рубеже, — откладывание работы отдельно от сторожа — число отказов и время в рубеже, — откладывание работы отдельно от
перехода, неделимость захвата и срок его протухания, условие записи результата перехода, неделимость захвата и срок его протухания, условие записи результата
держателем захвата, нарастающая пауза перед повтором, число воркеров настройкой, держателем захвата, нарастающая пауза перед повтором, число воркеров настройкой,
журнал событий записи и недоставка ответа при неподнятом входе. журнал событий записи и молчание конвейера наружу: обращений к отправителю он не
делает вовсе, и свой исход тот узнаёт опросом готовности.
Сознательно не описаны: освобождение ресурсов внешних клиентов и **какие отказы Сознательно не описаны: освобождение ресурсов внешних клиентов и **какие отказы
считаются приговором записи, а какие поводом к повтору**. Второе — не пробел считаются приговором записи, а какие поводом к повтору**. Второе — не пробел
@@ -101,7 +101,7 @@
захват, перевыданный другому — по протуханию срока или после того, как человек захват, перевыданный другому — по протуханию срока или после того, как человек
снял признак остановки в панели, — обязан обращать запись первого в отказ. снял признак остановки в панели, — обязан обращать запись первого в отказ.
Условие, проверяющее лишь непустоту признака или срок, пропустило бы обоих, и Условие, проверяющее лишь непустоту признака или срок, пропустило бы обоих, и
два шага записали бы в одну запись и оба ответили бы отправителю. два шага записали бы в одну запись по очереди, испортив её результат.
Одна и та же запись MUST доставаться ровно одному захватившему. Двум вызывающим, Одна и та же запись MUST доставаться ровно одному захватившему. Двум вызывающим,
пришедшим за работой одновременно, запись MUST достаться одному, а второй MUST пришедшим за работой одновременно, запись MUST достаться одному, а второй MUST
@@ -155,14 +155,18 @@
всё ещё принадлежит ему. Запись MUST быть условна по **признаку этого захвата** всё ещё принадлежит ему. Запись MUST быть условна по **признаку этого захвата**
значению, уникальному для каждого захвата, — а не по занятости записи вообще. значению, уникальному для каждого захвата, — а не по занятости записи вообще.
Шаг, чей захват за время работы достался другому, MUST завершиться без записи Шаг, чей захват за время работы достался другому, MUST завершиться без записи
результата и без ответа отправителю. результата.
Требование закрывает то, чего неделимость захвата не закрывает: захват протухает Требование закрывает то, чего неделимость захвата не закрывает: захват протухает
не только у мёртвого воркера, но и у живого — шаг, идущий дольше своего срока, не только у мёртвого воркера, но и у живого — шаг, идущий дольше своего срока,
теряет запись, продолжая работать. Снять захват может и человек, вернувший теряет запись, продолжая работать. Снять захват может и человек, вернувший
остановленную запись в работу. Без условия по уникальному признаку два воркера остановленную запись в работу. Без условия по уникальному признаку два воркера
пишут в одну запись по очереди, счётчик отказов сбрасывает тот, кто уже не пишут в одну запись по очереди, а счётчик отказов сбрасывает тот, кто уже не
владелец, а отправитель получает два ответа на одну запись. владелец.
Довод про два ответа отправителю из требования ушёл вместе с доставкой: обращений
наружу шаг не делает. Требование от этого не ослабло — порча записи двумя
пишущими остаётся его предметом целиком.
Шаг MUST записывать только те поля, которыми распоряжается сам. Запись он держит Шаг MUST записывать только те поля, которыми распоряжается сам. Запись он держит
снимком с момента захвата и до записи — это часы, — и безусловная запись снимка снимком с момента захвата и до записи — это часы, — и безусловная запись снимка
@@ -185,7 +189,6 @@
- **AND** за это время та же запись досталась другому захвату - **AND** за это время та же запись досталась другому захвату
- **WHEN** первый шаг доходит до записи результата - **WHEN** первый шаг доходит до записи результата
- **THEN** результат не записывается - **THEN** результат не записывается
- **AND** отправителю ничего не отправляется
#### Scenario: Человек снял остановку под работающим шагом #### Scenario: Человек снял остановку под работающим шагом
@@ -263,89 +266,18 @@ MUST расти с числом её отказов до объявленног
- **THEN** задержка до следующей проверки каждый раз одна и та же - **THEN** задержка до следующей проверки каждый раз одна и та же
- **AND** число отказов записи не растёт - **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: Выборка воркера владельцем не сужается ### Requirement: Выборка воркера владельцем не сужается
Воркер SHALL брать записи всех владельцев подряд и MUST не учитывать владельца Воркер SHALL брать записи всех владельцев подряд и MUST не учитывать владельца
при выборе очередной записи. Запись без владельца — принятая ботом — MUST при выборе очередной записи.
обрабатываться наравне с прочими.
Владелец решает, кому запись показывать, а не кому её считать. Сужение выборки Владелец решает, кому запись показывать, а не кому её считать. Сужение выборки
владельцем остановило бы расшифровку записей бота вовсе, а записи остальных владельцем поставило бы записи одних людей в зависимость от того, кто первым
поставило бы в зависимость от того, кто первым завёл учётную запись. завёл учётную запись.
Оговорка про записи без владельца из требования ушла: заводить их стало нечем —
колонка владельца пустого значения не принимает, и норму держит capability
`storage`.
Владелец записи MUST переживать работу конвейера: шаг, сохраняющий свой Владелец записи MUST переживать работу конвейера: шаг, сохраняющий свой
результат, владельца не трогает и не затирает. результат, владельца не трогает и не затирает.
@@ -356,12 +288,6 @@ MUST расти с числом её отказов до объявленног
- **WHEN** воркер забирает работу - **WHEN** воркер забирает работу
- **THEN** ему достаются обе, в порядке заведения - **THEN** ему достаются обе, в порядке заведения
#### Scenario: Запись без владельца обрабатывается
- **GIVEN** заведена запись, принятая ботом, — без владельца
- **WHEN** воркер забирает работу
- **THEN** она достаётся ему наравне с прочими
#### Scenario: Шаг конвейера владельца не затирает #### Scenario: Шаг конвейера владельца не затирает
- **GIVEN** запись с владельцем прошла шаг конвейера - **GIVEN** запись с владельцем прошла шаг конвейера
@@ -463,38 +389,6 @@ MUST расти с числом её отказов до объявленног
- **THEN** число отказов, пауза и время входа в рубеж сброшены - **THEN** число отказов, пауза и время входа в рубеж сброшены
- **AND** ближайший захват выдаёт запись, а не останавливает её снова - **AND** ближайший захват выдаёт запись, а не останавливает её снова
### Requirement: Всякая остановка сообщает отправителю
Остановка записи по любой причине SHALL сообщать отправителю о неудаче ровно
так же, как сообщает о ней отказ шага, и MUST быть видна владельцу сервиса
записью в журнале.
Требование стоит на инварианте проекта «Принятая запись не теряется молча»:
инвариант допускает два исхода — запись пригодна к повтору либо об отказе
сказано, — а остановленная запись захвату не выдаётся, значит первый исход
исключён.
Причин остановки больше одной, и обязанность общая для всех: исчерпанные
отказы, застревание в рубеже, приговор шага. Обязанность, записанная у одной
причины, у остальных читалась бы как снятая.
Ответ уходит **после** того, как признак остановки сохранён, и недоставка этого
ответа MUST не отменять остановку: её нормирует требование «Недоставленный ответ
не роняет шаг».
#### Scenario: Остановка по отказам сообщает отправителю
- **GIVEN** запись остановлена по исчерпании отказов
- **WHEN** шаг доходит до ответа отправителю
- **THEN** отправитель получает сообщение о неудаче
#### Scenario: Остановка по времени сообщает отправителю
- **GIVEN** запись остановлена по пределу времени в рубеже
- **WHEN** шаг доходит до ответа отправителю
- **THEN** отправитель получает сообщение о неудаче
- **AND** в журнале владельца есть запись об остановке с причиной
### Requirement: Время в рубеже ограничено ### Requirement: Время в рубеже ограничено
У аудиозаписи SHALL быть время входа в рубеж, и оно MUST ставиться только при У аудиозаписи SHALL быть время входа в рубеж, и оно MUST ставиться только при
@@ -674,8 +568,8 @@ MUST не быть привязаны к отдельному шагу: кажд
Запись, захваченная с числом отказов сверх заданного предела, MUST Запись, захваченная с числом отказов сверх заданного предела, MUST
останавливаться признаком тем, кто её захватил, и MUST не отдаваться шагу в останавливаться признаком тем, кто её захватил, и MUST не отдаваться шагу в
работу. Об этой остановке отправителю сообщается наравне с прочими — норму работу. Остановка эта видна отправителю опросом готовности наравне с прочими —
держит требование «Всякая остановка сообщает отправителю». норму держит capability `intake`.
Этот сторож MUST отвечать только за повторы внутри шага. Время, проведённое Этот сторож MUST отвечать только за повторы внутри шага. Время, проведённое
записью в рубеже, MUST мериться отдельным сторожем: одно число не справляется ни записью в рубеже, MUST мериться отдельным сторожем: одно число не справляется ни
@@ -689,7 +583,7 @@ MUST не быть привязаны к отдельному шагу: кажд
- **WHEN** запись проходит заданное число отказов - **WHEN** запись проходит заданное число отказов
- **THEN** у неё появляется признак остановки - **THEN** у неё появляется признак остановки
- **AND** следующий захват её не выдаёт - **AND** следующий захват её не выдаёт
- **AND** отправитель получает сообщение о неудаче - **AND** опрос готовности отдаёт владельцу записи признак остановки
#### Scenario: Шаг уносит процесс, не объявив отказа #### Scenario: Шаг уносит процесс, не объявив отказа
@@ -710,3 +604,39 @@ MUST не быть привязаны к отдельному шагу: кажд
- **WHEN** смотрят её число отказов - **WHEN** смотрят её число отказов
- **THEN** оно не приблизилось к пределу - **THEN** оно не приблизилось к пределу
### Requirement: Конвейер ответа отправителю не шлёт
Шаг конвейера SHALL доводить запись до достигнутого рубежа и MUST не обращаться
к отправителю вовсе — ни с готовым текстом, ни с сообщением о неудаче. Исход
своей записи отправитель узнаёт опросом готовности и в панели владельца; адрес
опроса и содержимое ответа нормирует capability `intake`.
Требование заведено взамен доставки в чат, убранной вместе с входом Telegram.
Без него молчание конвейера читалось бы как недоделка: прежде ответ уходил, и
всякий, кто помнит это, ищет в шаге отправку, а её отсутствие принимает за
потерянную ветку.
Инвариант проекта «Принятая запись не теряется молча» держится теперь опросом
готовности — там остановка видна признаком — и журналом владельца, где у неё
стоит причина. Обязанность при этом сменила направление: прежде об отказе
сообщали, теперь отказ доступен спросившему. Отправитель, который не
спрашивает, об остановке не узнаёт.
Записи, которой этот канал недоступен, не бывает: у каждой записи есть владелец,
и опрос отдаёт ему её исход. Держится это обязательностью владельца в схеме
хранилища — норму держит capability `storage`.
#### Scenario: Готовый текст отправителю не уходит
- **GIVEN** запись дошла до конечного рубежа
- **WHEN** шаг конвейера её завершает
- **THEN** ни одного обращения наружу с текстом расшифровки не уходит
- **AND** текст достаётся опросом готовности
#### Scenario: Остановка видна опросом, а не сообщением
- **GIVEN** запись остановлена по исчерпании отказов
- **WHEN** владелец записи спрашивает её рубеж
- **THEN** ответ несёт достигнутый рубеж и признак остановки
- **AND** в журнале владельца сервиса есть запись об остановке с причиной
+68 -16
View File
@@ -17,8 +17,8 @@
### Requirement: Сервис поднимается на чистом каталоге данных ### Requirement: Сервис поднимается на чистом каталоге данных
Сервис SHALL приводить хранилище в рабочий вид сам: на пустом каталоге данных он Сервис SHALL приводить хранилище в рабочий вид сам: на пустом каталоге данных он
MUST завести свою схему и принимать записи обоими входами без единого ручного MUST завести свою схему и принимать записи своим входом — приёмом по HTTP — без
шага до первого запуска. единого ручного шага до первого запуска.
Прежние данные не переносятся. Каталог, оставшийся от прежней раскладки, MUST не Прежние данные не переносятся. Каталог, оставшийся от прежней раскладки, MUST не
читаться и не считаться источником: сервис начинает с чистого листа, и это читаться и не считаться источником: сервис начинает с чистого листа, и это
@@ -267,14 +267,14 @@ MUST завести свою схему и принимать записи об
печатается оно в журнал контейнера, откуда строку не убрать: бессрочное отдало бы печатается оно в журнал контейнера, откуда строку не убрать: бессрочное отдало бы
панель всякому читателю логов навсегда. панель всякому читателю логов навсегда.
Пока владелец пароля не задал, сервис MUST работать обоими входами: панель без Пока владелец пароля не задал, сервис MUST принимать записи: панель без владельца
владельца не мешает принимать записи. приёму не мешает.
#### Scenario: Владелец пароля ещё не задал #### Scenario: Владелец пароля ещё не задал
- **GIVEN** каталог данных пуст и владелец панели не заведён - **GIVEN** каталог данных пуст и владелец панели не заведён
- **WHEN** сервис запускается - **WHEN** сервис запускается
- **THEN** он принимает записи обоими входами - **THEN** он принимает записи
- **AND** ни один ключ конфигурации не несёт пароля от панели - **AND** ни один ключ конфигурации не несёт пароля от панели
#### Scenario: Владелец заведён, приглашение больше не печатается #### Scenario: Владелец заведён, приглашение больше не печатается
@@ -289,10 +289,13 @@ MUST завести свою схему и принимать записи об
учётной записью, — и эта колонка MUST не иметь умолчания: запись, чей владелец учётной записью, — и эта колонка MUST не иметь умолчания: запись, чей владелец
не назван, не достаётся никому по недосмотру схемы. не назван, не достаётся никому по недосмотру схемы.
Колонка MUST допускать пустое значение, и это решение с названной ценой: записи, Колонка MUST не допускать пустого значения. Прежде допускала, и цену платили за
принятые ботом, владельца не имеют, потому что связи чата Telegram с учётной записи, принятые ботом: связи чата с учётной записью сервис не вёл. С убранным
записью сервис не ведёт. Обязательность для приёма по HTTP держит сама входом заводить ничью запись стало некому, и обязательность переезжает из одного
capability `intake`, а не схема. лишь приёма в схему — туда, где её держит хранилище, а не договорённость. Разница
не косметическая: пока обязательность жила в приёме, ничью запись заводили руками
в панели, и она уходила в конвейер, стоила денег на распознавание и не доставалась
потом никому.
Владелец MUST не назначаться и не меняться конвейером. Владелец MUST не назначаться и не меняться конвейером.
@@ -301,6 +304,14 @@ capability `intake`, а не схема.
- **WHEN** сервис поднимается на чистом каталоге данных - **WHEN** сервис поднимается на чистом каталоге данных
- **THEN** у аудиозаписи есть колонка владельца - **THEN** у аудиозаписи есть колонка владельца
- **AND** умолчания у неё нет - **AND** умолчания у неё нет
- **AND** пустого значения она не принимает
#### Scenario: Запись без владельца не сохраняется
- **GIVEN** сервис поднят
- **WHEN** аудиозапись пытаются сохранить с пустым владельцем — приёмом,
конвейером или руками в панели
- **THEN** хранилище её не сохраняет
#### Scenario: Конвейер владельца не назначает #### Scenario: Конвейер владельца не назначает
@@ -313,9 +324,16 @@ capability `intake`, а не схема.
Хранилище SHALL держать владельца и у файла записи — той же связью с учётной Хранилище SHALL держать владельца и у файла записи — той же связью с учётной
записью, — и правило просмотра файлов MUST пускать к файлу только его владельца. записью, — и правило просмотра файлов MUST пускать к файлу только его владельца.
Владелец файла MUST назначаться там же, где владелец записи, — при приёме, из Владелец файла MUST назначаться при приёме, из предъявленной сессии, а колонка
предъявленной сессии, — и MUST оставаться пустым у файлов, заведённых конвейером файла MUST не допускать пустого значения наравне с колонкой записи. Прежде пустое
для записи без владельца. значение оставалось у файлов, заведённых конвейером для записи без владельца;
таких записей больше не заводится, и разное правило у записи и у её файла
читалось бы как недосмотр.
Файл, заведённый шагом конвейера, — приведённую копию заводит именно он —
MUST получать владельца своей записи. Иного источника владельца у файла нет, и
шаг, оставивший его пустым, упрётся в отказ сохранения: запись накопит отказы и
остановится признаком на первом же приведении.
Ссылки на файлы у записи две — на принятую копию и на приведённую, — и обе живут Ссылки на файлы у записи две — на принятую копию и на приведённую, — и обе живут
до конца, но владелец файла MUST по-прежнему лежать своей колонкой, а не до конца, но владелец файла MUST по-прежнему лежать своей колонкой, а не
@@ -340,12 +358,18 @@ capability `intake`, а не схема.
- **WHEN** он идёт по ссылке на файл своей записи со своим токеном - **WHEN** он идёт по ссылке на файл своей записи со своим токеном
- **THEN** содержимое отдаётся - **THEN** содержимое отдаётся
#### Scenario: Файл записи из Telegram не отдаётся по API #### Scenario: Файл без владельца не сохраняется
- **GIVEN** запись принята ботом, и владельца у неё нет - **GIVEN** сервис поднят
- **WHEN** вошедший человек идёт по ссылке на её файл со своим токеном - **WHEN** файл записи пытаются сохранить с пустым владельцем
- **THEN** содержимого он не получает - **THEN** хранилище его не сохраняет
#### Scenario: Приведённая копия получает владельца записи
- **GIVEN** запись с владельцем дошла до приведения
- **WHEN** шаг заводит приведённую копию файла
- **THEN** владельцем копии стоит владелец записи
- **AND** шаг завершается без отказа
### Requirement: Учётная запись с записями не удаляется ### Requirement: Учётная запись с записями не удаляется
Хранилище SHALL отвергать удаление учётной записи, у которой остались Хранилище SHALL отвергать удаление учётной записи, у которой остались
@@ -551,3 +575,31 @@ MUST быть помечено защищённым.
- **WHEN** записи назначают шестую тему - **WHEN** записи назначают шестую тему
- **THEN** назначение не проходит - **THEN** назначение не проходит
### Requirement: Пустой результат не кладётся поверх сохранённого
Хранилище SHALL не заменять сохранённое содержимое приложения записи — текст и
структуру реплик — пустым. Замена пустым MUST оставлять прежнее значение и
считаться сделанной работой, а не отказом.
Требование стоит на повторном опросе одной и той же операции распознавания.
Повтор — обычное дело: держатель захвата умер, сохранение рубежа отказало,
человек снял признак остановки в панели. Провайдер при этом вправе ответить
пустым потоком, отказом это не считается, и безусловная замена стирала бы
расшифровку живого человека — без следа и без возврата, потому что сервис
объявлен архивом и удаления по требованию не знает.
Та же защита MUST стоять у сырого ответа провайдера: разное правило у двух
хранителей одного результата читается как недосмотр, и один из них молча теряет
то, ради чего второй заведён.
Норма записана со стороны **хранилища**, а не шага: шагов, кладущих текст,
больше одного, и правило, записанное у одного из них, у остальных читалось бы
как снятое.
#### Scenario: Пустой второй ответ не стирает расшифровку
- **GIVEN** расшифровка записи сохранена
- **WHEN** ту же операцию опрашивают снова, и провайдер отвечает пустым
- **THEN** сохранённая расшифровка остаётся прежней
- **AND** шаг завершается без отказа
-100
View File
@@ -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
}
}
-208
View File
@@ -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, "это ошибка настройки, а не режим")
}