diff --git a/.golangci.yml b/.golangci.yml index 99fc18c..bfa842f 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -154,8 +154,6 @@ linters: - (*os.File).Close - (io.ReadCloser).Close - os.Remove - # Метод сам логирует ошибку отправки, вызывающему она не нужна - - (*git.vakhrushev.me/av/transcriber/internal/controller/tg.TelegramController).send exclusions: rules: diff --git a/CLAUDE.md b/CLAUDE.md index 9475835..abd6ce1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,11 +9,12 @@ ## Что это -Сервис расшифровки аудио в текст. Принимает запись двумя входами — Telegram-бот и -HTTP API, — конвертирует её `ffmpeg` в ogg, отдаёт на отложенное распознавание -Yandex SpeechKit и возвращает текст туда, откуда пришла запись. Состояние задач, -метаданные и сами файлы лежат во встроенной PocketBase, и она же даёт владельцу -панель администратора. +Сервис расшифровки аудио в текст. Принимает запись одним входом — HTTP API, — +конвертирует её `ffmpeg` в ogg, отдаёт на отложенное распознавание Yandex +SpeechKit и отдаёт текст тому, кто запись загрузил, по опросу готовности. +Состояние записей, метаданные и сами файлы лежат во встроенной PocketBase, и она +же даёт владельцу панель администратора. Вход Telegram убран 2026-08-14 — +временно, до задачи, которая свяжет чат с учётной записью. Чего **не** делает: сам речь не распознаёт и своих моделей не держит, текст руками не правит и в форматы документов не экспортирует, учётных записей не @@ -25,7 +26,7 @@ Yandex SpeechKit и возвращает текст туда, откуда пр ## Стек Go 1.26 (сборке CGO не нужен; детектору гонок в гейте — нужен), встроенная PocketBase — хранилище, файлы записей и -панель администратора, — `go-telegram-bot-api`, `aws-sdk-go-v2` для Object +панель администратора, — `aws-sdk-go-v2` для Object Storage, gRPC-клиент Yandex SpeechKit v3, Prometheus, `slog`. Сборка — Taskfile, образ — Docker, выкладка — Ansible из `pet-project-server`. @@ -33,7 +34,7 @@ Taskfile, образ — Docker, выкладка — Ansible из `pet-project- Что нарушать нельзя. -- **Секрет не покидает конфиг.** Токен бота, ключ SpeechKit, пара ключей Object +- **Секрет не покидает конфиг.** Ключ SpeechKit, пара ключей Object Storage и секрет клиента OIDC не попадают в git, в лог, в ответ пользователю и в колонку `error_text`. Нарушение необратимо: утёкший ключ отзывают и меняют вручную во всех местах выкладки. **critical** @@ -53,14 +54,13 @@ Taskfile, образ — Docker, выкладка — Ansible из `pet-project- приведённым к перечню известных форматов. Границу держит спека `intake`, цена — [adr/ADR-2026-08-11-known-format-label.md](docs/adr/ADR-2026-08-11-known-format-label.md), остаток — [docs/security.md](docs/security.md). -- **Бот отвечает только тем, кто в белом списке.** Бот проверяет отправителя до - любой работы, включая скачивание файла. Нарушение обратимо правкой конфига, но - чужие записи к тому моменту уже обработаны за наши деньги. **critical** - **Принятая запись не теряется молча.** Отказ на любом шаге либо оставляет - задачу пригодной к повтору, либо переводит её в `failed` и сообщает - пользователю. Молчаливый выход из шага без записи в лог и без смены состояния - запрещён. Обратимо повторной отправкой, но пользователь об этом не узнает. - **major** + запись пригодной к повтору, либо ставит на неё признак остановки с причиной — + и тогда причина видна её владельцу опросом готовности, а владельцу сервиса + журналом. Молчаливый выход из шага без записи в лог и без смены состояния + запрещён. Обязанность сменила направление 2026-08-14 вместе с убранным входом + Telegram: прежде об отказе сообщали, теперь отказ доступен спросившему, и + отправитель, который не спрашивает, о нём не узнаёт. **major** - **`NoopJobError` — не ошибка.** Значение «задач в этом состоянии нет» не логируется, не считается в метрику и не поднимает уровень. Нарушение даёт запись раз в секунду на каждый воркер. **major** @@ -89,12 +89,21 @@ Taskfile, образ — Docker, выкладка — Ansible из `pet-project- Признак захвата уникален для каждого захвата, и запись результата условна по нему, а не по занятости записи. Шаг, чей захват за время работы достался другому — по протуханию срока или после того, как человек снял признак - остановки в панели, — завершается без записи и без ответа отправителю. Условие + остановки в панели, — завершается без записи результата. Условие по непустоте признака пропустило бы обоих: два воркера писали бы в одну запись - по очереди, а отправитель получал бы два ответа. **major** -- **Остановленная запись сообщает отправителю, какой бы ни была причина.** - Причин три — приговор шага, исчерпанные отказы, застревание. Остановленная - запись захвату не выдаётся, значит исход «пригодна к повтору» исключён. + по очереди, портя её результат. **major** +- **У записи есть владелец, и колонка пустого значения не принимает.** Ничья + запись не заводится ничем — ни приёмом, ни конвейером, ни рукой в панели, — и + держит это схема хранилища, а не договорённость. Пока обязательность жила в + одном приёме, ничью запись заводили в панели, она уходила в конвейер, стоила + денег на распознавание и не доставалась потом никому. Правило со стороны + спрашивающего при этом остаётся: пустой владелец не совпадает ни с одной + записью, потому что схема запрещает **заводить** ничью, а это правило — + **спрашивать** ничьим именем. **major** +- **Остановленная запись несёт причину, какой бы та ни была.** Причин три — + приговор шага, исчерпанные отказы, застревание, — и каждая записывается в саму + запись и в её журнал событий. Остановленная запись захвату не выдаётся, значит + исход «пригодна к повтору» исключён, и другого следа у неё не будет. Обязанность, записанная у одной причины, у остальных читалась бы как снятая. **major** @@ -200,16 +209,12 @@ task gate # весь набор проверок разом база (`data/data.db`), и записи живых людей (`data/storage/<коллекция>/<запись>/`). Локальный каталог данных — свой, его ронять и пересоздавать можно свободно. -- **Боевым токеном бота не запускаться.** Второй процесс с тем же токеном - перехватывает обновления у работающего, и пользователь теряет ответы. Запускай - с `telegram.enabled = false`: сервис поднимается без Telegram, к нему не уходит - ни одного обращения, и работает он одним входом, по HTTP. Пустого - `bot_token` для этого мало и больше не значит ничего: включён вход или нет, - решает отдельный признак `telegram.enabled`, а пустой ключ при `enabled = true` - роняет старт. Выключенного входа - для подъёма тоже мало: секции `[auth]` и `[yandex]` проверяются на старте, но - наружу при этом не ходят, так что годятся выдуманные непустые значения; - подробности строками в `config.example.toml`. +- **Локальный запуск не ходит наружу.** Секции `[auth]` и `[yandex]` + проверяются на старте, но наружу при этом не обращаются, так что годятся + выдуманные непустые значения — адреса `[auth]` должны лишь разбираться как + ссылки. Расшифровка при выдуманных ключах не работает: её подменяют + `internal/adapter/recognizer/memory.go`. Подробности строками в + `config.example.toml`. - **Yandex Cloud за деньги.** Распознавание и хранение в Object Storage оплачиваются по факту. Прогон на реальных ключах ради проверки кода запрещён — подставляй `internal/adapter/recognizer/memory.go`. @@ -249,7 +254,7 @@ task gate # весь набор проверок разом - Документация, комментарии, сообщения коммитов — русский. - Код и идентификаторы — английский. -- Текст, который видит пользователь Telegram, — русский. +- Текст, который видит пользователь сервиса, — русский. - **Точного числа накопленного в документах нет.** «Три capability», «пять прогонов ревью», «две типизированные ошибки» расходятся с действительностью на первой же задаче, которая прибавит четвёртую, — и расходятся молча: машина diff --git a/README.md b/README.md index 5999e7b..822f317 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,9 @@ # Transcriber Service -Сервис расшифровки аудиозаписей. Два входа — Telegram-бот и HTTP API. +Сервис расшифровки аудиозаписей. Вход один — HTTP API. ## Возможности -- Приём аудио из Telegram: голосовые сообщения, аудиофайлы и документы с аудио - Приём аудиофайлов через HTTP API - Конвертация в ogg через ffmpeg - Распознавание речи через Yandex SpeechKit @@ -15,7 +14,6 @@ - **Язык**: Go 1.26, CGO не нужен - **Веб-фреймворк**: gin-gonic/gin -- **Telegram**: go-telegram-bot-api - **Распознавание**: Yandex SpeechKit + Yandex Object Storage (S3) - **Конвертация**: ffmpeg - **Хранилище, файлы и панель**: встроенная PocketBase @@ -41,12 +39,11 @@ Сервер запустится на порту из `[server] port`, по умолчанию 8080. Нужен установленный `ffmpeg`. -### Белый список Telegram +### Кого пускают -Бот отвечает только тем, кто перечислен в конфиге. Кого и по какому признаку он -пускает — [docs/security.md](docs/security.md), «Что разграничивает доступ»; -известные прорехи образца конфига, включая недостающий ключ белого списка, — -[docs/conventions/config.md](docs/conventions/config.md). +Приём и опрос закрыты сессией OIDC. Что её выдаёт и чем она предъявляется — +[docs/security.md](docs/security.md), «Что разграничивает доступ»; известные +прорехи образца конфига — [docs/conventions/config.md](docs/conventions/config.md). ## Деплой @@ -94,13 +91,11 @@ transcriber/ │ ├── service/ # Конвейер расшифровки │ ├── controller/ │ │ ├── http/ # HTTP-обработчики -│ │ ├── tg/ # Telegram-бот │ │ └── worker/ # Фоновые воркеры │ └── adapter/ │ ├── converter/ffmpeg/ # Конвертация аудио │ ├── metaviewer/ffmpeg/ # Длительность аудио │ ├── recognizer/yandex/ # SpeechKit + Object Storage -│ ├── telegram/ # Отправка сообщений │ └── repo/pocketbase/ # Репозитории, схема коллекций, правила панели └── data/ # Каталог данных: база и файлы записей вместе ├── data.db # База хранилища (создаётся автоматически) @@ -109,7 +104,7 @@ transcriber/ ## Хранилище -Две коллекции, `files` и `transcribe_jobs`. Поля, ключи, правило времени и +Коллекции хранилища — аудиозапись и её приложения. Поля, ключи, правило времени и идентификаторов, а также механика захвата задачи воркером — [docs/database.md](docs/database.md). Панель владельца — по адресу `/_/` того же порта; пароль от неё задаёт сам владелец по приглашению, которое сервис печатает diff --git a/config.example.toml b/config.example.toml index f5f956c..aba410d 100644 --- a/config.example.toml +++ b/config.example.toml @@ -85,41 +85,3 @@ redirect_url = "https://transcriber.example.com/auth/callback" # Признак `Secure` у куки сессии. Умолчание true; false только для локального # запуска по http://localhost, где браузер такую куку не сохранит secure_cookie = true - -# Telegram Bot Configuration -[telegram] -# Нужен ли сервису вход Telegram. Ключ **обязателен**: умолчания у него нет, и -# файл без него негоден — сервис выходит с ошибкой настройки, назвав недостающий -# ключ. Умолчание было бы угаданным намерением, а признак заведён затем, чтобы -# намерение объявляли: любое умолчание делает одну из двух ошибок тихой — либо -# бот молча пропадает, либо файл без признака молча работает. -# -# false — сервис поднимается без Telegram и работает одним входом, по HTTP. Бот -# не заводится, к Telegram не уходит ни одного обращения, записи из Telegram не -# принимаются, а ответы на задачи, принятые оттуда прежде, не уходят — -# недоставка видна записью журнала, расшифровка достаётся из панели и по HTTP. -# О выключенном входе сервис говорит одной записью журнала «к сведению»: это -# выбор владельца, а не отклонение. -# -# true — сервис поднимает бота. Пустой bot_token при этом роняет старт: бота по -# пустому ключу не существует. Старт роняет и ответ Telegram «такого бота нет» — -# это опечатка в ключе, ждать тут нечего. А вот недоступность Telegram (сеть, -# DNS, авария Bot API) подъёму не мешает: сервис встаёт без бота и предупреждает -# записью журнала, потому что основной вход у него другой. -# -# Локальный прогон идёт с false — боевым токеном запускаться запрещено: второй -# процесс с тем же токеном перехватывает обновления у работающего. Выключенного -# входа для подъёма мало: секции [auth] и [yandex] проверяются на старте и -# роняют процесс на пустых ключах. Наружу при старте не ходит ни одна из них, -# поэтому для локального прогона годятся выдуманные непустые значения — адреса -# [auth] должны лишь разбираться как ссылки. Расшифровка при выдуманных ключах -# не работает: её подменяют в коде. -enabled = false - -# Токен Telegram бота (получить у @BotFather в Telegram). Только ключ доступа: -# включением входа он больше не заведует, этим занят enabled выше. При -# enabled = false не читается вовсе. -bot_token = "" - -# Таймаут обновлений Telegram бота (в секундах) -update_timeout = 10 diff --git a/docs/adr/ADR-2026-08-13-telegram-intent-declared-not-inferred.md b/docs/adr/ADR-2026-08-13-telegram-intent-declared-not-inferred.md index e537541..7331616 100644 --- a/docs/adr/ADR-2026-08-13-telegram-intent-declared-not-inferred.md +++ b/docs/adr/ADR-2026-08-13-telegram-intent-declared-not-inferred.md @@ -2,6 +2,7 @@ - **Дата:** 2026-08-13 - **Источник:** openspec/changes/archive/2026-08-13-telegram-enabled-flag/design.md +- **Статус:** устарело — вход Telegram убран решением [ADR-2026-08-15-telegram-intake-removed-temporarily](ADR-2026-08-15-telegram-intake-removed-temporarily.md); довод устоял и понадобится возврату входа ## Решение diff --git a/docs/adr/ADR-2026-08-13-telegram-outage-does-not-block-startup.md b/docs/adr/ADR-2026-08-13-telegram-outage-does-not-block-startup.md index 66eba06..7adf3ea 100644 --- a/docs/adr/ADR-2026-08-13-telegram-outage-does-not-block-startup.md +++ b/docs/adr/ADR-2026-08-13-telegram-outage-does-not-block-startup.md @@ -2,6 +2,7 @@ - **Дата:** 2026-08-13 - **Источник:** openspec/changes/archive/2026-08-13-start-without-telegram-token/design.md +- **Статус:** устарело — вход Telegram убран решением [ADR-2026-08-15-telegram-intake-removed-temporarily](ADR-2026-08-15-telegram-intake-removed-temporarily.md); довод устоял и понадобится возврату входа ## Решение diff --git a/docs/adr/ADR-2026-08-15-owner-required-by-schema.md b/docs/adr/ADR-2026-08-15-owner-required-by-schema.md new file mode 100644 index 0000000..f630ce8 --- /dev/null +++ b/docs/adr/ADR-2026-08-15-owner-required-by-schema.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)), +исключение исчезло вместе с ним, и владелец сервиса подтвердил, что записей без +владельца в боевой базе нет. + +Про непроверку существующих строк цитата источника: + +> **Проверяется это запросом, а не прогоном шага**, и разница выяснилась ревью с +> оракулом: хранилище держит обязательность связи проверкой записи при +> сохранении, а не ограничением таблицы. Смена признака на базе с ничьей записью +> проходит зелёным и такую запись оставляет… Заставить шаг считать строки самому +> владелец решил не делать: безопасность держится ручной проверкой, и она названа +> первым шагом плана перехода. + +Правило «пустой владелец не совпадает ни с одной записью» при этом осталось и +избыточным не стало: схема запрещает **заводить** ничью запись, а правило — +**спрашивать** ничьим именем. + +## Последствия + +- `+` значения «владельца нет» не существует ни на одном уровне: ни в схеме, ни в + модели, ни в отборе. +- `+` дыра «ничью запись заводят руками в панели» закрыта тем же механизмом, что + и приём, — одним, а не двумя. +- `−` откат шага возвращает необязательность, но операционно недостижим: команд + библиотеки сервис не подключает, и это верно для всех шагов схемы проекта. +- `−` ничья запись, если её проглядят перед выкладкой, становится незакрываемой: + захват выдаёт её воркеру, а всякое сохранение — включая то, которым ставится + признак остановки, — отказывает. Следа не остаётся ни в метрике, ни в журнале + событий, только строка в логе контейнера. diff --git a/docs/adr/ADR-2026-08-15-removed-intake-has-no-metric-label.md b/docs/adr/ADR-2026-08-15-removed-intake-has-no-metric-label.md new file mode 100644 index 0000000..4761e69 --- /dev/null +++ b/docs/adr/ADR-2026-08-15-removed-intake-has-no-metric-label.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()`, а не сравнением. + Владельцу, если такой отбор был заведён, править его руками. +- `−` требование «различать поднятый и неподнятый» стало непроверяемым до + возвращения второго входа, и это сказано в самом требовании прямо. diff --git a/docs/adr/ADR-2026-08-15-telegram-intake-removed-temporarily.md b/docs/adr/ADR-2026-08-15-telegram-intake-removed-temporarily.md new file mode 100644 index 0000000..451bec8 --- /dev/null +++ b/docs/adr/ADR-2026-08-15-telegram-intake-removed-temporarily.md @@ -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). + Отправитель голосового не получит ни ответа, ни отказа. diff --git a/docs/adr/README.md b/docs/adr/README.md index 1c846e8..127350e 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -35,12 +35,15 @@ | Дата | Запись | Статус | | --- | --- | --- | +| 2026-08-15 | [Вход Telegram убран целиком, а не выключен признаком](ADR-2026-08-15-telegram-intake-removed-temporarily.md) | | +| 2026-08-15 | [Обязательность владельца держит схема, а не приём](ADR-2026-08-15-owner-required-by-schema.md) | | +| 2026-08-15 | [Метка убранного входа не выставляется вовсе, а не обнуляется](ADR-2026-08-15-removed-intake-has-no-metric-label.md) | | | 2026-08-14 | [Предел простоя остаётся часом, хотя он короче самой работы](ADR-2026-08-14-stuck-limit-stays-an-hour.md) | | | 2026-08-14 | [Ответ распознавателя хранится дословно, двоичной формой и вложением](ADR-2026-08-14-provider-payload-stored-verbatim.md) | | | 2026-08-14 | [Остановка записи — признак, а не рубеж](ADR-2026-08-14-halt-is-a-flag-not-a-stage.md) | | | 2026-08-14 | [Учётная запись с записями не удаляется, и это осознанный тупик](ADR-2026-08-14-account-with-records-is-not-deleted.md) | | -| 2026-08-13 | [Намерение объявляется признаком, а не выводится из ключа доступа](ADR-2026-08-13-telegram-intent-declared-not-inferred.md) | | -| 2026-08-13 | [Недоступность Telegram подъёму сервиса не мешает](ADR-2026-08-13-telegram-outage-does-not-block-startup.md) | | +| 2026-08-13 | [Намерение объявляется признаком, а не выводится из ключа доступа](ADR-2026-08-13-telegram-intent-declared-not-inferred.md) | устарело: вход убран [ADR-2026-08-15-telegram-intake-removed-temporarily](ADR-2026-08-15-telegram-intake-removed-temporarily.md) | +| 2026-08-13 | [Недоступность Telegram подъёму сервиса не мешает](ADR-2026-08-13-telegram-outage-does-not-block-startup.md) | устарело: вход убран [ADR-2026-08-15-telegram-intake-removed-temporarily](ADR-2026-08-15-telegram-intake-removed-temporarily.md) | | 2026-08-12 | [Файл записи закрыт защищённым полем и отдаётся вошедшему по токену файла](ADR-2026-08-12-protected-file-behind-session.md) | | | 2026-08-12 | [Сессия живёт семь суток и не продлевает саму себя](ADR-2026-08-12-session-without-refresh.md) | | | 2026-08-12 | [Кого пускать в сервис, решает правило провайдера, а не сервис](ADR-2026-08-12-access-delegated-to-provider.md) | | diff --git a/docs/architecture.md b/docs/architecture.md index 4e6fef4..f639569 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -17,16 +17,17 @@ - [intake](../openspec/specs/intake/spec.md) — **приём по HTTP плюс наличие входов**: приём и опрос за сессией, имя отправителя не доходит ни до хранилища, ни до журнала, метка метрики несёт только известное расширение, а - выключенный вход Telegram не мешает подъёму. Задачи + наблюдатель видит единственный поднятый вход. Задачи `http-handler-tests-never-green` и `no-user-filename-in-log` 2026-08-11, `pocketbase-storage` и `oidc-login` 2026-08-12, - `local-run-without-telegram-token` 2026-08-13. Приём из Telegram по существу — - кто допущен и как забирается запись — здесь по-прежнему не описан; + `local-run-without-telegram-token` 2026-08-13, `remove-telegram-intake` + 2026-08-14; - [pipeline](../openspec/specs/pipeline/spec.md) — пустой прогон воркера, захват - задачи и срок его протухания, число попыток, состояние «мертва», пауза перед - повтором и недоставленный ответ отправителю: задачи - `errors-as-instead-of-typecast` 2026-08-11, `pocketbase-storage` 2026-08-12 и - `local-run-without-telegram-token` 2026-08-13. Переходы состояний и отмена + задачи и срок его протухания, число попыток, остановка признаком, пауза перед + повтором и молчание конвейера наружу: задачи + `errors-as-instead-of-typecast` 2026-08-11, `pocketbase-storage` 2026-08-12, + `local-run-without-telegram-token` 2026-08-13 и `remove-telegram-intake` + 2026-08-14. Переходы состояний и отмена контекста посреди шага остаются долгом; что именно не описано, перечисляет раздел `Purpose` самой спеки; - [storage](../openspec/specs/storage/spec.md) — где живут запись, её метаданные @@ -40,17 +41,17 @@ - [access](../openspec/specs/access/spec.md) — кто пришёл в сервис и пускают ли его дальше: вход через внешнего провайдера OIDC, чем предъявляется сессия, что её прекращает и какие адреса остаются открытыми. Задача `oidc-login` - 2026-08-12. Здесь же разграничение записей по владельцу: запись из веба - принадлежит тому, кто её принёс, чужая неотличима от несуществующей, а запись - из Telegram владельца не имеет и по API не достаётся никому. Задача - `record-ownership` 2026-08-14. + 2026-08-12. Здесь же разграничение записей по владельцу: принятая запись + принадлежит тому, кто её принёс, чужая неотличима от несуществующей, а ничьей + записи не бывает вовсе — колонка владельца пустого значения не принимает. + Задачи `record-ownership` и `remove-telegram-intake` 2026-08-14. -Поведение прочих узлов, включая приём из Telegram, по-прежнему живёт только в -коде. Задача, которая его трогает, дописывает спеку своей capability. +Поведение узла, которого нет в перечне выше, по-прежнему живёт только в коде. +Задача, которая его трогает, дописывает спеку своей capability. ## Принципы -- **Один процесс.** Бот, HTTP-сервер и фоновые воркеры живут в одном бинарнике и +- **Один процесс.** HTTP-сервер и фоновые воркеры живут в одном бинарнике и делят одну базу. Отдельного воркер-процесса нет намеренно. - **Очередь таблицей.** Состояние задачи лежит коллекцией хранилища; неделимость захвата и порядок выборки нормирует @@ -64,7 +65,7 @@ [pipeline](../openspec/specs/pipeline/spec.md), «Брошенная задача возвращается в работу»; здесь это принцип письма шага, а не описание поведения. - **Ядро зависит от интерфейсов.** `internal/service` знает только - `internal/contract`; ffmpeg, Yandex, Telegram и хранилище подставляются в + `internal/contract`; ffmpeg, Yandex и хранилище подставляются в `main.go`. Правило механизировано тестами-сканерами `internal/archrules`, и они же держат обратные направления: транспорты не знают друг о друге, адаптер не знает ни ядра, ни транспортов. @@ -77,17 +78,15 @@ Каждый — строкой со ссылкой на capability, а не пересказом её требований. - + | Компонент | Где | Что делает | | --- | --- | --- | -| Telegram-бот | `internal/controller/tg` | Принимает голосовые, аудиофайлы и документы с аудио, скачивает их, заводит задачу | | HTTP API | `internal/controller/http` | Приём файла и опрос статуса задачи | | Воркеры | `internal/controller/worker` | Пул одинаковых потоков: каждый берёт любую пригодную запись и опрашивает базу. Число — настройкой, ноль законен | | Сервис расшифровки | `internal/service` | Конвейер: приём, приведение, отправка, опрос, завершение. Шаг выбирается по рубежу записи | | Конвертер и метаданные | `internal/adapter/{converter,metaviewer}/ffmpeg` | `ffmpeg` в ogg/vorbis, `ffprobe` для длительности | | Распознаватель | `internal/adapter/recognizer/yandex` | Заливка в Object Storage и отложенное распознавание SpeechKit; разбор ответа в реплики со временем | -| Отправитель Telegram | `internal/adapter/telegram` | Отправка текста, деление длинного по словам | | Репозитории | `internal/adapter/repo/pocketbase` | Записи, файлы, тексты, структура, попытки распознавания и журнал событий — коллекциями хранилища; захват — сырым запросом | | Шаги схемы | `internal/adapter/repo/pocketbase/migrations` | Файл на шаг, имя файла — имя шага; там же имена коллекций | | Панель владельца | `internal/adapter/repo/pocketbase`, `panel.go` | Панель хранилища; правила правки записи нормирует [storage](../openspec/specs/storage/spec.md), «Владелец видит записи в панели» | @@ -102,12 +101,6 @@ ## Внешние границы и форматы -- **Telegram Bot API.** Вход — обновления длинным опросом, выход — сообщения. - Файл скачивается по ссылке `file.Link(token)` запросом с контекстом, клиентом - самого бота. Клиента заводит единая точка `internal/adapter/telegram`: токен - стоит в пути каждого обращения, и снятие адреса с отказа живёт там — - [conventions/logging.md](conventions/logging.md), «Безопасность: что не - логируем». Telegram не отдаёт файлы больше 20 МиБ — это потолок приёма из бота. - **Yandex Object Storage.** S3-совместимый, клиент `aws-sdk-go-v2` с `UsePathStyle`. Ключ объекта — имя файла, то есть UUID с расширением. - **Yandex SpeechKit v3.** gRPC, `stt.api.cloud.yandex.net:443`, модель @@ -121,26 +114,12 @@ - **Где работает, что рядом, кто перезапускает:** один контейнер на личном сервере, разворачивает и перезапускает Ansible из `pet-project-server`. Рядом — обратный прокси, который публикует HTTP-порт наружу. -- **Порядок выкладки задаётся по ключу, а не по файлу целиком.** Общего правила - «сперва образ» или «сперва конфиг» нет: два ключа секции Telegram требуют - противоположного, и оба правила действуют одновременно. - - **Признак включения `telegram.enabled` едет в конфиг раньше образа.** Он - обязателен с 2026-08-13, умолчания у него нет, и образ, который его ждёт, - без него выходит с кодом 1 **до** открытия порта — вместе с HTTP, панелью и - конвейером. Прежний образ лишний ключ TOML просто не читает, поэтому ранняя - правка конфига безопасна, а поздняя роняет сервис. - - **Пустой ключ доступа `telegram.bot_token` едет позже образа.** Образы - старше 2026-08-13 роняли старт на пустом ключе, тоже до открытия порта. - - **Откат при выключенном входе** допустим только на образ от 2026-08-13 и - новее. На более старом состояния «сервис поднят, бот опущен» не существует - вовсе: пустой ключ роняет старт, негодный роняет старт, годный поднимает - бота. Откат туда делают с непустым годным ключом, приняв, что бот поднимется. - - **Откат образа при `enabled = false` и заполненном ключе** отменяет решение - владельца молча: прежний образ признака не видит и поднимает бота. Если вход - был выключен потому, что бот с этим токеном поднят где-то ещё, два процесса - поделят один длинный опрос и часть ответов до людей не дойдёт. - - Ревью кода воспроизвело порядок на прежней версии, живой прогон — на нынешней. +- **Порядок выкладки: конфиг после образа.** Прежде здесь стояло правило, + разное для двух ключей секции Telegram; с убранным входом оно потеряло предмет + целиком. Оставшиеся ключи, которых новый образ ждёт, в конфиге уже есть. + Секцию `[telegram]` и ключ `server.users_while_list` человек убирает из боевого + файла после выкладки: незнакомые ключи разбор настроек не судит, и файл с ними + сервис поднимает молча. - **Откат образа через шаг схемы `202608140002` не работает и не говорит об этом.** Шаг удаляет прежнюю коллекцию задач, а библиотека накатывает только те шаги, которые знает сам бинарь: прежний образ шагов новее не видит, @@ -160,16 +139,15 @@ | Зависимость | Падает | Отвечает медленно | Молчит | Отдаёт мусор | | --- | --- | --- | --- | --- | - | Telegram Bot API | Сервис поднимается без Telegram и работает по HTTP; старт роняют только ошибки настройки — ответ «такого бота нет» и включённый вход с пустым ключом доступа. Норму держит [intake](../openspec/specs/intake/spec.md), «Признак включения решает, поднимается ли вход Telegram» | На старте — ждём не дольше срока, дальше поднимаемся без Telegram. У поднятого сервиса скачивание файла висит бесконечно: там срока нет | То же, что «отвечает медленно»: на старте — подъём без Telegram по истечении срока, у поднятого — длинный опрос пуст и новые задачи не заводятся | Файл скачался битым, отказ вылезет на конвертации | - | Yandex SpeechKit | Шаг возвращает ошибку, запись остаётся на повтор | Захват держится час, запись не двигается; по истечении предела простоя она останавливается с причиной «застряла», не теряя идентификатора операции | Операция вечно `in progress`, повтор каждые 5 секунд — до предела простоя в сутки | Пустой текст — запись завершается заглушкой «на записи нет текста» | + | Yandex SpeechKit | Шаг возвращает ошибку, запись остаётся на повтор | Захват держится час, запись не двигается; по истечении предела простоя она останавливается с причиной «застряла», не теряя идентификатора операции | Операция вечно `in progress`, повтор каждые 5 секунд — до предела простоя в сутки | Пустой текст — запись доходит до конечного рубежа без расшифровки, и в журнале стоит запись «может стать проблемой» с идентификатором записи; опрос готовности отдаёт рубеж `done` без поля текста | | ↳ *остановка сервиса* | Принятие операции от отмены защищено своим пределом в 10 секунд: операцию там могли принять и начать считать деньги, а потерянный идентификатор заставил бы повтор оплатить ту же запись второй раз. Заливка в Object Storage отменяется штатно — её повтор бесплатен, объект ложится под тем же ключом | — | — | — | | Yandex Object Storage | Заливка падает, запись остаётся на рубеже `normalized` | То же, что падение: висит до конца захвата | — | SpeechKit не прочитает объект и вернёт отказ операции | | ffmpeg, ffprobe | Запись останавливается признаком с текстом «сбой конвертации файла» — рубеж при этом сохраняется, и снятие признака продолжает с него. Остановка сервиса — исход другой: процесс убивают контекстом, запись остаётся на повтор и отказа не тратит | Конвейер стоит: вызов синхронный | — | Выходной файл пуст, отказ вылезет на распознавании | | Хранилище (файл на диске) | Приложение не стартует либо шаг падает на каждом запросе | Блокировка записи держит воркеры | — | — | | Диск | Запись файла падает, задача не заводится | — | — | — | -- **Кто заметит отказ и когда:** пользователь Telegram — сразу, по молчанию бота - или по сообщению об ошибке. Владелец — по метрике +- **Кто заметит отказ и когда:** тот, кто загрузил запись, — опросом готовности: + остановленная запись отдаёт признак остановки. Владелец — по метрике `transcriber_worker_job_count` с меткой `error="true"`, и метка `stage` называет рубеж, с которого запись взята: с появлением пула одинаковых воркеров имя потока перестало что-либо значить, а разрез по шагу — единственное, чем @@ -178,15 +156,15 @@ - **Журнал событий записи** — второй канал наблюдения, `record_events`. Пишется на смену рубежа, на остановку и на снятие остановки; читает его человек в панели, ни один шаг конвейера на него не смотрит. Экрана у него пока нет. -- **Характер потока:** непрерывный, но разреженный. Бот держит длинный опрос, - воркеры опрашивают базу вхолостую с паузой из +- **Характер потока:** непрерывный, но разреженный. Воркеры опрашивают базу + вхолостую с паузой из [database.md](database.md), «Настройки с числовым значением». ## Единые точки проекта | Что | Где | | --- | --- | -| Приём аудио и заведение записи | `TranscribeService.createRecord` — через него идут оба входа | +| Приём аудио и заведение записи | `TranscribeService.createRecord` — единственный путь, которым запись появляется в хранилище | | Правка записи владельцем | панель хранилища; правка запросом проходит правила перехода (`pocketbase.BindPanelRules`), а шаг конвейера пишет только свои поля и правку владельца не стирает | | Захват записи воркером | `AudioRecordRepository.FindAndAcquire` — один запрос с `RETURNING`, отдаёт идентификатор и признак захвата | | Объявление рубежа | `internal/entity/stage.go` — выбор шага, отбор захвата, срок протухания и предел простоя выводятся отсюда | @@ -194,7 +172,7 @@ | Рабочая копия файла на диске | `FileRepository.Localize`, `Stage`, `StageEmpty` — они же дают единственный способ её убрать (`WorkFile.Close`); зовёт его шаг | | Переход записи на рубеж | `entity.AudioRecord.MoveToState` — чистит служебные поля прошлого рубежа и ставит время входа | | Откладывание работы | `entity.AudioRecord.Postpone` — ставит паузу и снимает захват, рубежа не трогая | -| Остановка и перезапуск | `entity.AudioRecord.Halt` и `Resume`; ответ отправителю — `TranscribeService.halt`, одно место на все причины | +| Остановка и перезапуск | `entity.AudioRecord.Halt` и `Resume`; запись причины и события — `TranscribeService.halt`, одно место на все причины | | Разбор конфигурации | `internal/config.LoadConfig` | | Чтение времени | `internal/clock` — `Now` даёт метку в UTC, `Start` — начало измерения длительности; `time.Now` вне пакета запрещён правилом линтера | | Метрики | `internal/metrics`, префикс имени `transcriber_` | @@ -224,7 +202,8 @@ [access](../openspec/specs/access/spec.md), решения — [ADR-2026-08-12-session-without-refresh](adr/ADR-2026-08-12-session-without-refresh.md) и [ADR-2026-08-12-oidc-exchange-via-own-route](adr/ADR-2026-08-12-oidc-exchange-via-own-route.md). - **Не решено одно:** как связываются пользователь Telegram и пользователь веба. + **Не решено одно:** как связать чат Telegram с учётной записью — от этого + зависит возвращение убранного входа. Панель администратора при этом Authelia не закрывает: у неё свой пароль суперпользователя. - **Приложение.** Экранов нет вовсе, есть только API. Решено делать SPA, @@ -240,8 +219,8 @@ Push с VAPID отвергнут. Появляется внешняя зависимость, которой сегодня нет, и текст расшифровки начинает уходить на сторону — сдвиг периметра [security.md](security.md). -- **Долгие записи.** Потолок сегодня неизвестен и не замерялся: 20 МиБ на приём - из Telegram — точно, ограничения `deferred-general` по длине — нет. Расчётные +- **Долгие записи.** Потолок сегодня неизвестен и не замерялся: ограничения + `deferred-general` по длине не выяснены. Расчётные шесть часов нормирует [storage](../openspec/specs/storage/spec.md), «Файл записи живёт в хранилище»; откуда взято число — [research/pocketbase-defaults.md](research/pocketbase-defaults.md), «Чего эта @@ -265,8 +244,9 @@ - **Формат для распознавания.** Конвертер отдаёт ogg/vorbis (`libvorbis`), а SpeechKit получает `ContainerAudio_OGG_OPUS`. Расхождение не разобрано: то ли сервис определяет содержимое сам, то ли часть записей теряется на этом. -- **Видео.** Дорожку из видеофайла бот принимает по MIME-типу `video/`, но - конвертер этот случай не проверялся. +- **Видео.** Дорожка из видеофайла к приёму допускается — расширение он берёт из + имени и о годности содержимого спрашивает источник метаданных, — но конвертер + на этом случае не проверялся. - **Очередь.** Модель очереди сделана задачей `pocketbase-storage` 2026-08-12 ([ADR](adr/ADR-2026-08-11-queue-as-pocketbase-collection.md)), перестроена вокруг аудиозаписи задачей `record-centric-model` 2026-08-14 и нормирована diff --git a/docs/conventions/config.md b/docs/conventions/config.md index 257f5a2..a7393f3 100644 --- a/docs/conventions/config.md +++ b/docs/conventions/config.md @@ -5,7 +5,7 @@ **Взято из проекта jellybit.** Расхождения с сегодняшним кодом названы по месту. Главные: комментариями снабжена половина полей; единого места проверки на старте -нет: у секций `[auth]` и `[telegram]` свой `Validate()` в `main.go`, а пустые +нет: у секций `[auth]` и `[pipeline]` свой `Validate()` в `main.go`, а пустые ключи `[yandex]` ловит конструктор распознавателя. **Механизировано:** запрет `os.Getenv` — `forbidigo` в `.golangci.yml` @@ -46,7 +46,6 @@ port = # порт HTTP-сервера shutdown_timeout = # ждать мягкой остановки сервера, секунды force_shutdown_timeout = # ждать остановки воркеров, секунды -users_while_list = ["<@name>"] # кому отвечает бот; строка автора Telegram ``` Значения намеренно заменены плейсхолдерами: предмет конвенции — форма @@ -58,9 +57,6 @@ users_while_list = ["<@name>"] # кому отвечает бот; стр Секретные поля оставляем пустыми — значение приходит из выкладки (см. «Секреты»). -*Расхождение:* секции `[server]` в `config.example.toml` не хватает поля -`users_while_list`, из-за чего бот на свежем конфиге отвечает отказом всем. - *Расхождение:* адреса провайдера в секции `[auth]` образца заполнены примерами вида `https://auth.example.com/...`, а не оставлены пустыми: пустой адрес не говорит, какой формы значение здесь ждут. Пустым оставлен только @@ -91,7 +87,7 @@ Ansible из `pet-project-server`). Приложение просто читае секретов в коде нет. Источник истины секрета — внешнее хранилище выкладки, не репозиторий и не окружение. -- Секретные поля transcriber: `telegram.bot_token`, `yandex.speech_kit_api_key`, +- Секретные поля transcriber: `yandex.speech_kit_api_key`, `yandex.object_storage_access_key_id`, `yandex.object_storage_secret_access_key`, `auth.client_secret`. - Отрендеренный `config.toml` (с секретами) **не коммитится**; права `0600`, @@ -130,16 +126,8 @@ Ansible из `pet-project-server`). Приложение просто читае TOML. Пустые ключи Yandex ловятся в конструкторе распознавателя, и там процесс выходит с кодом 1. Единого места проверки нет. -Под это расхождение больше не подпадают два ключа секции `[telegram]` — признак -включения и ключ доступа, — и проверок у них две. Третий ключ секции, -`update_timeout`, границ по-прежнему не проверяет никто, и ноль в нём обращает -длинный опрос в непрерывный. Обязательность признака включения судит загрузчик — только разбор отличает -«ключ не задан» от «ключ задан ложным», потому что нулевое значение `bool` у -обоих одинаковое. Заполненность ключа доступа судит `TelegramConfig.Validate()` из -`main.go`, рядом с проверкой `[auth]`: пустой `bot_token` при `enabled = true` — -ошибка настройки и отказ старта. Непустой негодный по-прежнему судится при сборке -клиента, до подъёма сервера. Нормирует это `openspec/specs/intake`, «Признак -включения решает, поднимается ли вход Telegram». +Два ключа секции `[telegram]`, стоявшие здесь исключением, ушли вместе с самим +входом 2026-08-14: секции больше нет, и своей проверки у неё тоже. Секция `[auth]` — первая, у которой проверка своя и стоит на старте: `AuthConfig.Validate()` зовётся из `main.go` сразу после загрузки и роняет @@ -165,5 +153,6 @@ TOML. Пустые ключи Yandex ловятся в конструкторе комментария у самого поля), ни по нулевому значению типа; присутствие ключа судит **разбор** — `MetaData.IsDefined` из `toml.DecodeFile`, — потому что значение отличить «не задано» от «задано нулём» не позволяет. В - `config.example.toml` у поля стоит значение свежей установки. Первое такое - поле — `telegram.enabled`. + `config.example.toml` у поля стоит значение свежей установки. Первым таким + полем был `telegram.enabled`; секция убрана 2026-08-14, и живого примера у + правила сейчас нет. diff --git a/docs/conventions/errors.md b/docs/conventions/errors.md index ca8ba84..227541a 100644 --- a/docs/conventions/errors.md +++ b/docs/conventions/errors.md @@ -69,11 +69,10 @@ transcriber — **приложение, а не библиотека**: внеш `contract.JobNotFoundError` (состояние и сообщение), `contract.NoopJobError` (состояние), `contract.LostAcquisitionError` (идентификатор задачи). -`tg.EmptyBotTokenError` был ровно тем случаем, против которого написано правило — -тип без полей, — и снят задачей `local-run-without-telegram-token` 2026-08-13; -его место занял sentinel `telegram.ErrEmptyToken`. Рядом живёт -`contract.ErrDeliveryChannelDown` — тоже sentinel и по той же причине: заглушка -отправителя не знает ни задачи, ни чата, и нести ей нечего. +Правило это однажды нарушал `tg.EmptyBotTokenError` — тип без полей, — и был +снят задачей `local-run-without-telegram-token` 2026-08-13 в пользу sentinel'а. +Оба ушли из проекта 2026-08-14 вместе с входом Telegram; пример остаётся здесь +как случай, а не как живой код. ## Граница и трансляция: приватный и публичный канал @@ -83,8 +82,8 @@ transcriber — **приложение, а не библиотека**: внеш - **Приватный канал — логи** (владелец сервиса). Полная ошибка со всей цепочкой `%w` и контекстом. Пишется один раз на доменной границе — см. [logging.md](logging.md). -- **Публичный канал — пользовательские поверхности** (Telegram, веб-UI, HTTP - API). Сюда отдаём: +- **Публичный канал — пользовательские поверхности** (веб-UI, HTTP API). Сюда + отдаём: - **человекочитаемое сообщение** по доменной ошибке — не сырой `err.Error()` и не детали реализации (`database/sql`, пути на диске, имена внешних сервисов); - **корреляционный ключ** для владельца — идентификатор задачи, чтобы по нему @@ -118,8 +117,7 @@ transcriber — **приложение, а не библиотека**: внеш У публичной границы две поверхности, и правило сырого текста для них разное. -- **Разовый ответ на действие** (тело HTTP-ответа, сообщение бота по результату - команды) — строго нейтральный: отображение выше, `err.Error()` наружу не идёт, +- **Разовый ответ на действие** (тело HTTP-ответа) — строго нейтральный: отображение выше, `err.Error()` наружу не идёт, полная ошибка остаётся в логах по идентификатору задачи. - **Сохранённая диагностика состояния** — колонка `error_text` задачи. Это **поверхность владельца**, а не пользователя: сюда сырой текст ошибки допустим @@ -131,9 +129,9 @@ transcriber — **приложение, а не библиотека**: внеш - **внешнее значение в тексте усекается на границе, а его размер называется числом рядом**: без этого непонятно, насколько сокращать. - *Расхождение:* `error_text` пишется целиком, без вычистки и без усечения, а - пользователь Telegram видит отдельный человекочитаемый текст — это часть - правила соблюдена. + *Расхождение:* `error_text` пишется целиком, без вычистки и без усечения. + Наружу он при этом не выходит: опрос готовности отдаёт признак остановки без + машинного текста — эту часть правила держит спека `intake`. ## panic @@ -145,8 +143,8 @@ transcriber — **приложение, а не библиотека**: внеш ронял процесс. В transcriber его вешает роутер хранилища сам (`apis.panicRecover`, слой с идентификатором `DefaultPanicRecoverMiddlewareId` на каждом роутере PocketBase): паникующий обработчик отдаёт `500`, процесс - живёт. Своего слоя мы не пишем. У воркеров и у бота такой границы **нет**: - паника в шаге конвейера роняет процесс целиком. + живёт. Своего слоя мы не пишем. У воркеров такой границы **нет**: паника в + шаге конвейера роняет процесс целиком. ## Несколько ошибок diff --git a/docs/conventions/go-linters.md b/docs/conventions/go-linters.md index 19a11ac..d312411 100644 --- a/docs/conventions/go-linters.md +++ b/docs/conventions/go-linters.md @@ -78,7 +78,7 @@ | --- | --- | | Сравнение ошибок через `errors.Is` и `errors.As`, не `==` и не приведением типа | `.golangci.yml` → `errorlint` | | Ошибка не узнаётся сравнением текста сообщения (`strings.Contains(err.Error(), …)`, `err.Error() == …`) | `internal/archrules` → `TestОшибкаНеУзнаётсяПоТексту` | -| Непроверенное возвращаемое значение ошибки | `.golangci.yml` → `errcheck`, включая присваивание в `_` (`check-blank`). Отказ, который решено не проверять, объявляют в `exclude-functions` поимённо — там сегодня `defer Close`, `os.Remove` и `send` | +| Непроверенное возвращаемое значение ошибки | `.golangci.yml` → `errcheck`, включая присваивание в `_` (`check-blank`). Отказ, который решено не проверять, объявляют в `exclude-functions` поимённо — там сегодня `defer Close` и `os.Remove` | | Непроверенное приведение типа (`v := x.(T)`) | `.golangci.yml` → `errcheck` с `check-type-assertions`. Отдельная настройка, потому что такое приведение паникует, а не возвращает ошибку, и `check-blank` его не видит | | Проверенный отказ не оборачивается в `return nil` | `.golangci.yml` → `nilerr`. Механизирует половину инварианта «принятая запись не теряется молча»: молчаливый успех после отказа | | Отказ выборки из хранилища не теряется (`rows.Err()`), а сама выборка закрывается | `.golangci.yml` → `rowserrcheck`, `sqlclosecheck`. **Профилактические: предмета в коде сегодня нет** — выборки идут через `dbx` хранилища, а из `database/sql` употребляются только `sql.NullString` и `sql.ErrNoRows`. Правила заведены на будущий сырой запрос; мутацией проверены на пробе, а не на своём коде | @@ -89,7 +89,7 @@ | Правило | Где механизировано | | --- | --- | | Ядро (`internal/service`) не знает ни адаптеров, ни транспортов | `internal/archrules` → `TestЯдроНеЗнаетОбАдаптерах`, `TestЯдроНеЗнаетОТранспортах` | -| Транспорты (`controller/http`, `controller/tg`, `controller/worker`) не знают друг о друге | `internal/archrules` → `TestТранспортыНеЗнаютДругОДруге` | +| Транспорты (`controller/http`, `controller/worker`) не знают друг о друге | `internal/archrules` → `TestТранспортыНеЗнаютДругОДруге` | | Адаптер не знает ни ядра, ни транспортов | `internal/archrules` → `TestАдаптерыНеЗнаютНиЯдра_НиТранспортов` | | Колонки записи согласованы: что пишет отображение ↔ что читает обратное ↔ что заводит шаг схемы | `internal/archrules` → правила о колонках. Закрывает инвариант «колонки записи правятся в двух местах» (CLAUDE.md, major), которого компилятор не держит. Литерал колонки ищется в телах нужных функций, а не в файле целиком | | Рубежи согласованы: дескриптор ↔ таблица выбора шага, в обе стороны | `internal/archrules` → правила о рубежах. Закрывает инвариант «рубеж объявляется одним дескриптором» (CLAUDE.md, major). Рубеж без шага останавливает запись, не начав работы; шаг без рубежа недостижим — захват такую запись не выдаст никогда | @@ -117,7 +117,7 @@ | --- | --- | | Проверка судит ответ по готовому ответу (`Result()`), а не по живой карте заголовков обработчика | `.golangci.yml` → `forbidigo` с `analyze-types`, находки только в `*_test.go`. Судит по типу приёмника (`httptest.ResponseRecorder`), поэтому ловит любую форму: цепочкой, через переменную, по индексу карты, обходом, полем `HeaderMap`. Остаётся ревью проверка, идущая мимо recorder — через свой `http.ResponseWriter` | | Форма утверждения в проверках: «ожидалось» и «получено» не перепутаны местами, отказ судится `NoError`, а не `Nil`, `require` не зовут из горутины | `.golangci.yml` → `testifylint` | -| Одновременный доступ проверен детектором, а не чтением кода | `Taskfile.yml` → шаг `tests` (`go test -race ./...`). Общее у воркеров — счётчики метрик, логгер и клиент бота; захват задачи в гонку не входит, он по построению её не даёт (одно состояние на воркер) — см. «Типовые ложноположительные» в [../review.md](../review.md). Что делает шаг без компилятора C и каким кодом краснеет — [CLAUDE.md](../../CLAUDE.md), «Гейт» | +| Одновременный доступ проверен детектором, а не чтением кода | `Taskfile.yml` → шаг `tests` (`go test -race ./...`). Общее у воркеров — счётчики метрик и логгер; захват задачи в гонку не входит, он по построению её не даёт (одно состояние на воркер) — см. «Типовые ложноположительные» в [../review.md](../review.md). Что делает шаг без компилятора C и каким кодом краснеет — [CLAUDE.md](../../CLAUDE.md), «Гейт» | | Строчное подавление называет линтер и причину, а протухшее краснеет | `.golangci.yml` → `nolintlint` (`require-explanation`, `require-specific`, `allow-unused: false`) | ### Форма кода и файлов вне Go @@ -152,7 +152,7 @@ | Подавлено | Где | Почему | | --- | --- | --- | -| `errcheck` на `defer Close`, `os.Remove` и `send` | `.golangci.yml`, `exclude-functions` | Отказ, который решено не проверять, объявляют поимённо — так он заметен | +| `errcheck` на `defer Close` и `os.Remove` | `.golangci.yml`, `exclude-functions` | Отказ, который решено не проверять, объявляют поимённо — так он заметен | | Правило о заголовках вне `*_test.go` | `.golangci.yml`, `exclusions` | В рабочем коде `Header()` и есть способ отдать заголовок | | `time.Now` внутри `internal/clock` | там же | Единой точке чтения времени нечем читать время иначе | | Чтение времени и окружения в `*_test.go` | там же | Проверка строит вход прогона — фикстуру времени, `PATH`, окружение дочернего процесса, — а не метку домена и не настройки приложения. Исключение объявлено по тексту сообщения: правило называет четыре имени, и исключение обязано покрывать те же четыре | diff --git a/docs/conventions/logging.md b/docs/conventions/logging.md index 783c1d3..ccde8a9 100644 --- a/docs/conventions/logging.md +++ b/docs/conventions/logging.md @@ -28,7 +28,7 @@ OpenSpec. `jq` без регулярных выражений. ```json -{"time":"2026-08-10T11:23:45.123456Z","level":"INFO","msg":"record accepted","capability":"intake","record_id":"…","source":"telegram","duration_seconds":137} +{"time":"2026-08-10T11:23:45.123456Z","level":"INFO","msg":"record accepted","capability":"intake","record_id":"…","source":"api","duration_seconds":137} ``` *Расхождение:* `main.go` ставит `slog.NewTextHandler(os.Stdout, …)`. @@ -37,7 +37,7 @@ OpenSpec. - `msg` — короткая константа в нижнем регистре: `record accepted`, `recognition done`, `conversion failed`. Данные — в атрибутах: - `log.Info("record accepted", "record_id", id, "source", "telegram")`. + `log.Info("record accepted", "record_id", id, "source", "api")`. - `msg` — чистая категория без префикса подсистемы: `recognition done`, а не `recognize: done`. Подсистему выносим в поле `capability`, не в текст. - **Смена состояния задачи — единая категория `state transition`** с полями @@ -60,7 +60,7 @@ OpenSpec. | `DEBUG` | разработчику при отладке; в продакшене выключен | `GET /health`, пустой прогон воркера, проверка готовности операции распознавания, тела запросов и ответов внешних сервисов | | `INFO` | владельцу, разбор постфактум | приём записи, переход задачи, конвертация выполнена, текст отправлен, старт и остановка процессов, **событийный вызов внешнего сервиса** | | `WARN` | владельцу, «может стать проблемой» | повтор внешнего вызова, задача досталась повторно по истечении захвата, пустой текст распознавания | -| `ERROR` | владельцу, в разбор | внешний сервис недоступен, задача ушла в `failed`, необработанная ошибка | +| `ERROR` | владельцу, в разбор | внешний сервис недоступен, запись остановлена признаком, необработанная ошибка | Правила: @@ -99,7 +99,7 @@ OpenSpec. | Когда добавляем | Поля | | --- | --- | -| на входящий HTTP-запрос | `transport` (`http`, `telegram`), `http.method`, `http.route`, `http.status_code`, `duration_ms` | +| на входящий HTTP-запрос | `transport` (`http`), `http.method`, `http.route`, `http.status_code`, `duration_ms` | | на задачу | `capability` (значения — по именам заведённых capability в `openspec/specs/`), `record_id`, `file_id`, `source` | | на запись об ошибке | `error` | | на вызов внешнего сервиса | `ext.service`, `ext.operation`, `ext.status_code`, `duration_ms`, `retry` | @@ -137,7 +137,7 @@ log := log.With("record_id", record.Id, "capability", "conversion") - Логируем ошибку **один раз — на границе доменного слоя**, которая определяет исход операции. Логирует эта единая точка, а не каждый транспорт — так транспорты остаются тонкими. Границы в transcriber: - - приём записи (`CreateJobFromTelegram`, `CreateJobFromApi`); + - приём записи (`CreateJobFromApi`); - **шаг конвейера** (`FindAndRunConversionJob`, `FindAndRunTranscribeJob`, `FindAndRunTranscribeCheckJob`) — исход шага, вызванного циклом воркера; - завершение и отказ задачи (`completeJob`, `failJob`). @@ -169,9 +169,9 @@ log := log.With("record_id", record.Id, "capability", "conversion") **Каждый** вызов внешнего сервиса логируется. Поля: -- `ext.service` — `telegram`, `speechkit`, `object-storage`, `ffmpeg`; -- `ext.operation` — логическая операция (`getFile`, `sendMessage`, - `RecognizeFile`, `GetOperation`, `PutObject`, `convert`); +- `ext.service` — `speechkit`, `object-storage`, `ffmpeg`; +- `ext.operation` — логическая операция (`RecognizeFile`, `GetOperation`, + `PutObject`, `convert`); - `ext.status_code` — код ответа, если применим; - `duration_ms` — длительность вызова; - `retry` — номер попытки, если повторы были. @@ -191,8 +191,7 @@ log := log.With("record_id", record.Id, "capability", "conversion") *Расхождение:* обёртки `ext.*` нет. Из внешних вызовов логируется только конвертация (через метрику длительности) и запуск распознавания; заливка в -Object Storage, скачивание файла из Telegram и опрос операции не логируются -никак. +Object Storage и опрос операции не логируются никак. ## HTTP и проверка здоровья @@ -218,7 +217,6 @@ Object Storage, скачивание файла из Telegram и опрос оп Никаких секретов в полях и сообщениях. Под запретом: -- токен бота Telegram; - ключ SpeechKit и заголовок `Authorization`; - пара ключей Object Storage; - **сам текст расшифровки и имена файлов пользователя** — это содержимое личной @@ -234,28 +232,17 @@ Object Storage, скачивание файла из Telegram и опрос оп - При сомнении не логируем значение, логируем факт его наличия (`"has_api_key", true`). - **Ошибка HTTP-транспорта несёт URL — возможный носитель секрета.** - `*url.Error` из `net/http` встраивает полный URL запроса, а токен Telegram - живёт прямо в пути (`…/bot/…`). Такую ошибку разворачивают в + `*url.Error` из `net/http` встраивает полный URL запроса, а секрет иногда + живёт прямо в пути. Такую ошибку разворачивают в первопричину на границе клиента **до** лога и до обёртки: URL отбрасывается, проверка `errors.Is` на причину сохраняется. Общее правило: **секрет не кладём в URL, если у сервиса есть заголовок** — тогда его нет и в ошибке транспорта. -Обращения к Telegram этому правилу следуют, и точка чистки одна на все вызовы — -`internal/adapter/telegram`, `NewBot`. Токен стоит в пути **каждого** обращения к -Bot API, поэтому чистка на месте употребления закрывала бы один вызов из всех: - -- отказ транспорта разворачивает в первопричину клиент бота (`safeClient`), а - библиотека отдаёт наш отказ вызывающему нетронутым — этим закрыты `getFile`, - `sendMessage`, скачивание записи и `getMe` из конструктора; -- длинный опрос печатает свои отказы **пакетным логгером самой библиотеки**, - минуя наш `slog`; логгер подменён на вычищающий (`tgbotapi.SetLogger`), и - замена точная — токен известен. - -Прежде здесь стоял `http.Get(file.Link(token))`, отказ уезжал в журнал вместе с -токеном, а конвенция числила это расхождением с оценкой «не логируется», которая -была неверной. Запись — [../review.md](../review.md), 2026-08-13; оракулом -служат проверки `internal/adapter/telegram/bot_test.go`, судящие по тексту -отказа и строке журнала. +Живого случая у этого правила сейчас нет: единственный секрет, стоявший в пути +обращения, — токен бота, и он ушёл вместе с входом Telegram 2026-08-14. Разбор +случая и цена промаха записаны в [../review.md](../review.md), 2026-08-13: +конвенция числила утечку расхождением с оценкой «не логируется», и оценка была +неверной. *Изъятие, а не расхождение:* расширение берётся из имени отправителя дословно (`filepath.Ext`), поэтому имя `запись.тайное-слово` отдаёт приватный хвост diff --git a/docs/conventions/web-ui.md b/docs/conventions/web-ui.md index ead9724..4b2e568 100644 --- a/docs/conventions/web-ui.md +++ b/docs/conventions/web-ui.md @@ -108,5 +108,5 @@ узнала»). - **Устройство service worker и версионирование статики** — задача [installable-pwa](../../tasks/items/installable-pwa.md). -- **Как связываются пользователь Telegram и пользователь веба** — открытый вопрос +- **Как связать чат Telegram с учётной записью** — открытый вопрос; от него зависит возвращение убранного 2026-08-14 входа «Учётные записи» в [../architecture.md](../architecture.md). diff --git a/docs/database.md b/docs/database.md index 2678f45..5aa7bdb 100644 --- a/docs/database.md +++ b/docs/database.md @@ -47,7 +47,7 @@ CGO сборке не нужен. | --- | --- | --- | | `id` | TEXT PK | Идентификатор записи, выдаёт хранилище | | `file` | file | Сам файл | -| `owner` | relation → `users` | Владелец файла; пусто у файлов записи, принятой ботом | +| `owner` | relation → `users` | Владелец файла; пустого значения не принимает | | `location` | select | `local` или `s3` | | `object_key` | TEXT | Ключ объекта; заведён прежним шагом и новым путём не заполняется | | `size` | INTEGER | Размер в байтах | @@ -66,8 +66,8 @@ capability, и третий смысл развёл бы одно слово п | Поле | Тип | Что | | --- | --- | --- | | `id` | TEXT PK | Идентификатор записи, выдаёт хранилище | -| `owner` | relation → `users` | Владелец записи; пусто у записей, принятых ботом | -| `source` | select | `api`, `telegram`, `unknown` | +| `owner` | relation → `users` | Владелец записи; пустого значения не принимает | +| `source` | select | `api`, `unknown`; значение `telegram` осталось историческим — вход убран, новых записей с ним не появляется | | `title`, `brief` | TEXT | Заголовок и краткое описание: читаются вместе со списком | | `state` | select | Рубеж: `uploaded`, `normalized`, `submitted`, `transcribed`, `done`; перечень закрыт схемой | | `state_entered_at` | DATETIME | Время входа в рубеж — сторож застревания | @@ -84,8 +84,8 @@ capability, и третий смысл развёл бы одно слово п | `structure` | relation → `structures` | Структура реплик | | `recognition` | relation → `recognitions` | Попытка распознавания | | `topics` | relation → `topics`, до 5 | Темы записи | -| `tg_chat_id` | INTEGER | Куда отправить результат | -| `tg_reply_message_id` | INTEGER | С каким сообщением связать | +| `tg_chat_id` | INTEGER | Адресат ответа у записи убранного входа; кодом не читается | +| `tg_reply_message_id` | INTEGER | Ответное сообщение у неё же; кодом не читается | | `created`, `updated` | DATETIME | Проставляет хранилище | Индекс один — по паре «рубеж и признак остановки»: выборка захвата идёт по ним, @@ -188,10 +188,15 @@ capability, и третий смысл развёл бы одно слово п висела бы в панели вторым домом для понятия, которого больше нет. **Владелец записи** заведён шагом `202608140001` — связью с коллекцией `users` в -обеих таблицах. Пустое значение допустимо, и это решение с ценой: записи, -принятые ботом, владельца не имеют вовсе, потому что связи чата Telegram с -учётной записью сервис не ведёт. Обязательность для приёма по HTTP держит поэтому -сам приём, а не схема. +обеих таблицах, — и шагом `202608140003` пустого значения больше не принимает. +Прежде принимал, и цену за это платили записи входа Telegram: связи чата с +учётной записью сервис не вёл. Вход убран 2026-08-14, ничью запись заводить стало +некому, и обязательность переехала из приёма в схему — туда, где её держит +хранилище, а не договорённость. + +**Колонки `tg_chat_id` и `tg_reply_message_id`** остались от убранного входа и +кодом больше не читаются. Из схемы они не убираются: заводили их применённые +шаги `202608110001` и `202608140002`, а применённый шаг не переписывается. Выборка по владельцу сужает **чтение записи**: чужая, ничья и несуществующая дают один и тот же отказ. Выборку воркера владелец не сужает — конвейер @@ -288,11 +293,8 @@ capability, и третий смысл развёл бы одно слово п | Задержка перед первой проверкой операции | 10 секунд | `service/transcribe.go` | как было | | Задержка между проверками операции | 5 секунд | там же | как было | | Пауза воркера между прогонами | 1 секунда | `controller/worker/worker.go` | как было | -| Предел длины сообщения Telegram | 4000 символов | `adapter/telegram/sender.go` | предел Telegram | | Таймаут мягкой остановки | 5 секунд | конфиг, `[server] shutdown_timeout` | — | | Таймаут жёсткой остановки | 20 секунд | конфиг, `[server] force_shutdown_timeout` | — | -| Таймаут обновлений Telegram | 10 секунд | конфиг, `[telegram] update_timeout` | — | -| Срок ожидания Telegram при сборке клиента | 10 секунд | `adapter/telegram.ProbeTimeout` | решение, не замер: одно обращение за `getMe` укладывается в доли секунды, дольше Telegram считается недоступным и сервис поднимается без него. Длинный опрос этим сроком не ограничен — клиент подменяется сразу после сборки | | Качество кодирования vorbis | `-q:a 4` | `adapter/converter/ffmpeg/ffmpeg.go` | — | | Жизнь приглашения завести владельца панели | 30 минут | умолчание PocketBase | — | | Потолок размера одной записи | 8 ГиБ | `entity.MaxRecordSize` | расчётный потолок в шесть часов с запасом на видео | @@ -320,4 +322,4 @@ capability, и третий смысл развёл бы одно слово п Чего среди настроек **нет**: режим журналирования, таймаут занятости и размер пула соединений задаёт хранилище своими умолчаниями, а не мы; срока хранения файлов и объектов нет вовсе. Таймаутов у -обращений к Telegram, S3 и SpeechKit тоже нет — ни одного. +обращений к S3 и SpeechKit тоже нет — ни одного. diff --git a/docs/passport.md b/docs/passport.md index e61ee9b..4b4cddf 100644 --- a/docs/passport.md +++ b/docs/passport.md @@ -21,12 +21,14 @@ | --- | --- | | Владелец сервиса | Загрузить диктофонную запись или видео из семейного архива с телефона и получить текст. Видеть, кто сколько загрузил и во что это обошлось | | Приглашённый пользователь | Войти в приложение через свою учётную запись, загрузить запись, забрать текст, вернуться к ней через месяц. Приложение ставится на телефон; каждый видит только свои записи | -| Пользователь Telegram | Отправить боту голосовое сообщение и получить текст ответом. Работает сегодня | | Внешняя программа | Отдать файл по HTTP, представившись своим токеном, и опросить готовность. Сегодня почти не работает: приём и опрос закрыты сессией OIDC, а своего токена у программы нет — годится только чужая сессия, снятая из браузера и предъявленная кукой либо заголовком `Authorization`. Токен приносит `api-tokens` | -**Основной вход — приложение**, бот и HTTP API дополняют его. До 2026-08-11 -основным был бот, и порядок здесь перевёрнут сознательно: диктофонная запись на -несколько часов через Telegram не проходит вовсе. +**Вход у сервиса один — HTTP API**, и приложение строится поверх него. До +2026-08-11 основным входом был Telegram-бот. 2026-08-11 основным объявили +приложение: диктофонная запись на несколько часов через Telegram не проходит +вовсе. 2026-08-14 бот убран целиком — временно, до задачи, которая свяжет чат с +учётной записью. Вместе с ним из потребителей ушёл пользователь +Telegram. Цель достигнута, когда: @@ -35,8 +37,8 @@ - запись расчётного потолка — шести часов — доходит до текста, а не прерывается ошибкой при достижении предела (норма — `openspec/specs/storage`); - сервисом пользуются несколько человек, и записи одного не видны другому; -- текст доступен там же, где загружали, — в приложении и в Telegram. Человек - узнаёт о его готовности, не держа приложение открытым; +- текст доступен там же, где загружали. Человек узнаёт о его готовности, не + держа приложение открытым; - расшифровка не теряется: к записи возвращаются через месяц и находят её по заголовку и темам; - владелец видит расход по каждому пользователю и понимает, во что обходится @@ -90,21 +92,16 @@ 2. **Возвращение к записи.** Через месяц человек открывает список, находит запись по заголовку или теме и читает вычитанный текст, а при нужде — сырую расшифровку. -3. **Голосовое из Telegram.** Пользователь шлёт боту голосовое сообщение, бот - отвечает «обрабатываю», через минуту приходит текст ответом на то же - сообщение. Записи, чей текст длиннее предела сообщения Telegram, приходят - несколькими частями. Работает сегодня. -4. **Файл через Telegram.** То же для аудиофайла или документа с аудио: бот - отличает их по MIME-типу и расширению. Работает сегодня. -5. **Загрузка по HTTP.** Программа шлёт `POST /api/audio` со своим токеном, +3. **Загрузка по HTTP.** Программа шлёт `POST /api/audio` со своим токеном, получает идентификатор задачи и опрашивает `GET /api/status/:id`, пока не увидит `done` и текст. Сегодня доступно только предъявившему сессию OIDC: анонимный запрос обоими адресами отклоняется. Своего входа у программы нет — его заводит `api-tokens`. Записи при этом разграничены: программа с чужой сессией видит только записи того, чью сессию предъявила. -6. **Отказ на середине.** Конвертация или распознавание не удались — задача - переходит в `failed`, а пользователь получает сообщение о том, что именно не - вышло, и предложение повторить. +4. **Отказ на середине.** Конвертация или распознавание не удались — запись + получает признак остановки с причиной, и опрос готовности отдаёт этот признак + тому, кто её загрузил. Сообщения о неудаче сервис никому не шлёт: доставка + ушла вместе с ботом, а уведомления заводит задача `ntfy-delivery`. ## Референсы diff --git a/docs/research/pocketbase-defaults.md b/docs/research/pocketbase-defaults.md index 184582c..897057c 100644 --- a/docs/research/pocketbase-defaults.md +++ b/docs/research/pocketbase-defaults.md @@ -4,14 +4,16 @@ [записки разведки](pocketbase.md) отличаются предметом: та мерила, **что даёт панель**, эта — **что библиотека делает молча**, если её не переубедить. -Все четыре наблюдения нашлись ревью, а не чтением документации: три из них -выглядят как «значение по умолчанию — нет ограничения», а значат обратное. +Наблюдения нашлись ревью, а не чтением документации, и все об одном роде промаха: +объявление библиотеки выглядит как «ограничения нет» либо «ограничение есть», а +значит обратное. ## Как снималось Версия **0.39.10**, та же, что у первой записки. Прогоны — на пустом каталоге данных во временном каталоге и на поднятом сервере `127.0.0.1:18099`; боевые -данные и ключи не участвовали. Числа ниже сняты 2026-08-11 и 2026-08-12. +данные и ключи не участвовали. Числа сняты 2026-08-11 и 2026-08-12, последнее +наблюдение — 2026-08-15. ## Нулевой потолок у поля файла значит 5 МиБ, а не «без предела» @@ -86,6 +88,34 @@ core/field_file.go:310 if f.MaxSize <= 0 { return DefaultFileFieldMaxSize } прогоном: при первом запуске строка со ссылкой в журнале есть, после заведения владельца при следующем запуске её нет. +## Обязательность связи проверяется у записи, а не у колонки + +`Required` у поля связи — правило **проверки записи при сохранении**, а не +ограничение таблицы. Шаг схемы, объявляющий колонку обязательной на базе, где уже +лежат строки с пустым значением, проходит **зелёным** и такие строки оставляет: + +``` +core/field_relation.go:156 ColumnType отдаёт TEXT DEFAULT '' NOT NULL — от Required не зависит +core/collection_validate.go ни одной проверки, читающей существующие строки +``` + +Проверено прогоном 2026-08-15 на копии хранилища во временном каталоге: строка с +пустым владельцем заведена до шага, шаг применён тем же кодом, что и на подъёме, +и вывод: + +``` +STEP 003 (Required=true) поверх ничьей записи: err= +ПОСЛЕ ШАГА: строка на месте, owner="" +Save остановленной ничьей записи: err=failed to update audio record: owner: cannot be blank. +``` + +Следствие для нас: оставленная строка становится **незакрываемой**. Захват идёт +сырым запросом мимо проверки и выдаёт её воркеру, а всякое сохранение отказывает — +включая то, которым ставится признак остановки. Искать такие строки надо запросом +до выкладки, а не прогоном самого шага: прогон чистую базу от грязной не +отличает. Цена решения записана в +[adr/ADR-2026-08-15-owner-required-by-schema.md](../adr/ADR-2026-08-15-owner-required-by-schema.md). + ## Чего эта записка не узнала - **Во что обходится потолок в 8 ГиБ на диске.** Число выбрано расчётом из @@ -96,3 +126,6 @@ core/field_file.go:310 if f.MaxSize <= 0 { return DefaultFileFieldMaxSize } — нет. - **Поведение под одновременной правкой панели и конвейера в бою.** Проверено тестом на одной машине, не живой нагрузкой. +- **Сколько строк с пустой связью выдерживает смена признака обязательности.** + Проверено на одной строке: суть наблюдения — сам факт отсутствия проверки, а не + её цена на объёме. diff --git a/docs/review.md b/docs/review.md index a8cf35c..86a9820 100644 --- a/docs/review.md +++ b/docs/review.md @@ -51,14 +51,14 @@ - повтор шага на той же задаче не создаёт лишних файлов и записей; - отвечает пользователю ровно один раз. -**Транспорт** (`internal/controller/tg`, `internal/controller/http`): +**Транспорт** (`internal/controller/http`): - проверяет право отправителя до всякой работы; - не логирует ошибку, которую уже залогировал доменный слой; - переводит доменную ошибку в свой ответ, а не отдаёт сырой текст; - закрывает то, что открыл, на всех ветках выхода. -**Клиент внешнего сервиса** (`adapter/recognizer/yandex`, `adapter/telegram`): +**Клиент внешнего сервиса** (`adapter/recognizer/yandex`): - имеет таймаут и не виснет, когда внешний сервис не отвечает; - не кладёт секрет в URL и не даёт ему утечь через ошибку транспорта; @@ -121,17 +121,19 @@ - **«Файлы и объекты не удаляются, диск растёт».** Факт верный и записан в [database.md](database.md); срок хранения не задан сознательно, задачи на него нет. Новой находкой это не считается, пока не измерен рост. -- **«Запись без владельца не достаётся никому».** Не дефект: владельца не имеют - записи, принятые ботом, — связи чата Telegram с учётной записью приложения - сервис не ведёт, её заводит `telegram-account-link`. Ответ такой записи по API - совпадает с ответом на несуществующую, и это норма — расшифровку отправитель - получает в чат. +- **«Запись без владельца не достаётся никому».** Строка отменена **дважды**, и + обе отмены оставлены намеренно: прогон, помнящий любую из прежних редакций, + иначе выбросил бы настоящую находку как известную. - **Прежняя редакция этой строки отменена 2026-08-14.** До задачи - `record-ownership` здесь стояло «вошедший видит чужие записи — не дефект и не - новость»: разграничения не было сознательно. Теперь оно есть, и такая находка - настоящая. Строка оставлена вместо удаления намеренно: прогон, помнящий её - прежний вид, выбросил бы регрессию не глядя. + До задачи `record-ownership` здесь стояло «вошедший видит чужие записи — не + дефект и не новость»: разграничения не было сознательно. Первая отмена + 2026-08-14 завела разграничение и объявила не дефектом уже другое — запись без + владельца, принятую ботом. + + Вторая отмена того же дня, задачей `remove-telegram-intake`, сняла и это: + колонка владельца пустого значения больше не принимает, ничьих записей у + сервиса не бывает вовсе. **Запись без владельца сегодня — настоящая находка**, + а не известное исключение. ### Вопросы по темам @@ -145,10 +147,10 @@ делает с задачей, деньгами и ответом отправителю» (чтение `worker.go` и `transcribe.go`, 2026-08-13; прежняя запись от 2026-08-10 устарела вместе с дефектом «остановка хоронила запись»). -- `operations`: появился ли таймаут у обращения к Telegram, S3 и SpeechKit — ни у - одного из них таймаута нет, и проброс контекста на этот вопрос **не отвечает**: - контекст здесь несёт жизнь процесса, а не дедлайн вызова (чтение `tg.go`, - `s3.go`, `speechkit.go`, 2026-08-13). +- `operations`: появился ли таймаут у обращения к S3 и SpeechKit — ни у одного + из них таймаута нет, и проброс контекста на этот вопрос **не отвечает**: + контекст здесь несёт жизнь процесса, а не дедлайн вызова (чтение `s3.go`, + `speechkit.go`, 2026-08-13). - `operations`: не удвоилась ли запись об одном сбое — шаг логирует ошибку и возвращает её воркеру, который логирует снова (чтение `transcribe.go`, 2026-08-10). @@ -161,14 +163,12 @@ - `security`: не уходит ли значение, пришедшее снаружи, меткой метрики — страница метрик отдаётся без проверки отправителя, и метка это поверхность пошире журнала (журнал, запись 2026-08-11 про хвост имени). -- `architecture`: не появился ли второй путь приёма мимо - `createTranscribeJob` — сегодня через него идут оба входа +- `architecture`: не появился ли второй путь приёма мимо `createRecord` — сегодня + он единственный, которым запись попадает в хранилище ([architecture.md](architecture.md), «Единые точки проекта»). - `architecture`: не поехало ли поведение в `architecture.md` вместо спеки — - заведены четыре capability (`intake`, `pipeline`, `storage`, `access`), и - первые две описаны частично. Поведение прочих узлов, включая - приём из Telegram, живёт в обзоре под маркерами долга, а соблазн дописать туда - ещё — самый большой. + заведённые capability описывают поведение не целиком, и остаток живёт в обзоре + под маркерами долга, а соблазн дописать туда ещё — самый большой. - `conventions`: новая колонка правится в обоих местах репозитория, а новый рубеж — одним дескриптором (CLAUDE.md, «Инварианты»). @@ -205,12 +205,12 @@ - замена хранилища или переход на PocketBase — любой её кусок; - смена модели очереди: захват, повторы и воркеры разом; - каркас приложения: сборка фронтенда, раздача статики и шаг гейта разом; -- изменение, трогающее оба входа сразу — Telegram и HTTP. +- изменение, убирающее или возвращающее вход приёма целиком. **Незнакомое здесь** (поднимает до `large`, ось формы решения): -- вход через OIDC и разграничение доступа: как связаны пользователь Telegram и - пользователь приложения, до начала работы назвать нельзя; +- вход через OIDC и разграничение доступа: как связать чат Telegram с учётной + записью, до начала работы назвать нельзя; - всё, что делается на выбранном фреймворке впервые: правила [conventions/web-ui.md](conventions/web-ui.md) выведены из выбора и из замера на пробном экране, а не из написанного кода, и первая же задача проверяет их @@ -224,7 +224,6 @@ **Мелкое здесь** (опускает до `small`): -- правка текста, который видит пользователь Telegram; - новая метрика в `internal/metrics`; - правка `config.example.toml` и умолчаний `defaultConfig()` без нового поля; - правка документов канона. @@ -259,19 +258,19 @@ API и имя не откатываются обратной правкой по `adapter/metaviewer/ffmpeg` нет; решение и его цена — в [adr/ADR-2026-08-11-stub-adapters-in-tests.md](adr/ADR-2026-08-11-stub-adapters-in-tests.md); - **работа сервиса с настоящими внешними собеседниками.** Сам сервис поднять - теперь можно: с `telegram.enabled = false` он встаёт и работает одним входом - (`openspec/specs/intake`, «Признак включения решает, поднимается ли вход - Telegram»). Живой прогон — осмотр HTTP, панели, журнала и остановки — доступен - теперь любой задаче. Прежняя формулировка «всё, что требует поднять сервис целиком» - снята задачей `local-run-without-telegram-token` 2026-08-13; рецепт прогона - сменился с пустого ключа доступа на выключенный вход задачей - `telegram-enabled-flag` того же дня. + можно: он встаёт своим единственным входом на выдуманных непустых ключах + секций `[auth]` и `[yandex]` — наружу они на старте не ходят. Живой прогон — + осмотр HTTP, панели, журнала, метрик и остановки — доступен любой задаче. + Прежняя формулировка «всё, что требует поднять сервис целиком» снята задачей + `local-run-without-telegram-token` 2026-08-13; рецепт прогона менялся дважды — + с пустого ключа доступа на выключенный вход (`telegram-enabled-flag` того же + дня), а 2026-08-14 признак включения ушёл вместе с самим входом. - **Остаток**: за настоящий Telegram, SpeechKit и Object Storage живой прогон - по-прежнему не отвечает — боевым токеном запускаться запрещено, ключи Yandex в - прогоне выдуманные, а распознавание подменяют в коде. Проверить живьём можно - подъём, отказ старта, маршруты и остановку; нельзя — приём из Telegram, - расшифровку и заливку. + **Остаток**: за настоящие SpeechKit и Object Storage живой прогон по-прежнему + не отвечает — ключи Yandex в прогоне выдуманные, а распознавание подменяют в + коде. Проверить живьём можно подъём, отказ старта, маршруты, метрики и + остановку; нельзя — расшифровку и заливку. Вход через живого провайдера OIDC + тоже недоступен: сессию в прогоне выдать нечем. ## Журнал дефектов @@ -281,6 +280,60 @@ API и имя не откатываются обратной правкой по истории git 2026-08-10: поле «Чем воспроизведён» называет у них коммит, а не оракул, и выдумывать оракул задним числом нельзя. +## 2026-08-15 — пустой второй ответ распознавателя стирал сохранённую расшифровку [пойман ревью] + +- **Где:** `internal/adapter/repo/pocketbase/text_repo.go`, `TextRepository.Put` + и `StructureRepository.Put`; путь до них — `poll` → `storeOutcome` в + `internal/service/transcribe.go`. Кода задачи `remove-telegram-intake` дефект не + касался: она этот путь не трогала +- **Симптом:** поток от SpeechKit, закрывшийся на первом же ответе, отказом не + считается — наружу уходит пустой результат без отказа. Замена содержимого шла + безусловно, и повторный опрос той же операции клал пустое поверх сохранённой + расшифровки. Шаг при этом объявлял запись готовой: рубеж двигался, опрос + готовности отдавал `done` без текста +- **Причина:** соседний хранитель того же результата — сырой ответ провайдера — + от пустого значения защищён условием `len(raw) > 0` с самого заведения, а текст + и структура реплик такого условия не имели. Разное правило у двух хранителей + одного результата +- **Чем воспроизведён:** падающий тест враждебного прохода, переснятый триажем, — + `expected: "Личный разговор." actual: ""`. Оракул закреплён в дереве: + `internal/service/recognition_test.go`, `TestEmptySecondAnswerKeepsArchivedText`; + он же проверяет, что до второго ответа дело действительно дошло +- **Почему не поймали раньше:** повторный опрос одной операции — не редкость, но + и не штатный путь: он наступает, когда держатель захвата умер, сохранение рубежа + отказало либо человек снял остановку в панели. Ни один прогон до этого не строил + такого входа, а от чтения кода защита у соседа выглядела общей +- **Что меняем:** правило «пустое не кладётся поверх сохранённого» записано + нормой в спеку `storage` и держится **хранилищем**, а не шагом: шагов, кладущих + текст, больше одного, и правило у одного из них у остальных читалось бы как + снятое. Дефект пред-существующий, чинился решением владельца от 2026-08-14 в + задаче, которая его нашла + +## 2026-08-15 — пустая расшифровка перестала быть заметной вместе с убранным входом [пойман ревью] + +- **Где:** `internal/service/transcribe.go`, шаг завершения; документы + `docs/conventions/logging.md` и `docs/architecture.md` +- **Симптом:** запись с пустым распознаванием доходила до конечного рубежа и от + успешной не отличалась ничем — ни строкой журнала, ни ответом опроса +- **Причина:** единственным следом этого случая был текст, уходивший отправителю + в чат («на записи нет текста»). Задача убрала доставку целиком, и след исчез + вместе с ней — при том, что конвенция журнала называет пустой текст + распознавания поимённым примером уровня «может стать проблемой», а обзор + архитектуры обещал заглушку +- **Чем воспроизведён:** `internal/service/recognition_test.go`, + `TestEmptyRecognitionIsNamedInJournal` — подставной распознаватель отдаёт + готовую операцию с пустым результатом, проверка судит уровень строки и + идентификатор записи +- **Почему не поймали раньше:** удаление сняло **последнего потребителя** видимого + признака, а не сам признак; такое не видно ни компилятору, ни грепу по + удаляемому имени. Нашёл проход конвенций, сверив таблицу уровней журнала с тем, + что осталось в коде +- **Что меняем:** шаг опроса пишет строку уровня «может стать проблемой» с + идентификатором записи; строка обзора архитектуры переписана на фактическое + поведение. Класс общий: **удаляя канал, проверь, не был ли он единственным + потребителем сигнала** — сигнал переживает канал только там, где его переносят + руками + ## 2026-08-13 — сторож инварианта про секрет искал подстроку, которой не бывает [пойман ревью] - **Где:** `internal/config/config_test.go`, проверка «значение ключа доступа не diff --git a/docs/security.md b/docs/security.md index 36ebcb4..e02b306 100644 --- a/docs/security.md +++ b/docs/security.md @@ -15,11 +15,10 @@ записи, а чужая отвечает «не найдено». Целевому периметру недостаёт теперь второго уровня доступа — страницы расхода для владельца сервиса. -Записи, принятые ботом, владельца не имеют и по API не достаются никому: связи -чата с учётной записью приложения нет, её заводит `telegram-account-link`. - -Разграничение доступа в Telegram осталось прежним — белым списком, и с учётной -записью приложения он не связан. +Ничьих записей у сервиса больше не бывает: колонка владельца пустого значения +не принимает, и держит это схема хранилища. Прежде такие записи заводил вход +Telegram — связи чата с учётной записью сервис не вёл, — и 2026-08-14 вход убран +вместе с этим исключением. **Целевой периметр шире сегодняшнего не только входом.** Содержимое записи начинает уходить на три новые стороны — языковой модели, в канал уведомлений и @@ -59,9 +58,7 @@ | --- | --- | --- | | Аудиофайл и его имя | `POST /api/audio`, multipart-поле `audio` | Любой вошедший через OIDC; без сессии — `401` до чтения тела | | Идентификатор задачи | `GET /api/status/:id` | Любой вошедший через OIDC; без сессии — `401`, одинаковый для заведённой и незаведённой задачи | -| Голосовое, аудио, документ | Telegram, длинный опрос | Любой пользователь Telegram; обрабатывается только из белого списка | -| Имя файла в Telegram | Поле `file_path` ответа Bot API | Telegram, а через него — отправитель | -| Содержимое аудио | Файл, скармливаемый `ffmpeg` и `ffprobe` | Отправитель по любому из каналов | +| Содержимое аудио | Файл, скармливаемый `ffmpeg` и `ffprobe` | Отправитель | | Текст расшифровки | Поток gRPC от SpeechKit | Yandex, а через него — содержимое записи | Что добавится вместе с целевым периметром — каждый вход появляется своей @@ -82,9 +79,10 @@ ## Куда уходит содержимое записи -Сегодня запись и её текст покидают наш сервер тремя путями: файл уезжает в -Yandex Object Storage, оттуда его читает SpeechKit, а текст возвращается в -Telegram отправителю. +Сегодня запись покидает наш сервер двумя путями: файл уезжает в Yandex Object +Storage, оттуда его читает SpeechKit. Третий путь — ответ в Telegram — исчез +2026-08-14 вместе с убранным входом: текст теперь достаётся только по опросу +готовности и в панели владельца. Целевой периметр добавляет три пути, каждый — своей задачей: @@ -156,10 +154,6 @@ Telegram отправителю. ## Что разграничивает доступ -- **Telegram** — белый список `[server] users_while_list`. Сверяется со строкой - автора сообщения (`update.Message.From.String()`, то есть `@username` либо имя - с фамилией), а не с числовым идентификатором. Имя пользователя Telegram - меняется владельцем в любой момент: список привязан к изменяемому значению. - **HTTP API** — сессия, заведённая входом через OIDC у Authelia. Предъявляется кукой `transcriber_session`, обесценивается выходом, срок жизни назначен числом ([database.md](database.md), «Настройки с числовым значением»). @@ -203,9 +197,6 @@ Telegram отправителю. | Личный токен | Права своего владельца программе, без браузерной сессии | `api-tokens` | | Признак владельца сервиса | Страницу расхода и сводку по всем пользователям | `admin-stats-screen` | -Белый список Telegram при этом перестаёт быть отдельным механизмом: право -писать боту выводится из учётной записи (`telegram-account-link`). - Признак владельца сервиса — **второй уровень доступа**, которого в сегодняшней модели нет вовсе: до него всё разграничение сводилось к «свой или чужой». Откуда он берётся — из группы OIDC или из конфигурации — не решено @@ -229,10 +220,10 @@ Telegram отправителю. `record_events` (журнал событий, содержимого не несёт) и `topics` (словарь тем человека). Всякая новая коллекция, куда содержимое переезжает, закрывается наравне с записью — норму держит спека `storage`. -2. **Токен бота Telegram.** Даёт полный доступ к боту и к перепискам с ним. -3. **Ключи Yandex Cloud** — `speech_kit_api_key` и пара ключей Object Storage. +2. **Ключи Yandex Cloud** — `speech_kit_api_key` и пара ключей Object Storage. Утечка оплачивается деньгами и доступом к бакету. -4. **Белый список пользователей** — сам по себе перечень имён. +3. **Секрет клиента OIDC** — вместе с адресами провайдера открывает вход в + приложение от чужого имени. Всё перечисленное лежит в `config.toml`. Файл в `.gitignore`, на сервер его кладёт Ansible; `gitleaks` на pre-commit смотрит только индекс коммита. @@ -288,30 +279,14 @@ Telegram отправителю. приведения хвост читал бы кто угодно из интернета, а множеством значений метки распоряжался бы анонимный отправитель. -Приём из Telegram имени, данного человеком, до сервиса не доводит: оттуда -приходит путь, выданный самим Telegram. Настоящее имя документа дальше проверки -типа файла не идёт. +Два пути утечки токена бота — адрес Bot API в отказе транспорта и отказ сборки +клиента — закрыты задачами `no-user-filename-in-log` и +`local-run-without-telegram-token` 2026-08-13 и потеряли предмет 2026-08-14 +вместе с убранным входом: ни клиента, ни токена у сервиса больше нет. Разбор +случая остался в [review.md](review.md) — он про класс, а не про Telegram. -Токен бота стоит в пути **каждого** обращения к Bot API (`bot/getFile`, -`…/sendMessage`, `…/getMe`, `…/getUpdates`) и в ссылке на скачивание -(`file.Link(token)`). Сами адреса нигде не логируются, но до 2026-08-13 их -уносил **отказ транспорта**: `*url.Error` встраивает адрес целиком, а отказы -скачивания и отправки пишутся в журнал. Теперь адрес на границе клиента снимает -свой `Do` — `internal/adapter/telegram`, `NewBot`: он чистит отказ, а -подменённый логгер библиотеки вычищает токен из строк длинного опроса, которые -она печатает сама. Транспорт бота токена больше не получает вовсе: клиента ему -отдают готовым. Правило — [conventions/logging.md](conventions/logging.md), -случай — [review.md](review.md), оракул — `internal/adapter/telegram/bot_test.go`. - -Ещё один путь закрыт задачей `local-run-without-telegram-token` 2026-08-13, и до -неё он был открыт: токен, не разбирающийся как часть адреса (перенос строки из -шаблона выкладки, невычищенная `%`-последовательность), роняет сборку клиента -**раньше** обращения к нему — то есть мимо чистки на границе клиента. Отказ -конструктора теперь чистится отдельно. Нашло это ревью кода тремя проходами -независимо; оракул — там же, в `bot_test.go`. - -Третий путь закрыт задачей `telegram-enabled-flag` 2026-08-13, и он **шире -токена бота**: до неё утечь мог любой секрет конфига. Отказ разбора файла +Путь, который остался, закрыт задачей `telegram-enabled-flag` 2026-08-13, и он +**шире всякого одного ключа**: до неё утечь мог любой секрет конфига. Отказ разбора файла настроек пересказывался как есть, а библиотека разбора собирает текст отказа из разбираемого куска — `toml.ParseError` кладёт в сообщение само значение. Строка секретного ключа с оборванной кавычкой — типовая поломка криво собранного diff --git a/go.mod b/go.mod index 2684d17..dfe7c85 100644 --- a/go.mod +++ b/go.mod @@ -10,7 +10,6 @@ require ( github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.18.3 github.com/aws/aws-sdk-go-v2/service/s3 v1.97.3 github.com/aws/smithy-go v1.27.7 - github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 github.com/google/uuid v1.6.0 github.com/joho/godotenv v1.5.1 github.com/pocketbase/dbx v1.12.0 @@ -50,7 +49,6 @@ require ( github.com/go-sql-driver/mysql v1.9.2 // indirect github.com/golang-jwt/jwt/v5 v5.3.1 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/kylelemons/godebug v1.1.0 // indirect github.com/mattn/go-colorable v0.1.15 // indirect github.com/mattn/go-isatty v0.0.23 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect @@ -66,12 +64,12 @@ require ( github.com/spf13/cobra v1.10.2 // indirect github.com/spf13/pflag v1.0.10 // indirect golang.org/x/crypto v0.54.0 // indirect - golang.org/x/image v0.44.0 // indirect + golang.org/x/image v0.45.0 // indirect golang.org/x/net v0.57.0 // indirect golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/sync v0.22.0 // indirect golang.org/x/sys v0.47.0 // indirect - golang.org/x/text v0.40.0 // indirect + golang.org/x/text v0.41.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/go.sum b/go.sum index ebed60f..814351b 100644 --- a/go.sum +++ b/go.sum @@ -74,8 +74,6 @@ github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w= github.com/go-sql-driver/mysql v1.9.2 h1:4cNKDYQ1I84SXslGddlsrMhc8k4LeDVj6Ad6WRjiHuU= github.com/go-sql-driver/mysql v1.9.2/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= -github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 h1:wG8n/XJQ07TmjbITcGiUaOtXxdrINDz1b0J1w0SzqDc= -github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1/go.mod h1:A2S0CWkNylc2phvKXWBBdD3K0iGnDBGbzRpISP2zBl8= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= @@ -162,10 +160,10 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= -golang.org/x/image v0.44.0 h1:+tDekMZED9+LrtB3G5xzRggpVh9CARjZqROla3R3R+I= -golang.org/x/image v0.44.0/go.mod h1:V8K3KE9KKKE+pLpQDOeN18w9oacNSvy1tDOirTu4xtY= -golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= -golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= +golang.org/x/image v0.45.0 h1:FMb1nTbH5H9vF55SriQHgFw5GnNL9Jg6L25BwXKzhB0= +golang.org/x/image v0.45.0/go.mod h1:n62x/7RqlwXDvGsSU4u6IUTUf6KghUZ9Bt7cG/T9Fx4= +golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= +golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= @@ -178,11 +176,11 @@ golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= -golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= -golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= +golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= +golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= -golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= +golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= +golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= diff --git a/internal/adapter/repo/pocketbase/migrations/202608140003_owner_required.go b/internal/adapter/repo/pocketbase/migrations/202608140003_owner_required.go new file mode 100644 index 0000000..fe46727 --- /dev/null +++ b/internal/adapter/repo/pocketbase/migrations/202608140003_owner_required.go @@ -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 +} diff --git a/internal/adapter/repo/pocketbase/migrations/migrations.go b/internal/adapter/repo/pocketbase/migrations/migrations.go index 5462e70..95bfbce 100644 --- a/internal/adapter/repo/pocketbase/migrations/migrations.go +++ b/internal/adapter/repo/pocketbase/migrations/migrations.go @@ -49,6 +49,7 @@ func init() { pbmigrations.Register(up202608120001, down202608120001, "202608120001_oidc_login.go") pbmigrations.Register(up202608140001, down202608140001, "202608140001_record_owner.go") pbmigrations.Register(up202608140002, down202608140002, "202608140002_record_centric_model.go") + pbmigrations.Register(up202608140003, down202608140003, "202608140003_owner_required.go") } func ptr[T any](v T) *T { return &v } diff --git a/internal/adapter/repo/pocketbase/owner_test.go b/internal/adapter/repo/pocketbase/owner_test.go index 5c809e0..e2c6678 100644 --- a/internal/adapter/repo/pocketbase/owner_test.go +++ b/internal/adapter/repo/pocketbase/owner_test.go @@ -42,9 +42,7 @@ func newRecordOf(t *testing.T, app core.App, ownerID string) *entity.AudioRecord State: entity.StateUploaded, StateEnteredAt: clock.Now(), Source: entity.SourceApi, - } - if ownerID != "" { - record.OwnerID = &ownerID + OwnerID: ownerID, } require.NoError(t, NewAudioRecordRepository(app).Create(record)) return record @@ -65,8 +63,7 @@ func TestGuardOwnerDeletion(t *testing.T) { after, err := NewAudioRecordRepository(app).Get(record.Id) require.NoError(t, err, "запись на месте") - require.NotNil(t, after.OwnerID, "и владелец у неё прежний") - assert.Equal(t, account.Id, *after.OwnerID) + assert.Equal(t, account.Id, after.OwnerID, "и владелец у неё прежний") } // Считаются все коллекции с владельцем, а не одни записи: файл переживает свою @@ -119,19 +116,52 @@ func TestGuardOwnerDeletionLetsEmptyAccountGo(t *testing.T) { require.NoError(t, app.Delete(account), "пустая учётная запись удаляется") } -// Умолчания у колонки владельца нет: запись, чей владелец не назван, не -// достаётся никому по недосмотру схемы. -func TestOwnerColumnHasNoDefault(t *testing.T) { +// Колонка владельца пустого значения не принимает и умолчания не имеет: +// ничьей записи в хранилище не бывает, и завести её нечем — ни приёмом, ни +// конвейером, ни рукой в панели. +func TestOwnerColumnRefusesEmptyValue(t *testing.T) { app := newTestStorage(t) - records, err := app.FindCollectionByNameOrId(migrations.RecordsCollection) - require.NoError(t, err) + for _, name := range []string{migrations.RecordsCollection, migrations.FilesCollection} { + t.Run(name, func(t *testing.T) { + collection, err := app.FindCollectionByNameOrId(name) + require.NoError(t, err) - field := records.Fields.GetByName("owner") - require.NotNil(t, field, "колонка владельца заведена") + field := collection.Fields.GetByName("owner") + require.NotNil(t, field, "колонка владельца заведена") - relation, ok := field.(*core.RelationField) - require.True(t, ok, "владелец — связь с учётной записью, а не строка") - assert.False(t, relation.Required, "пустое значение допустимо ради записей бота") - assert.False(t, relation.CascadeDelete, "удаление учётной записи не уносит архив следом") + relation, ok := field.(*core.RelationField) + require.True(t, ok, "владелец — связь с учётной записью, а не строка") + assert.True(t, relation.Required, "пустое значение колонка не принимает") + assert.False(t, relation.CascadeDelete, "удаление учётной записи не уносит архив следом") + }) + } +} + +// Та же норма со стороны сохранения: схема отвергает запись без владельца, а не +// только объявляет колонку обязательной. +func TestStorageRefusesRecordWithoutOwner(t *testing.T) { + app := newTestStorage(t) + + record := &entity.AudioRecord{ + State: entity.StateUploaded, + StateEnteredAt: clock.Now(), + Source: entity.SourceApi, + } + + require.Error(t, NewAudioRecordRepository(app).Create(record), + "ничья запись в хранилище не ложится") +} + +// И файл — наравне с записью: разное правило у них читалось бы как недосмотр. +func TestStorageRefusesFileWithoutOwner(t *testing.T) { + app := newTestStorage(t) + + repo := NewFileRepository(app) + work, err := repo.Stage(".mp3", strings.NewReader("запись")) + require.NoError(t, err) + defer func() { require.NoError(t, work.Close()) }() + + _, err = repo.Create("sample.mp3", work, contract.FileMeta{Format: "mp3"}, "") + require.Error(t, err, "ничей файл в хранилище не ложится") } diff --git a/internal/adapter/repo/pocketbase/panel_test.go b/internal/adapter/repo/pocketbase/panel_test.go index 23d6cbc..8e3f83a 100644 --- a/internal/adapter/repo/pocketbase/panel_test.go +++ b/internal/adapter/repo/pocketbase/panel_test.go @@ -69,7 +69,7 @@ func updateByRequest(t *testing.T, app core.App, recordID string, body map[strin func haltedRecord(t *testing.T, app core.App) *entity.AudioRecord { t.Helper() - record := newRecordOf(t, app, "") + record := newRecordOf(t, app, newAccount(t, app).Id) record.MoveToState(entity.StateNormalized) record.Attempts = 4 record.AcquisitionID = ptrOf("прежний-захват") @@ -143,7 +143,7 @@ func TestPanelResumeIsLogged(t *testing.T) { func TestPanelStateEditClearsGuards(t *testing.T) { app := newPanelStorage(t) - record := newRecordOf(t, app, "") + record := newRecordOf(t, app, newAccount(t, app).Id) record.Attempts = 4 record.AcquisitionID = ptrOf("прежний-захват") record.AcquireExpiresAt = ptrOf(clock.Now().Add(8 * time.Hour)) @@ -162,7 +162,7 @@ func TestPanelStateEditClearsGuards(t *testing.T) { func TestPanelKeepsGuardsOnUnrelatedEdit(t *testing.T) { app := newPanelStorage(t) - record := newRecordOf(t, app, "") + record := newRecordOf(t, app, newAccount(t, app).Id) record.Attempts = 3 record.AcquisitionID = ptrOf("живой-захват") require.NoError(t, NewAudioRecordRepository(app).Save(record, "")) @@ -181,7 +181,7 @@ func TestAcquireCarriesStageDeadline(t *testing.T) { app := newTestStorage(t) repo := NewAudioRecordRepository(app) - record := newRecordOf(t, app, "") + record := newRecordOf(t, app, newAccount(t, app).Id) acquired, err := repo.FindAndAcquire(entity.WorkingStages()) require.NoError(t, err) @@ -205,7 +205,7 @@ func TestAcquireHandsRecordToExactlyOne(t *testing.T) { app := newTestStorage(t) repo := NewAudioRecordRepository(app) - newRecordOf(t, app, "") + newRecordOf(t, app, newAccount(t, app).Id) first, err := repo.FindAndAcquire(entity.WorkingStages()) require.NoError(t, err, "первому запись досталась") @@ -223,7 +223,7 @@ func TestRottenAcquisitionIsHandedOutAgain(t *testing.T) { app := newTestStorage(t) repo := NewAudioRecordRepository(app) - record := newRecordOf(t, app, "") + record := newRecordOf(t, app, newAccount(t, app).Id) first, err := repo.FindAndAcquire(entity.WorkingStages()) require.NoError(t, err) diff --git a/internal/adapter/repo/pocketbase/record_mapping.go b/internal/adapter/repo/pocketbase/record_mapping.go index 8a45d7a..aa804a6 100644 --- a/internal/adapter/repo/pocketbase/record_mapping.go +++ b/internal/adapter/repo/pocketbase/record_mapping.go @@ -50,18 +50,16 @@ func applyToRecord(record *core.Record, r *entity.AudioRecord) { // Владелец кладётся только здесь, при заведении. В applyOwnedByPipeline его // нет намеренно: конвейер владельца не назначает и не меняет, а снимок шага, // записанный поверх, стёр бы его молча. - record.Set("owner", derefString(r.OwnerID)) + record.Set("owner", r.OwnerID) record.Set("source", r.Source) record.Set("title", derefString(r.Title)) record.Set("brief", derefString(r.Brief)) - record.Set("tg_chat_id", derefInt64(r.TgChatId)) - record.Set("tg_reply_message_id", derefInt(r.TgReplyMessageId)) } func recordToAudioRecord(record *core.Record) *entity.AudioRecord { return &entity.AudioRecord{ Id: record.Id, - OwnerID: nilIfEmpty(record.GetString("owner")), + OwnerID: record.GetString("owner"), Source: record.GetString("source"), Title: nilIfEmpty(record.GetString("title")), Brief: nilIfEmpty(record.GetString("brief")), @@ -80,8 +78,6 @@ func recordToAudioRecord(record *core.Record) *entity.AudioRecord { LiteraryTextID: nilIfEmpty(record.GetString("literary_text")), StructureID: nilIfEmpty(record.GetString("structure")), RecognitionID: nilIfEmpty(record.GetString("recognition")), - TgChatId: nilIfZero64(int64(record.GetInt("tg_chat_id"))), - TgReplyMessageId: nilIfZeroInt(record.GetInt("tg_reply_message_id")), CreatedAt: record.GetDateTime("created").Time(), UpdatedAt: record.GetDateTime("updated").Time(), } @@ -94,20 +90,6 @@ func derefString(v *string) string { return *v } -func derefInt64(v *int64) int64 { - if v == nil { - return 0 - } - return *v -} - -func derefInt(v *int) int { - if v == nil { - return 0 - } - return *v -} - // dateOrEmpty отдаёт пустое значение вместо нулевой даты: пустая колонка даты в // хранилище это пустая строка, и она же значит «времени нет». func dateOrEmpty(v *time.Time) any { @@ -135,17 +117,3 @@ func timeOrNil(v types.DateTime) *time.Time { t := v.Time() return &t } - -func nilIfZero64(v int64) *int64 { - if v == 0 { - return nil - } - return &v -} - -func nilIfZeroInt(v int) *int { - if v == 0 { - return nil - } - return &v -} diff --git a/internal/adapter/repo/pocketbase/record_repo.go b/internal/adapter/repo/pocketbase/record_repo.go index 0c54ff7..13dbd53 100644 --- a/internal/adapter/repo/pocketbase/record_repo.go +++ b/internal/adapter/repo/pocketbase/record_repo.go @@ -58,7 +58,7 @@ func (repo *AudioRecordRepository) Create(r *entity.AudioRecord) error { // перевыданный другому — по протуханию срока или после того, как человек снял // признак остановки в панели, — обязан обратить запись первого в отказ; условие // по непустоте признака пропустило бы обоих, и два шага записали бы в одну -// запись и оба ответили бы отправителю. +// запись по очереди, портя её результат. func (repo *AudioRecordRepository) Save(r *entity.AudioRecord, holder string) error { return repo.app.RunInTransaction(func(txApp core.App) error { record, err := txApp.FindRecordById(migrations.RecordsCollection, r.Id) @@ -89,9 +89,10 @@ func (repo *AudioRecordRepository) Save(r *entity.AudioRecord, holder string) er // по разнице ответов иначе перебирается список заведённых записей, а // идентификатор записи и есть то, что разграничение прячет. // -// Пустой ownerID отсекается **до** чтения и не совпадает ни с чем: иначе -// вызывающий без учётной записи получил бы ровно множество записей без -// владельца, то есть все записи бота. +// Пустой ownerID отсекается **до** чтения и не совпадает ни с чем. Правило это +// не стало избыточным с обязательностью колонки: схема запрещает **заводить** +// ничью запись, а здесь запрещено **спрашивать** ничьим именем — иначе +// вызывающий без учётной записи получил бы выборку вместо отказа. func (repo *AudioRecordRepository) GetByID(id, ownerID string) (*entity.AudioRecord, error) { if ownerID == "" { return nil, &contract.JobNotFoundError{Message: "record not found"} diff --git a/internal/adapter/repo/pocketbase/text_repo.go b/internal/adapter/repo/pocketbase/text_repo.go index f17db2e..0376669 100644 --- a/internal/adapter/repo/pocketbase/text_repo.go +++ b/internal/adapter/repo/pocketbase/text_repo.go @@ -26,6 +26,15 @@ func NewTextRepository(app core.App) *TextRepository { // Замена, а не вставка: пара «запись и вид» уникальна, и повтор прерванного шага // иначе завёл бы второй комплект строк — тогда вопрос «какой текст отдавать // человеку» стал бы вопросом порядка записи, а не состояния. +// +// **Пустое не кладётся поверх непустого**, и это не осторожность, а защита +// архива. Повторный опрос той же операции — обычное дело: держатель захвата +// умер, сохранение рубежа отказало, человек снял остановку в панели. Провайдер +// при этом вправе ответить пустым потоком, отказом это не считается, и +// безусловная замена стирала бы сохранённую расшифровку живого человека без +// следа и без возврата. Та же защита стоит у сырого ответа провайдера +// (`RecognitionRepository.Finish`), и разное правило у двух хранителей одного +// результата читалось бы как недосмотр. func (repo *TextRepository) Put(recordID, kind, contents string) (*entity.Text, error) { collection, err := findCollection(repo.app, migrations.TextsCollection) if err != nil { @@ -50,6 +59,11 @@ func (repo *TextRepository) Put(recordID, kind, contents string) (*entity.Text, // запись вместо правды о недоступной базе. return nil, fmt.Errorf("failed to look up text of kind %s for record %s: %w", kind, recordID, err) } + // Прежнее непустое содержимое пустым не заменяется: строка остаётся как + // есть, и вызывающий получает её обратно. + if contents == "" && record.GetString("contents") != "" { + return textFromRecord(record), nil + } record.Set("contents", contents) if err := repo.app.Save(record); err != nil { @@ -106,7 +120,12 @@ func (repo *StructureRepository) Put(recordID string, version int, replicas []en ) switch { case err == nil: - // Строка есть — заменяем содержимое. + // Строка есть — заменяем содержимое. Пустой перечень реплик поверх + // непустого не кладётся по тому же доводу, что и у текста: повторный + // опрос с пустым ответом провайдера стирал бы разбор живой записи. + if len(replicas) == 0 && len(record.GetString("contents")) > len("[]") { + return repo.GetByID(record.Id) + } case errors.Is(err, sql.ErrNoRows): record = core.NewRecord(collection) record.Set("record", recordID) diff --git a/internal/adapter/telegram/absent.go b/internal/adapter/telegram/absent.go deleted file mode 100644 index b1d9925..0000000 --- a/internal/adapter/telegram/absent.go +++ /dev/null @@ -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 -} diff --git a/internal/adapter/telegram/absent_test.go b/internal/adapter/telegram/absent_test.go deleted file mode 100644 index 05432ad..0000000 --- a/internal/adapter/telegram/absent_test.go +++ /dev/null @@ -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, "отправитель говорит с тем же клиентом, что и транспорт") -} diff --git a/internal/adapter/telegram/bot.go b/internal/adapter/telegram/bot.go deleted file mode 100644 index fa4c67d..0000000 --- a/internal/adapter/telegram/bot.go +++ /dev/null @@ -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/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)) -} diff --git a/internal/adapter/telegram/bot_test.go b/internal/adapter/telegram/bot_test.go deleted file mode 100644 index b99fb80..0000000 --- a/internal/adapter/telegram/bot_test.go +++ /dev/null @@ -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", - "строка библиотеки прошла мимо нашего журнала: логгер не подменён") -} diff --git a/internal/adapter/telegram/sender.go b/internal/adapter/telegram/sender.go deleted file mode 100644 index ebce845..0000000 --- a/internal/adapter/telegram/sender.go +++ /dev/null @@ -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 -} diff --git a/internal/adapter/telegram/split.go b/internal/adapter/telegram/split.go deleted file mode 100644 index 85ae6ed..0000000 --- a/internal/adapter/telegram/split.go +++ /dev/null @@ -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 -} diff --git a/internal/adapter/telegram/split_test.go b/internal/adapter/telegram/split_test.go deleted file mode 100644 index ec2101e..0000000 --- a/internal/adapter/telegram/split_test.go +++ /dev/null @@ -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) - } - } - }) - } -} diff --git a/internal/archrules/arch_test.go b/internal/archrules/arch_test.go index 4858254..bf54d33 100644 --- a/internal/archrules/arch_test.go +++ b/internal/archrules/arch_test.go @@ -28,7 +28,7 @@ const ( ) // Ядро — `internal/service`: оно знает только интерфейсы `internal/contract`, а -// ffmpeg, Yandex, Telegram и хранилище подставляются в `main.go` +// ffmpeg, Yandex и хранилище подставляются в `main.go` // (docs/architecture.md, «Принципы»). const core = "internal/service" @@ -36,7 +36,6 @@ const core = "internal/service" // одном из них: иначе второй начинает зависеть от первого и тащит его целиком. var transports = map[string]bool{ "internal/controller/http": true, - "internal/controller/tg": true, "internal/controller/worker": true, } diff --git a/internal/config/config.go b/internal/config/config.go index 50eb217..5ea3bb9 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -19,7 +19,6 @@ type Config struct { Storage StorageConfig `toml:"storage"` Pipeline PipelineConfig `toml:"pipeline"` Yandex YandexConfig `toml:"yandex"` - Telegram TelegramConfig `toml:"telegram"` Auth AuthConfig `toml:"auth"` } @@ -61,10 +60,9 @@ func (c PipelineConfig) Validate() error { } type ServerConfig struct { - Port int `toml:"port"` - ShutdownTimeout int `toml:"shutdown_timeout"` - ForceShutdownTimeout int `toml:"force_shutdown_timeout"` - UsersWhiteList []string `toml:"users_while_list"` + Port int `toml:"port"` + ShutdownTimeout int `toml:"shutdown_timeout"` + ForceShutdownTimeout int `toml:"force_shutdown_timeout"` } // StorageConfig — единственный каталог данных: под ним лежат и база, и файлы @@ -83,33 +81,6 @@ type YandexConfig struct { ObjStorageEndpoint string `toml:"object_storage_endpoint"` } -// TelegramConfig — вход Telegram. Признак включения объявляет намерение -// владельца, `BotToken` означает только доступ. Пока два значения жили в одном -// поле, пустой токен читался разом как «вход выключен» и как «ключ не доехал», -// и сервис поднимался без бота в обоих случаях. -type TelegramConfig struct { - // Enabled — умолчания у него нет **намеренно**, и потому его нет в - // `defaultConfig()`: умолчание было бы угаданным намерением, а признак - // заведён затем, чтобы намерение объявляли. Отсутствие ключа в файле ловит - // `LoadConfig` — нулевое значение `bool` режима не выбирает. - Enabled bool `toml:"enabled"` - BotToken string `toml:"bot_token"` - UpdateTimeout int `toml:"update_timeout"` -} - -// Validate проверяет ключ доступа против объявленного намерения. Пустой ключ -// при включённом входе — ошибка настройки: бот по нему не появится, а тихий -// подъём без бота оставил бы отправителей без ответов. -// -// Названо имя ключа, а не значение: значение `bot_token` в журнал попасть не -// должно. -func (c TelegramConfig) Validate() error { - if c.Enabled && c.BotToken == "" { - return errors.New("telegram: не заполнен ключ bot_token при enabled = true") - } - return nil -} - // AuthConfig — вход через внешнего провайдера OIDC. Адреса, идентификатор // клиента и секрет приезжают сюда и приводятся к настройкам коллекции // пользователей при каждом подъёме: применённый шаг схемы не переписывается, и @@ -202,11 +173,6 @@ func defaultConfig() *Config { ObjStorageRegion: "ru-central1", ObjStorageEndpoint: "https://storage.yandexcloud.net/", }, - // Умолчания у `Enabled` здесь нет намеренно — причина у поля. - Telegram: TelegramConfig{ - BotToken: "", - UpdateTimeout: 10, - }, Auth: AuthConfig{ SecureCookie: true, }, @@ -223,19 +189,10 @@ func LoadConfig(path string) (*Config, error) { config := defaultConfig() // Load configuration from file - meta, err := toml.DecodeFile(path, &config) - if err != nil { + if _, err := toml.DecodeFile(path, &config); err != nil { return nil, decodeError(path, err) } - // Признак включения входа Telegram обязателен: умолчания у него нет, и - // отличить «не задан» от «задан ложным» умеет только разбор — нулевое - // значение `bool` в структуре у обоих одинаковое. Отсюда и `meta`: наружу - // она не отдаётся, приговор выносится здесь. - if !meta.IsDefined("telegram", "enabled") { - return nil, errors.New("telegram: не задан ключ enabled; он объявляет, нужен ли сервису вход Telegram") - } - return config, nil } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 89b8874..ad5ffca 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -1,7 +1,6 @@ package config import ( - "fmt" "os" "path/filepath" "strings" @@ -100,68 +99,6 @@ func TestAuthConfigValidateRejectsMalformedURL(t *testing.T) { } } -// Признак включения объявляет намерение, ключ доступа означает только доступ. -// Пока эти два значения жили в одном поле, пустой токен читался разом как -// «вход выключен» и как «ключ не доехал». - -func TestTelegramConfigValidateAcceptsEnabledWithToken(t *testing.T) { - cfg := TelegramConfig{Enabled: true, BotToken: "123456:AA-fake"} - - if err := cfg.Validate(); err != nil { - t.Fatalf("включённый вход с ключом отвергнут: %v", err) - } -} - -func TestTelegramConfigValidateRejectsEnabledWithoutToken(t *testing.T) { - cfg := TelegramConfig{Enabled: true, BotToken: ""} - - err := cfg.Validate() - if err == nil { - t.Fatal("включённый вход без ключа доступа пропущен") - } - if !strings.Contains(err.Error(), "bot_token") { - t.Fatalf("имя ключа не названо: %v", err) - } -} - -// Выключенный вход на ключ доступа не смотрит вовсе: пустой ключ при нём — -// обычное состояние локального прогона, а не ошибка настройки. -func TestTelegramConfigValidateIgnoresTokenWhenDisabled(t *testing.T) { - cfg := TelegramConfig{Enabled: false, BotToken: ""} - - if err := cfg.Validate(); err != nil { - t.Fatalf("выключенный вход без ключа отвергнут: %v", err) - } -} - -// Половина требования «сообщение не несёт значения ключа» на этой проверке -// **вакуумна**, и честнее это назвать, чем изображать сторожа. -// -// `Validate()` отказывает ровно на пустом ключе — значения, которым можно -// проговориться, на этом пути не существует. Прежняя редакция сторожа искала -// подстроку, которой в сообщении нет ни при каком входе, и потому не могла -// упасть вовсе: правка на `%q` от токена оставила бы её зелёной. В проекте это -// третий пойманный случай проверки, не способной упасть. -// -// Настоящий сторож той же нормы живёт там, где непустой ключ в отказ попасть -// действительно может, — `TestLoadConfigMalformedSecretLineHidesValue` и -// `TestLoadConfigMalformedBeforeAnyKeyHidesValue`. Здесь проверяется то, что -// проверяемо: заполненный ключ проходит, пустой отвергается с именем ключа. -func TestTelegramConfigValidateNamesKeyWithoutValue(t *testing.T) { - filled := TelegramConfig{Enabled: true, BotToken: "123456:AAHfake-secret-token-value"} - if err := filled.Validate(); err != nil { - t.Fatalf("включённый вход с заполненным ключом отвергнут: %v", err) - } - - err := TelegramConfig{Enabled: true, BotToken: ""}.Validate() - if err == nil { - t.Fatal("включённый вход без ключа доступа пропущен") - } - if !strings.Contains(err.Error(), "bot_token") { - t.Fatalf("имя ключа не названо: %v", err) - } -} - func writeConfig(t *testing.T, body string) string { t.Helper() @@ -173,49 +110,17 @@ func writeConfig(t *testing.T, body string) string { } const validConfigBody = ` -[telegram] -enabled = false -bot_token = "" +[storage] +data_dir = "data" ` -// Признак обязателен: файл без него негоден. Умолчание было бы угаданным -// намерением, а отличить «не задан» от «задан ложным» умеет только разбор — -// нулевое значение bool у обоих одинаковое. -func TestLoadConfigRejectsMissingTelegramEnabled(t *testing.T) { - path := writeConfig(t, "[telegram]\nbot_token = \"123456:AA-fake\"\n") - - _, err := LoadConfig(path) - if err == nil { - t.Fatal("файл без признака включения принят") - } - if !strings.Contains(err.Error(), "enabled") { - t.Fatalf("имя недостающего ключа не названо: %v", err) - } -} - -func TestLoadConfigReadsBothValuesOfTelegramEnabled(t *testing.T) { - for _, enabled := range []bool{true, false} { - t.Run(fmt.Sprintf("%t", enabled), func(t *testing.T) { - body := fmt.Sprintf("[telegram]\nenabled = %t\nbot_token = \"123456:AA-fake\"\n", enabled) - - cfg, err := LoadConfig(writeConfig(t, body)) - if err != nil { - t.Fatalf("годный файл отвергнут: %v", err) - } - if cfg.Telegram.Enabled != enabled { - t.Fatalf("признак доехал как %t, а в файле %t", cfg.Telegram.Enabled, enabled) - } - }) - } -} - // Инвариант «секрет не покидает конфиг»: текст отказа разбора собирает чужая // библиотека из разбираемого куска файла, и оборванная строка ключа доступа // уехала бы в журнал вместе со значением. Отсюда собственное сообщение. func TestLoadConfigMalformedSecretLineHidesValue(t *testing.T) { - const secret = "123456:AAHfake-secret-token-value" + const secret = "AQVNfake-secret-api-key-value" // Кавычка не закрыта: разбор оборвётся на значении. - path := writeConfig(t, "[telegram]\nenabled = true\nbot_token = \""+secret+"\n") + path := writeConfig(t, "[yandex]\nfolder_id = \"b1g\"\nspeech_kit_api_key = \""+secret+"\n") _, err := LoadConfig(path) if err == nil { @@ -223,7 +128,7 @@ func TestLoadConfigMalformedSecretLineHidesValue(t *testing.T) { } message := err.Error() - for _, part := range []string{secret, "AAHfake", "secret-token-value", "123456"} { + for _, part := range []string{secret, "AQVNfake", "secret-api-key-value"} { if strings.Contains(message, part) { t.Fatalf("значение ключа доступа уехало в отказ: %v", err) } @@ -232,7 +137,7 @@ func TestLoadConfigMalformedSecretLineHidesValue(t *testing.T) { if !strings.Contains(message, "строке 3") { t.Fatalf("номер строки не назван, чинить нечего: %v", err) } - if !strings.Contains(message, "bot_token") { + if !strings.Contains(message, "speech_kit_api_key") { t.Fatalf("ключ не назван, чинить нечего: %v", err) } } @@ -256,9 +161,9 @@ func TestLoadConfigTypeMismatchKeepsDiagnostics(t *testing.T) { // уязвимая: именно в ней будущая правка легче всего протащит текст библиотеки // обратно. func TestLoadConfigMalformedBeforeAnyKeyHidesValue(t *testing.T) { - const secret = "123456:AAHsecret-token-value" + const secret = "AQVNsecret-api-key-value" // У секции не закрыта скобка: разбор оборвётся, не назвав ни одного ключа. - path := writeConfig(t, "[telegram\nenabled = true\nbot_token = \""+secret+"\"\n") + path := writeConfig(t, "[yandex\nfolder_id = \"b1g\"\nspeech_kit_api_key = \""+secret+"\"\n") _, err := LoadConfig(path) if err == nil { @@ -266,7 +171,7 @@ func TestLoadConfigMalformedBeforeAnyKeyHidesValue(t *testing.T) { } message := err.Error() - for _, part := range []string{secret, "AAHsecret", "secret-token-value"} { + for _, part := range []string{secret, "AQVNsecret", "secret-api-key-value"} { if strings.Contains(message, part) { t.Fatalf("значение ключа доступа уехало в отказ: %v", err) } @@ -284,8 +189,8 @@ workers = 7 own_work_limit_minutes = 90 foreign_work_limit_minutes = 720 -[telegram] -enabled = false +[storage] +data_dir = "data" `) cfg, err := LoadConfig(path) @@ -309,7 +214,7 @@ enabled = false // Умолчания есть у всех трёх чисел: файл без секции конвейера годен, и сервис // поднимается с рабочими значениями. func TestPipelineSettingsHaveDefaults(t *testing.T) { - path := writeConfig(t, "[telegram]\nenabled = false\n") + path := writeConfig(t, "[storage]\ndata_dir = \"data\"\n") cfg, err := LoadConfig(path) if err != nil { diff --git a/internal/contract/contract.go b/internal/contract/contract.go index d3ef390..0b1da64 100644 --- a/internal/contract/contract.go +++ b/internal/contract/contract.go @@ -58,7 +58,3 @@ type AudioRecognizer interface { // обращаясь к нему. По нему архив пересчитывается без единого рубля. Parse(raw []byte) (*entity.RecognitionOutcome, error) } - -type TelegramMessageSender interface { - Send(text string, chatId int64, replyToMessageId *int) error -} diff --git a/internal/contract/error.go b/internal/contract/error.go index 747853f..bbcab12 100644 --- a/internal/contract/error.go +++ b/internal/contract/error.go @@ -5,15 +5,6 @@ import ( "fmt" ) -// ErrDeliveryChannelDown — канал, которым отвечают отправителю, не поднят. -// Отдаётся отправителем-заглушкой, которого получает ядро, когда вход не -// настроен. -// -// Значение сентинельное, а не тип: соседям по ряду есть что нести — состояние, -// идентификатор задачи, — а этому нечего. Заглушка не знает ни задачи, ни чата, -// и запись о недоставке делает шаг, у которого задача под рукой. -var ErrDeliveryChannelDown = errors.New("delivery channel is down") - // ErrOwnerRequired — приём по HTTP дошёл до заведения задачи, а владельца ему не // назвали. Значение сентинельное: нести отказу нечего, а имя учётной записи в // него не кладётся никогда. @@ -29,9 +20,9 @@ func (e *JobNotFoundError) Error() string { } // LostAcquisitionError — захват задачи за время работы шага достался другому. -// Шаг, получивший его, завершается без записи результата и без ответа -// отправителю: иначе два воркера пишут в одну задачу по очереди, а отправитель -// получает два ответа на одну запись. +// Шаг, получивший его, завершается без записи результата: иначе два воркера +// пишут в одну запись по очереди, портя её результат, и счётчик отказов +// сбрасывает тот, кто уже не владелец. type LostAcquisitionError struct { JobID string } diff --git a/internal/contract/repository.go b/internal/contract/repository.go index 0576b20..c7ed5fd 100644 --- a/internal/contract/repository.go +++ b/internal/contract/repository.go @@ -44,11 +44,11 @@ type FileRepository interface { // файле. Имя задаёт сервис: умолчание хранилища, строящее его из имени // отправителя, не применяется. // - // ownerID — владелец записи, которой файл принадлежит; пустой значит «файл - // без владельца», и таков всякий файл записи, принятой ботом. Владелец - // лежит своей колонкой, а не выводится через запись: файл переживает свою - // запись — шаг заводит его до сохранения, и потерянный захват оставляет файл - // с владельцем и без ссылки. + // ownerID — владелец записи, которой файл принадлежит, и он обязателен: + // колонка владельца пустого значения не принимает, пустой отвергается + // схемой. Владелец лежит своей колонкой, а не выводится через запись: файл + // переживает свою запись — шаг заводит его до сохранения, и потерянный + // захват оставляет файл с владельцем и без ссылки. Create(name string, work WorkFile, meta FileMeta, ownerID string) (*entity.File, error) GetByID(id string) (*entity.File, error) // Open отдаёт содержимое хранимого файла потоком. diff --git a/internal/controller/http/ownership_test.go b/internal/controller/http/ownership_test.go index 334cf20..f40d37f 100644 --- a/internal/controller/http/ownership_test.go +++ b/internal/controller/http/ownership_test.go @@ -4,17 +4,13 @@ import ( "encoding/json" "net/http" "net/http/httptest" - "strings" "testing" "github.com/pocketbase/pocketbase/core" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - pbrepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase" "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations" - "git.vakhrushev.me/av/transcriber/internal/clock" - "git.vakhrushev.me/av/transcriber/internal/contract" "git.vakhrushev.me/av/transcriber/internal/entity" ) @@ -81,33 +77,6 @@ func TestGetTranscribeJobStatus_ForeignJobLooksMissing(t *testing.T) { assert.NotContains(t, foreign.Body.String(), "transcription_text") } -// Задача, принятая ботом, владельца не имеет и не достаётся по API никому. -func TestGetTranscribeJobStatus_OwnerlessJobLooksMissing(t *testing.T) { - env := setupTestEnv(t, readableMetaViewer()) - - fileRepo := pbrepo.NewFileRepository(env.app) - work, err := fileRepo.Stage(".ogg", strings.NewReader("запись")) - require.NoError(t, err) - defer func() { require.NoError(t, work.Close()) }() - - // Файл записи из Telegram владельца тоже не имеет. - file, err := fileRepo.Create("voice.ogg", work, contract.FileMeta{Format: "ogg"}, "") - require.NoError(t, err) - - job := &entity.AudioRecord{ - State: entity.StateUploaded, - StateEnteredAt: clock.Now(), - Source: entity.SourceTelegram, - OriginalFileID: &file.Id, - } - require.NoError(t, env.handler.recordRepo.Create(job)) - - w := httptest.NewRecorder() - env.serve(w, httptest.NewRequest("GET", "/api/status/"+job.Id, http.NoBody)) - - assert.Equal(t, http.StatusNotFound, w.Code) -} - // Владельцем принятой записи становится предъявитель сессии — и у задачи, и у // её файла. func TestCreateTranscribeJob_OwnerIsSession(t *testing.T) { diff --git a/internal/controller/http/transcribe_test.go b/internal/controller/http/transcribe_test.go index 9ccc649..0de4e35 100644 --- a/internal/controller/http/transcribe_test.go +++ b/internal/controller/http/transcribe_test.go @@ -60,13 +60,6 @@ type stubConverter struct{} func (c *stubConverter) Convert(context.Context, string, string) error { return nil } -// TestTgSender: приём по HTTP в Telegram не отвечает, но сервису отправитель нужен. -type TestTgSender struct{} - -func (s *TestTgSender) Send(msg string, chatId int64, replyMsgId *int) error { - return nil -} - // readableMetaViewer — источник метаданных, который читает любую запись. func readableMetaViewer() *stubMetaViewer { return &stubMetaViewer{seconds: 42} @@ -192,7 +185,6 @@ func setupTestEnv(t *testing.T, metaviewer contract.AudioMetaViewer) *testEnv { metaviewer, &stubConverter{}, &recognizer.MemoryAudioRecognizer{}, - &TestTgSender{}, entity.StuckLimits{Own: time.Hour, Foreign: 24 * time.Hour}, logger, ) @@ -293,7 +285,7 @@ func jobWithFile(t *testing.T, env *testEnv) *entity.AudioRecord { State: entity.StateUploaded, StateEnteredAt: clock.Now(), Source: entity.SourceApi, - OwnerID: &env.account.Id, + OwnerID: env.account.Id, OriginalFileID: &file.Id, } require.NoError(t, env.handler.recordRepo.Create(record)) diff --git a/internal/controller/tg/download_test.go b/internal/controller/tg/download_test.go deleted file mode 100644 index 8a76465..0000000 --- a/internal/controller/tg/download_test.go +++ /dev/null @@ -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") -} diff --git a/internal/controller/tg/tg.go b/internal/controller/tg/tg.go deleted file mode 100644 index 0787b2c..0000000 --- a/internal/controller/tg/tg.go +++ /dev/null @@ -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 -} diff --git a/internal/entity/audio_record.go b/internal/entity/audio_record.go index 39ab763..e1c4179 100644 --- a/internal/entity/audio_record.go +++ b/internal/entity/audio_record.go @@ -34,8 +34,11 @@ const ( ) const ( - SourceUnknown = "unknown" - SourceApi = "api" + SourceUnknown = "unknown" + SourceApi = "api" + // SourceTelegram — историческое значение. Вход Telegram убран, новых записей + // с этим источником не появляется, а константа остаётся: на неё ссылается + // применённый шаг схемы `202608140002`, а применённый шаг не переписывается. SourceTelegram = "telegram" ) @@ -47,10 +50,11 @@ const ( // `texts`, и чтение очереди её не тянет. type AudioRecord struct { Id string - // OwnerID — учётная запись, от имени которой запись принята. Пуст у записей - // из Telegram: связи чата с учётной записью сервис не ведёт. Назначается - // один раз, при приёме, и конвейером не меняется. - OwnerID *string + // OwnerID — учётная запись, от имени которой запись принята. Обязателен: + // колонка владельца пустого значения не принимает, и ничьей записи в + // хранилище не бывает. Назначается один раз, при приёме, и конвейером не + // меняется. + OwnerID string Source string // Title и Brief читаются вместе со списком, сотней штук разом, и потому @@ -91,9 +95,6 @@ type AudioRecord struct { LiteraryTextID *string RecognitionID *string - TgChatId *int64 - TgReplyMessageId *int - CreatedAt time.Time UpdatedAt time.Time } diff --git a/internal/metrics/format_label.go b/internal/metrics/format_label.go index 3db66f4..6ad3bc1 100644 --- a/internal/metrics/format_label.go +++ b/internal/metrics/format_label.go @@ -10,13 +10,11 @@ const OtherFormatLabel = "other" // knownFormats — закрытый перечень расширений, которые допускаются меткой. // -// Состав: пути, которые выдаёт Telegram (голосовое приходит как -// `voice/file_N.oga`, кружок — с `.mp4`), плюс форматы, доезжающие приёмом по -// HTTP, плюс собственное умолчание сервиса на случай имени без расширения. -// Списку, по которому бот отбирает **документы** (`isAudioDocument`), перечень -// намеренно не равен: тот судит по типу содержимого и своим списком пользуется -// лишь когда типа нет, а сюда попадает и то, что приходит другими путями. -// Сведение двух списков в один уронило бы основной вход сервиса в `other`. +// Состав: форматы, доезжающие приёмом по HTTP, плюс собственное умолчание +// сервиса на случай имени без расширения. Значения `oga` и `mp4` достались от +// убранного входа Telegram — голосовое приходило оттуда как `voice/file_N.oga`, +// кружок с `.mp4`, — и остаются: перечень сужает **метку**, а не приём, и +// выброшенное из него значение уронило бы прежние записи в `other`. var knownFormats = map[string]struct{}{ "mp3": {}, "wav": {}, diff --git a/internal/metrics/metrics.go b/internal/metrics/metrics.go index ecc1317..919ee71 100644 --- a/internal/metrics/metrics.go +++ b/internal/metrics/metrics.go @@ -49,9 +49,11 @@ var ( []string{"source_format", "target_format", "error"}, ) - // Поднят ли вход приёма. Единственный канал наблюдения, автоматизированный - // у владельца: потерянный вход иначе виден только строкой журнала при - // старте, а проба здоровья отвечает «ok» и без него. + // Поднят ли вход приёма. Метка ставится только тому входу, который у сервиса + // есть; вход остался один, и метки убранного здесь не появляется — ноль рядом + // с ним читался бы как поломка, а вечная единица — как исправность того, чего + // нет. Различать поднятый и неподнятый признак снова станет, когда входов + // снова станет больше одного. IntakeUpGauge = promauto.NewGaugeVec( prometheus.GaugeOpts{ Name: "transcriber_intake_up", @@ -60,17 +62,6 @@ var ( []string{"channel"}, ) - // Ответы, которые не удалось доставить отправителю. Работа при этом - // сделана, шаг отказа не объявляет, и без счётчика недоставка видна только - // в журнале — до его ротации. - UndeliveredReplyCounter = promauto.NewCounterVec( - prometheus.CounterOpts{ - Name: "transcriber_undelivered_reply_count", - Help: "Count of replies that could not be delivered to the sender", - }, - []string{"reason"}, - ) - // Размер файла после конвертации (в байтах) OutputFileSizeHistogram = promauto.NewHistogramVec( prometheus.HistogramOpts{ diff --git a/internal/service/find_job_test.go b/internal/service/find_job_test.go index 35e2ed2..c03c0a1 100644 --- a/internal/service/find_job_test.go +++ b/internal/service/find_job_test.go @@ -40,7 +40,7 @@ func (r *stubRecordRepo) FindAndAcquire([]entity.Stage) (*contract.AcquiredRecor func serviceWithRepo(repo contract.AudioRecordRepository) *TranscribeService { return NewTranscribeService( Repositories{Records: repo}, - nil, nil, nil, nil, + nil, nil, nil, entity.StuckLimits{}, slog.New(slog.DiscardHandler), ) diff --git a/internal/service/metrics_test.go b/internal/service/metrics_test.go index 5d43b69..e91dc66 100644 --- a/internal/service/metrics_test.go +++ b/internal/service/metrics_test.go @@ -55,7 +55,7 @@ func TestFailureIsCountedWithStageLabel(t *testing.T) { beforeOk := stageCount(t, entity.StateUploaded, "false") - record := newTelegramRecord(t, env) + record := newRecord(t, env) require.NoError(t, env.service.RunStep(t.Context())) require.Equal(t, entity.StateNormalized, readRecord(t, env, record.Id).State) @@ -66,7 +66,7 @@ func TestFailureIsCountedWithStageLabel(t *testing.T) { failing := newPipelineEnv(t, &okMetaViewer{}, &failingStepConverter{}) beforeErr := stageCount(t, entity.StateUploaded, "true") - newTelegramRecord(t, failing) + newRecord(t, failing) require.Error(t, failing.service.RunStep(t.Context())) assert.InDelta(t, beforeErr+1, stageCount(t, entity.StateUploaded, "true"), 0, diff --git a/internal/service/ownership_test.go b/internal/service/ownership_test.go index dc38f05..d5026ca 100644 --- a/internal/service/ownership_test.go +++ b/internal/service/ownership_test.go @@ -12,9 +12,8 @@ import ( ) // Выборка воркера владельцем не сужается: владелец решает, кому запись -// показывать, а не кому её считать. Сужение остановило бы расшифровку записей -// бота вовсе, а записи остальных поставило бы в зависимость от того, кто первым -// завёл учётную запись. +// показывать, а не кому её считать. Сужение поставило бы записи одних людей в +// зависимость от того, кто первым завёл учётную запись. func TestWorkerTakesRecordsOfEveryOwner(t *testing.T) { env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{}) @@ -26,8 +25,8 @@ func TestWorkerTakesRecordsOfEveryOwner(t *testing.T) { strings.NewReader("вторая"), "two.mp3", newOwner(t, env.app)) require.NoError(t, err) - // Третья пришла ботом, и владельца у неё нет вовсе. - third := newTelegramRecord(t, env) + // Третья — ещё одного владельца: воркер не сужается ни одним из них. + third := newRecord(t, env) // Захваченная запись остаётся за держателем, и следующий вызов берёт // следующую, а не ту же самую. @@ -40,7 +39,7 @@ func TestWorkerTakesRecordsOfEveryOwner(t *testing.T) { assert.True(t, taken[first.Id], "запись первого владельца досталась воркеру") assert.True(t, taken[second.Id], "и второго") - assert.True(t, taken[third.Id], "и запись без владельца") + assert.True(t, taken[third.Id], "и третьего") _, err = env.recordRepo.FindAndAcquire(entity.WorkingStages()) var missing *contract.JobNotFoundError @@ -65,8 +64,7 @@ func TestAcquireReturnsIdentifierAndHolder(t *testing.T) { // Колонки читаются отдельным чтением, и владелец среди них. read := readRecord(t, env, record.Id) - require.NotNil(t, read.OwnerID) - assert.Equal(t, owner, *read.OwnerID) + assert.Equal(t, owner, read.OwnerID) assert.Equal(t, acquired.Holder, *read.AcquisitionID, "признак захвата записан в саму запись") require.NotNil(t, read.AcquireExpiresAt, "срок протухания приехал с рубежом") } @@ -86,12 +84,12 @@ func TestPipelineStepKeepsOwner(t *testing.T) { after := readRecord(t, env, record.Id) require.True(t, after.IsHalted(), "шаг записал свой приговор") - require.NotNil(t, after.OwnerID, "владелец пережил шаг") - assert.Equal(t, owner, *after.OwnerID) + assert.Equal(t, owner, after.OwnerID, "владелец пережил шаг") } -// Приём из веба без владельца записи не заводит. Обязательность держит здесь -// код, а не схема: колонка допускает пустое значение ради записей бота. +// Приём без владельца записи не заводит. Отказ стоит здесь раньше схемы: он +// отвечает понятной ошибкой до того, как запись ляжет в хранилище, а схема +// отказала бы уже после укладки файла. func TestCreateJobFromApiRequiresOwner(t *testing.T) { env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{}) @@ -110,17 +108,11 @@ func TestGetByIDHidesForeignRecords(t *testing.T) { record, err := env.service.CreateJobFromApi(t.Context(), strings.NewReader("запись"), "one.mp3", owner) require.NoError(t, err) - // Запись без владельца — принятая ботом. - orphan := newTelegramRecord(t, env) - var missing *contract.JobNotFoundError _, err = env.recordRepo.GetByID(record.Id, stranger) require.ErrorAs(t, err, &missing, "чужая запись неотличима от несуществующей") - _, err = env.recordRepo.GetByID(orphan.Id, stranger) - require.ErrorAs(t, err, &missing, "ничья запись не достаётся никому") - _, err = env.recordRepo.GetByID(record.Id, "") require.ErrorAs(t, err, &missing, "пустой владелец не совпадает ни с чем") diff --git a/internal/service/pipeline_test.go b/internal/service/pipeline_test.go index 284d4e0..607ea0c 100644 --- a/internal/service/pipeline_test.go +++ b/internal/service/pipeline_test.go @@ -60,32 +60,12 @@ func (m *failingMetaViewer) GetInfo(context.Context, string) (*contract.AudioInf return nil, errors.New("запись не читается") } -// recordingSender запоминает, что отправлено. -type recordingSender struct { - mu sync.Mutex - messages []string -} - -func (s *recordingSender) Send(text string, chatId int64, replyMsgId *int) error { - s.mu.Lock() - defer s.mu.Unlock() - s.messages = append(s.messages, text) - return nil -} - -func (s *recordingSender) sent() []string { - s.mu.Lock() - defer s.mu.Unlock() - return append([]string(nil), s.messages...) -} - type pipelineEnv struct { app core.App service *TranscribeService repos Repositories recordRepo *pbrepo.AudioRecordRepository fileRepo *pbrepo.FileRepository - sender *recordingSender } // testLimits — пределы простоя проверок. Числа боевые; проверка застревания @@ -104,6 +84,19 @@ func newPipelineEnvWith( rec contract.AudioRecognizer, ) *pipelineEnv { t.Helper() + return newPipelineEnvWithLogger(t, metaviewer, converter, rec, slog.New(slog.DiscardHandler)) +} + +// newPipelineEnvWithLogger — то же с подменённым журналом: проверке, судящей +// строку журнала, нужен свой, а не общий. +func newPipelineEnvWithLogger( + t *testing.T, + metaviewer contract.AudioMetaViewer, + converter contract.AudioFileConverter, + rec contract.AudioRecognizer, + logger *slog.Logger, +) *pipelineEnv { + t.Helper() app, err := pbrepo.New(t.TempDir()) require.NoError(t, err) @@ -128,9 +121,7 @@ func newPipelineEnvWith( Recognitions: pbrepo.NewRecognitionRepository(app), Events: pbrepo.NewRecordEventRepository(app), } - sender := &recordingSender{} - - svc := NewTranscribeService(repos, metaviewer, converter, rec, sender, testLimits, slog.New(slog.DiscardHandler)) + svc := NewTranscribeService(repos, metaviewer, converter, rec, testLimits, logger) return &pipelineEnv{ app: app, @@ -138,15 +129,16 @@ func newPipelineEnvWith( repos: repos, recordRepo: recordRepo, fileRepo: fileRepo, - sender: sender, } } -// newTelegramRecord заводит запись — так, как её завёл бы приём из бота. -func newTelegramRecord(t *testing.T, env *pipelineEnv) *entity.AudioRecord { +// newRecord заводит запись — так, как её заводит приём по HTTP: от имени +// вошедшего, потому что ничьей записи в хранилище не бывает. +func newRecord(t *testing.T, env *pipelineEnv) *entity.AudioRecord { t.Helper() - record, err := env.service.CreateJobFromTelegram(t.Context(), strings.NewReader("запись"), "voice.ogg", 100, 1) + record, err := env.service.CreateJobFromApi( + t.Context(), strings.NewReader("запись"), "voice.ogg", newOwner(t, env.app)) require.NoError(t, err) return record } @@ -175,6 +167,17 @@ func enteredStateAt(t *testing.T, env *pipelineEnv, recordID string, moment time require.NoError(t, env.app.Save(record)) } +// setAttempts ставит записи число отказов: так она выглядит, когда шаг отказал +// столько раз подряд, что предел исчерпан. +func setAttempts(t *testing.T, env *pipelineEnv, recordID string, attempts int) { + t.Helper() + + record, err := env.app.FindRecordById(migrations.RecordsCollection, recordID) + require.NoError(t, err) + record.Set("attempts", attempts) + require.NoError(t, env.app.Save(record)) +} + // drain крутит конвейер, пока он двигает записи. Паузы опроса снимаются: они // проверяются отдельно, а здесь мешают дойти до конца. func drain(t *testing.T, env *pipelineEnv, recordID string) { @@ -217,7 +220,7 @@ func readRecord(t *testing.T, env *pipelineEnv, id string) *entity.AudioRecord { func TestRecordHaltsAfterAttemptLimit(t *testing.T) { env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{}) - record := newTelegramRecord(t, env) + record := newRecord(t, env) // Захват без выполнения шага — так это выглядит при гибели процесса: отказа // шаг объявить не успевает, а попытка засчитана. @@ -239,9 +242,6 @@ func TestRecordHaltsAfterAttemptLimit(t *testing.T) { assert.Equal(t, entity.StateUploaded, after.State, "рубеж пережил остановку") assert.Greater(t, after.Attempts, maxAttempts, "число отказов сохранено") - require.Len(t, env.sender.sent(), 1, "отправитель узнал о неудаче") - assert.Contains(t, env.sender.sent()[0], "попытки исчерпаны") - // И из выборки она исчезла. _, err = env.recordRepo.FindAndAcquire(entity.WorkingStages()) var missing *contract.JobNotFoundError @@ -265,7 +265,7 @@ func expireAcquisition(t *testing.T, env *pipelineEnv, recordID string) { func TestHaltedRecordResumesFromItsStage(t *testing.T) { env := newPipelineEnv(t, &okMetaViewer{}, &okConverter{}) - record := newTelegramRecord(t, env) + record := newRecord(t, env) // Доводим до рубежа приведения и останавливаем на нём. require.NoError(t, env.service.RunStep(t.Context())) @@ -302,7 +302,7 @@ func TestHaltedRecordResumesFromItsStage(t *testing.T) { func TestBothFileLinksSurvivePipeline(t *testing.T) { env := newPipelineEnv(t, &okMetaViewer{}, &okConverter{}) - record := newTelegramRecord(t, env) + record := newRecord(t, env) drain(t, env, record.Id) after := readRecord(t, env, record.Id) @@ -327,7 +327,7 @@ func TestBothFileLinksSurvivePipeline(t *testing.T) { func TestOutcomeDoesNotDependOnWorkerCount(t *testing.T) { for _, workers := range []int{1, 4} { env := newPipelineEnv(t, &okMetaViewer{}, &okConverter{}) - record := newTelegramRecord(t, env) + record := newRecord(t, env) for range 20 { clearDelay(t, env, record.Id) @@ -361,7 +361,7 @@ func TestOutcomeDoesNotDependOnWorkerCount(t *testing.T) { // Записи принимаются и не двигаются, и это режим, а не поломка. func TestZeroWorkersLeaveRecordUntouched(t *testing.T) { env := newPipelineEnv(t, &okMetaViewer{}, &okConverter{}) - record := newTelegramRecord(t, env) + record := newRecord(t, env) // Пул нулевого размера ни одного прогона не делает — потому запись остаётся // там, где её оставил приём. @@ -393,7 +393,7 @@ func TestPostponeKeepsStuckCountdown(t *testing.T) { func TestStuckRecordIsHalted(t *testing.T) { env := newPipelineEnv(t, &okMetaViewer{}, &okConverter{}) - record := newTelegramRecord(t, env) + record := newRecord(t, env) enteredStateAt(t, env, record.Id, time.Now().Add(-2*testLimits.Own)) err := env.service.RunStep(t.Context()) @@ -406,8 +406,6 @@ func TestStuckRecordIsHalted(t *testing.T) { assert.Equal(t, entity.HaltReasonStuck, *after.HaltReason, "причина названа") assert.Equal(t, entity.StateUploaded, after.State, "рубеж сохранён") - require.Len(t, env.sender.sent(), 1, "отправитель узнал о неудаче") - assert.Contains(t, env.sender.sent()[0], "застряла") } // Конечный рубеж под предел простоя не подпадает: стоять в нём запись будет @@ -415,7 +413,7 @@ func TestStuckRecordIsHalted(t *testing.T) { func TestDoneRecordIsNeverStuck(t *testing.T) { env := newPipelineEnv(t, &okMetaViewer{}, &okConverter{}) - record := newTelegramRecord(t, env) + record := newRecord(t, env) drain(t, env, record.Id) enteredStateAt(t, env, record.Id, time.Now().Add(-10*24*time.Hour)) @@ -434,7 +432,7 @@ func TestDoneRecordIsNeverStuck(t *testing.T) { func TestOnlyHolderWritesResult(t *testing.T) { env := newPipelineEnv(t, &okMetaViewer{}, &okConverter{}) - record := newTelegramRecord(t, env) + record := newRecord(t, env) first, err := env.recordRepo.FindAndAcquire(entity.WorkingStages()) require.NoError(t, err) @@ -457,12 +455,12 @@ func TestOnlyHolderWritesResult(t *testing.T) { after := readRecord(t, env, record.Id) assert.Equal(t, entity.StateUploaded, after.State, "рубеж не сдвинут потерявшим захват") - assert.Empty(t, env.sender.sent(), "и отправителю от него ничего не ушло") } -// Критерий приёмки 7. Всякий способ вывести запись из работы сообщает -// отправителю: причин остановки больше одной, и обязанность у них общая. -func TestEveryHaltReasonNotifiesSender(t *testing.T) { +// Критерий приёмки 7. Всякий способ вывести запись из работы оставляет причину +// остановки: причин больше одной, и обязанность у них общая. Отправитель узнаёт +// исход опросом готовности, а владелец сервиса — журналом событий записи. +func TestEveryHaltReasonRecordsItsCause(t *testing.T) { reasons := []struct { name string halt func(t *testing.T, env *pipelineEnv, recordID string) @@ -481,18 +479,40 @@ func TestEveryHaltReasonNotifiesSender(t *testing.T) { require.ErrorAs(t, env.service.RunStep(t.Context()), &noop) }, }, + { + name: "исчерпанные отказы", + halt: func(t *testing.T, env *pipelineEnv, recordID string) { + setAttempts(t, env, recordID, maxAttempts+1) + var noop *contract.NoopJobError + require.ErrorAs(t, env.service.RunStep(t.Context()), &noop) + }, + }, } for _, reason := range reasons { t.Run(reason.name, func(t *testing.T) { env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{}) - record := newTelegramRecord(t, env) + record := newRecord(t, env) reason.halt(t, env, record.Id) after := readRecord(t, env, record.Id) require.True(t, after.IsHalted(), "запись остановлена") - require.Len(t, env.sender.sent(), 1, "отправитель узнал о неудаче") + + // Признак остановки и причина ставятся одним движением, поэтому + // вторым утверждением берётся **журнал событий**: он пишется + // отдельной строкой, отдельным сохранением, и упасть может сам по + // себе. Прежде эту роль играл счёт ответов отправителю; ответы ушли + // вместе с входом Telegram, и без замены проверка вывелась бы из + // собственной предыдущей строки. + events := recordEvents(t, env, record.Id) + require.Len(t, events, 1, "остановка оставила строку журнала событий") + + outcome := events[0].GetString("outcome") + assert.Contains(t, + []string{entity.EventOutcomeHalted, entity.EventOutcomeFailed}, outcome, + "строка журнала называет исход остановкой") + assert.NotEmpty(t, events[0].GetString("outcome_text"), "и несёт причину") }) } } @@ -503,7 +523,7 @@ func TestEveryHaltReasonNotifiesSender(t *testing.T) { func TestRepeatedStoreKeepsSingleText(t *testing.T) { env := newPipelineEnv(t, &okMetaViewer{}, &okConverter{}) - record := newTelegramRecord(t, env) + record := newRecord(t, env) first, err := env.repos.Texts.Put(record.Id, entity.TextKindTranscript, "первый разбор") require.NoError(t, err) @@ -535,7 +555,7 @@ func TestRetryDelayGrowsAndCaps(t *testing.T) { func TestFailedStepSchedulesRetryWithGrowingDelay(t *testing.T) { env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{}) - record := newTelegramRecord(t, env) + record := newRecord(t, env) // Ссылка переставляется на запись о файле без содержимого: шаг отказывает на // получении рабочей копии — то есть отказом, а не приговором записи. @@ -544,6 +564,8 @@ func TestFailedStepSchedulesRetryWithGrowingDelay(t *testing.T) { empty := core.NewRecord(files) empty.Set("location", entity.LocationLocal) empty.Set("size", 1) + // Владелец обязателен и у файла: схема ничьих не принимает. + empty.Set("owner", record.OwnerID) require.NoError(t, env.app.Save(empty)) stored, err := env.app.FindRecordById(migrations.RecordsCollection, record.Id) @@ -606,7 +628,7 @@ func TestWorkFilesRemovedAfterConversionFailure(t *testing.T) { env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{}) - newTelegramRecord(t, env) + newRecord(t, env) leftovers, err := filepath.Glob(filepath.Join(tempDir, "transcriber-*")) require.NoError(t, err) @@ -624,7 +646,7 @@ func TestWorkFilesRemovedAfterConversionFailure(t *testing.T) { func TestRecordNeverPointsToMissingFile(t *testing.T) { env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{}) - record := newTelegramRecord(t, env) + record := newRecord(t, env) require.NoError(t, env.service.RunStep(t.Context())) @@ -714,7 +736,7 @@ func TestHaltIsCountedAsFailureAndLoggedOnce(t *testing.T) { beforeOk := stageCount(t, entity.StateUploaded, "false") beforeErr := stageCount(t, entity.StateUploaded, "true") - record := newTelegramRecord(t, env) + record := newRecord(t, env) require.NoError(t, env.service.RunStep(t.Context())) after := readRecord(t, env, record.Id) @@ -739,7 +761,7 @@ func TestPostponeWritesNoEvent(t *testing.T) { rec.inProgress.Store(5) env := newPipelineEnvWith(t, &okMetaViewer{}, &okConverter{}, rec) - record := newTelegramRecord(t, env) + record := newRecord(t, env) require.NoError(t, env.service.RunStep(t.Context())) // приведение require.NoError(t, env.service.RunStep(t.Context())) // отправка require.Equal(t, entity.StateSubmitted, readRecord(t, env, record.Id).State) diff --git a/internal/service/recognition_test.go b/internal/service/recognition_test.go index 6092ff5..d5425a6 100644 --- a/internal/service/recognition_test.go +++ b/internal/service/recognition_test.go @@ -1,10 +1,12 @@ package service import ( + "bytes" "context" "encoding/json" "errors" "io" + "log/slog" "sync/atomic" "testing" @@ -101,7 +103,7 @@ func TestStructureIsBuiltFromStoredPayload(t *testing.T) { rec := &countingRecognizer{} env := newPipelineEnvWith(t, &okMetaViewer{}, &okConverter{}, rec) - record := newTelegramRecord(t, env) + record := newRecord(t, env) drain(t, env, record.Id) after := readRecord(t, env, record.Id) @@ -141,7 +143,7 @@ func TestPaidWorkIsNotRepeated(t *testing.T) { rec := &countingRecognizer{} env := newPipelineEnvWith(t, &okMetaViewer{}, &okConverter{}, rec) - record := newTelegramRecord(t, env) + record := newRecord(t, env) // Приведение. require.NoError(t, env.service.RunStep(t.Context())) @@ -171,7 +173,7 @@ func TestPollingPostponesWithoutSpendingAttempts(t *testing.T) { rec.inProgress.Store(3) env := newPipelineEnvWith(t, &okMetaViewer{}, &okConverter{}, rec) - record := newTelegramRecord(t, env) + record := newRecord(t, env) require.NoError(t, env.service.RunStep(t.Context())) // приведение require.NoError(t, env.service.RunStep(t.Context())) // отправка @@ -195,3 +197,88 @@ func TestPollingPostponesWithoutSpendingAttempts(t *testing.T) { assert.Len(t, delays, 3, "все три прогона отложили работу") } + +// emptyRecognizer отдаёт готовую операцию с пустым результатом: так выглядит +// оплаченное распознавание, из которого ничего не вышло. +type emptyRecognizer struct { + countingRecognizer +} + +func (r *emptyRecognizer) Fetch(context.Context, string) (*entity.RecognitionOutcome, error) { + r.fetches.Add(1) + return &entity.RecognitionOutcome{}, nil +} + +// Пустой результат виден владельцу сервиса записью журнала «может стать +// проблемой». Прежде этот случай был виден ответом отправителю — «на записи нет +// текста», — и вместе с убранной доставкой он исчез бы вовсе: запись доходит до +// конца молча и от успешной не отличается. Текста в записи нет по инварианту +// приватности: пустой ему взяться неоткуда, а проверка судит уровень и +// идентификатор. +func TestEmptyRecognitionIsNamedInJournal(t *testing.T) { + journal := &bytes.Buffer{} + + env := newPipelineEnvWithLogger(t, &okMetaViewer{}, &okConverter{}, &emptyRecognizer{}, + slog.New(slog.NewTextHandler(journal, &slog.HandlerOptions{Level: slog.LevelDebug}))) + + record := newRecord(t, env) + drain(t, env, record.Id) + + written := journal.String() + assert.Contains(t, written, "Recognition returned empty text", "пустой результат назван") + assert.Contains(t, written, "level=WARN", "уровень — «может стать проблемой»") + assert.Contains(t, written, record.Id, "запись названа идентификатором") +} + +// exhaustibleRecognizer отдаёт полный результат один раз, а на всяком следующем +// обращении — пустой: так выглядит провайдер, чей поток закрылся на первом же +// ответе. Отказом это не считается ни у него, ни у нас. +type exhaustibleRecognizer struct { + countingRecognizer + served atomic.Bool +} + +func (r *exhaustibleRecognizer) Fetch(context.Context, string) (*entity.RecognitionOutcome, error) { + r.fetches.Add(1) + if r.served.Swap(true) { + return &entity.RecognitionOutcome{}, nil + } + return r.Parse(countingPayload(t0Replicas)) +} + +// Повторный опрос той же операции — обычное дело: держатель захвата умер, +// сохранение рубежа отказало, человек снял остановку в панели. Пустой ответ +// провайдера при этом не должен стирать уже сохранённую расшифровку: сервис +// объявлен архивом, а восстановления у стёртого текста нет. +func TestEmptySecondAnswerKeepsArchivedText(t *testing.T) { + rec := &exhaustibleRecognizer{} + env := newPipelineEnvWith(t, &okMetaViewer{}, &okConverter{}, rec) + + record := newRecord(t, env) + drain(t, env, record.Id) + + stored := readRecord(t, env, record.Id) + require.NotNil(t, stored.TranscriptTextID, "расшифровка сохранена первым ответом") + + before, err := env.repos.Texts.GetByID(*stored.TranscriptTextID) + require.NoError(t, err) + require.NotEmpty(t, before.Contents) + + // Второй опрос той же записи: рубеж возвращается на отправленный, и шаг + // забирает результат заново — теперь пустой. + back := readRecord(t, env, record.Id) + back.MoveToState(entity.StateSubmitted) + require.NoError(t, env.recordRepo.Save(back, "")) + clearDelay(t, env, record.Id) + require.NoError(t, env.service.RunStep(t.Context())) + + // Проверка обязана дойти до второго ответа: иначе она зеленела бы, ничего не + // проверив, — а класс «проверка, не способная упасть» в этом проекте ловили + // уже трижды. + require.EqualValues(t, 2, rec.fetches.Load(), "результат забирали дважды") + + after, err := env.repos.Texts.GetByID(*stored.TranscriptTextID) + require.NoError(t, err) + assert.Equal(t, before.Contents, after.Contents, + "пустой ответ провайдера не стирает сохранённую расшифровку") +} diff --git a/internal/service/shutdown_test.go b/internal/service/shutdown_test.go index 9948339..b3c1409 100644 --- a/internal/service/shutdown_test.go +++ b/internal/service/shutdown_test.go @@ -37,7 +37,7 @@ func TestShutdownDuringConversionKeepsRecordRetryable(t *testing.T) { converter := &killedConverter{cancel: cancel} env := newPipelineEnv(t, &okMetaViewer{}, converter) - record := newTelegramRecord(t, env) + record := newRecord(t, env) err := env.service.RunStep(ctx) @@ -51,14 +51,13 @@ func TestShutdownDuringConversionKeepsRecordRetryable(t *testing.T) { assert.Nil(t, after.AcquisitionID, "захват снят: запись возьмёт следующий прогон") assert.Equal(t, 0, after.Attempts, "остановка отказа не тратит") assert.Nil(t, after.ErrorText) - assert.Empty(t, env.sender.sent(), "отправителю о несуществующем сбое не сообщают") } // Запись, которую шаг не успел взять, потому что нас уже остановили, остаётся // нетронутой: захват не случился, отказ не потрачен. func TestShutdownBeforeStepLeavesRecordUntouched(t *testing.T) { env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{}) - record := newTelegramRecord(t, env) + record := newRecord(t, env) ctx, cancel := context.WithCancel(t.Context()) cancel() diff --git a/internal/service/transcribe.go b/internal/service/transcribe.go index 92dd791..d3cb1d9 100644 --- a/internal/service/transcribe.go +++ b/internal/service/transcribe.go @@ -66,7 +66,6 @@ type TranscribeService struct { metaviewer contract.AudioMetaViewer converter contract.AudioFileConverter recognizer contract.AudioRecognizer - tgSender contract.TelegramMessageSender limits entity.StuckLimits logger *slog.Logger } @@ -76,7 +75,6 @@ func NewTranscribeService( metaviewer contract.AudioMetaViewer, converter contract.AudioFileConverter, recognizer contract.AudioRecognizer, - tgSender contract.TelegramMessageSender, limits entity.StuckLimits, logger *slog.Logger, ) *TranscribeService { @@ -88,7 +86,6 @@ func NewTranscribeService( metaviewer: metaviewer, converter: converter, recognizer: recognizer, - tgSender: tgSender, limits: limits, logger: logger, } @@ -134,25 +131,14 @@ func (s *TranscribeService) stepFor(state string) (string, step, bool) { } } -func (s *TranscribeService) CreateJobFromTelegram(ctx context.Context, file io.Reader, fileName string, chatId int64, replyMsgId int) (*entity.AudioRecord, error) { - record := &entity.AudioRecord{ - State: entity.StateUploaded, - Source: entity.SourceTelegram, - TgChatId: &chatId, - TgReplyMessageId: &replyMsgId, - } - - return s.createRecord(ctx, record, file, fileName) -} - -// CreateJobFromApi заводит запись от имени вошедшего. Владелец обязателен: -// пустой отвергается здесь, потому что колонка владельца допускает пустое -// значение ради записей бота, и приём по HTTP — то место, где обязательность -// держится. +// CreateJobFromApi заводит запись от имени вошедшего. Владелец обязателен, и +// обязательность эту держит схема хранилища: колонка владельца пустого значения +// не принимает. Отказ стоит и здесь, раньше схемы, потому что отвечает +// отправителю понятной ошибкой до того, как запись ляжет в хранилище: схема +// отказала бы уже после укладки файла, а уборки файлов сервис не умеет. // -// Отказ этот — последний рубеж, а не первый: предъявителя без учётной записи -// пользователя транспорт отвергает раньше, до чтения тела. Здесь он остаётся на -// случай нового вызывающего, который такой проверки не поставит. +// Первый рубеж при этом ещё раньше: предъявителя без учётной записи пользователя +// транспорт отвергает до чтения тела. func (s *TranscribeService) CreateJobFromApi(ctx context.Context, file io.Reader, fileName, ownerID string) (*entity.AudioRecord, error) { if ownerID == "" { s.logger.Error("Refusing to create record without owner") @@ -162,7 +148,7 @@ func (s *TranscribeService) CreateJobFromApi(ctx context.Context, file io.Reader record := &entity.AudioRecord{ State: entity.StateUploaded, Source: entity.SourceApi, - OwnerID: &ownerID, + OwnerID: ownerID, } return s.createRecord(ctx, record, file, fileName) @@ -212,7 +198,7 @@ func (s *TranscribeService) createRecord(ctx context.Context, r *entity.AudioRec DurationMs: int64(info.Seconds) * 1000, } - fileRecord, err := s.repos.Files.Create(storageFileName, work, meta, ownerOf(r)) + fileRecord, err := s.repos.Files.Create(storageFileName, work, meta, r.OwnerID) if err != nil { s.logger.Error("Failed to create file record", "error", err, "file_ext", ext) return nil, err @@ -253,8 +239,8 @@ func (s *TranscribeService) createRecord(ctx context.Context, r *entity.AudioRec // // Контекст доходит до шага, а через него — до внешнего собеседника: остановка // сервиса убивает `ffmpeg` и обрывает запрос к распознаванию. Прерванный шаг -// приговора не выносит: запись остаётся пригодной к повтору, отказа не тратит и -// отправителю о несуществующем сбое не сообщает. +// приговора не выносит: запись остаётся пригодной к повтору и отказа не +// тратит. func (s *TranscribeService) RunStep(ctx context.Context) error { // Нас уже остановили — запись не забираем: захват стоил бы ей отказа, а // работы всё равно не будет. Исход «шаг не сделал ничего» — это @@ -275,8 +261,7 @@ func (s *TranscribeService) RunStep(ctx context.Context) error { // таблицы. Молчать нельзя: запись выпала бы из работы без единого следа. s.logger.Error("No step declared for state", "record_id", record.Id, "state", record.State) s.halt(record, holder, record.State, entity.HaltReasonStepFailed, - fmt.Sprintf("no step for state %s", record.State), - "сервис не знает, что делать с этой записью") + fmt.Sprintf("no step for state %s", record.State)) return &contract.NoopJobError{State: record.State} } @@ -347,8 +332,7 @@ func (s *TranscribeService) acquire() (*entity.AudioRecord, string, error) { s.logger.Error("Record exhausted its attempts", "record_id", record.Id, "state", record.State, "attempts", record.Attempts) s.halt(record, acquired.Holder, record.State, entity.HaltReasonAttempts, - fmt.Sprintf("attempts exhausted: %d", record.Attempts), - "попытки исчерпаны") + fmt.Sprintf("attempts exhausted: %d", record.Attempts)) return nil, "", &contract.NoopJobError{State: record.State} } @@ -360,8 +344,7 @@ func (s *TranscribeService) acquire() (*entity.AudioRecord, string, error) { "record_id", record.Id, "state", record.State, "state_entered_at", record.StateEnteredAt) s.halt(record, acquired.Holder, record.State, entity.HaltReasonStuck, - fmt.Sprintf("stuck in %s", record.State), - "обработка застряла") + fmt.Sprintf("stuck in %s", record.State)) return nil, "", &contract.NoopJobError{State: record.State} } @@ -391,7 +374,7 @@ func (s *TranscribeService) normalize(ctx context.Context, r *entity.AudioRecord if r.OriginalFileID == nil { s.logger.Error("Record has no original file", "record_id", r.Id) - return s.failStep(r, holder, stepNormalize, errors.New("record has no original file"), "у записи нет файла") + return s.failStep(r, holder, stepNormalize, errors.New("record has no original file")) } srcFile, err := s.repos.Files.GetByID(*r.OriginalFileID) @@ -441,7 +424,7 @@ func (s *TranscribeService) normalize(ctx context.Context, r *entity.AudioRecord s.logger.Error("File conversion failed", "error", err, "record_id", r.Id, "duration", conversionDuration) - return s.failStep(r, holder, stepNormalize, err, "сбой конвертации файла") + return s.failStep(r, holder, stepNormalize, err) } destSize, err := dest.Size() @@ -457,7 +440,7 @@ func (s *TranscribeService) normalize(ctx context.Context, r *entity.AudioRecord destFileName := fmt.Sprintf("%s%s", uuid.NewString(), ".ogg") destMeta := contract.FileMeta{Format: "ogg", DurationMs: srcFile.DurationMs} - destFileRecord, err := s.repos.Files.Create(destFileName, dest, destMeta, ownerOf(r)) + destFileRecord, err := s.repos.Files.Create(destFileName, dest, destMeta, r.OwnerID) if err != nil { s.logger.Error("Failed to create normalized file record", "error", err, "record_id", r.Id) return outcomeDone, err @@ -489,7 +472,7 @@ func (s *TranscribeService) submit(ctx context.Context, r *entity.AudioRecord, h if r.NormalizedFileID == nil { s.logger.Error("Record has no normalized file", "record_id", r.Id) - return s.failStep(r, holder, stepSubmit, errors.New("record has no normalized file"), "у записи нет приведённого файла") + return s.failStep(r, holder, stepSubmit, errors.New("record has no normalized file")) } fileRecord, err := s.repos.Files.GetByID(*r.NormalizedFileID) @@ -646,7 +629,7 @@ func (s *TranscribeService) uploadSource( func (s *TranscribeService) poll(ctx context.Context, r *entity.AudioRecord, holder string) (stepOutcome, error) { if r.RecognitionID == nil { s.logger.Error("Record has no recognition attempt", "record_id", r.Id) - return s.failStep(r, holder, stepPoll, errors.New("record has no recognition attempt"), "сведений о распознавании нет") + return s.failStep(r, holder, stepPoll, errors.New("record has no recognition attempt")) } attempt, err := s.repos.Recognitions.GetByID(*r.RecognitionID) @@ -656,7 +639,7 @@ func (s *TranscribeService) poll(ctx context.Context, r *entity.AudioRecord, hol } if attempt.ExternalID == "" { s.logger.Error("Recognition attempt has no operation id", "record_id", r.Id) - return s.failStep(r, holder, stepPoll, errors.New("recognition attempt has no operation id"), "сведений о распознавании нет") + return s.failStep(r, holder, stepPoll, errors.New("recognition attempt has no operation id")) } // Опрос идёт раз в несколько секунд всё время распознавания: часовая запись @@ -689,7 +672,7 @@ func (s *TranscribeService) poll(ctx context.Context, r *entity.AudioRecord, hol errorText := result.GetError() s.logger.Error("Operation failed", "record_id", r.Id, "operation_id", attempt.ExternalID, "error_message", errorText) - return s.failStep(r, holder, stepPoll, errors.New(errorText), "сбой при распознавании файла") + return s.failStep(r, holder, stepPoll, errors.New(errorText)) } outcome, err := s.recognizer.Fetch(ctx, attempt.ExternalID) @@ -708,6 +691,17 @@ func (s *TranscribeService) poll(ctx context.Context, r *entity.AudioRecord, hol "text_length", len(outcome.PlainText), "replicas", len(outcome.Replicas)) + // Пустой результат — оплаченная наружу операция, из которой ничего не + // вышло. Прежде этот случай был виден ответом отправителю («на записи нет + // текста»), и вместе с доставкой видимость исчезла бы вовсе: запись дошла бы + // до конца молча и от успешной не отличалась. Уровень — «может стать + // проблемой»: конвенция журнала называет пустой текст распознавания + // поимённым примером. Текста в записи нет по инварианту приватности, только + // длина. + if len(outcome.PlainText) == 0 && len(outcome.Replicas) == 0 { + s.logger.Warn("Recognition returned empty text", "record_id", r.Id, "operation_id", attempt.ExternalID) + } + // Сырой ответ сохраняется целиком: результат операции у провайдера не // переспрашивается, и когда мы научимся размечать говорящих, архив // пересчитается из сохранённого без единого рубля. @@ -749,55 +743,42 @@ func (s *TranscribeService) storeOutcome(r *entity.AudioRecord, outcome *entity. return nil } -// finish отвечает отправителю и доводит запись до конечного рубежа. Доставка — -// хвост последнего шага, а не отдельный узел конвейера. +// finish доводит запись до конечного рубежа. Наружу шаг не обращается: доставки +// ответа отправителю у сервиса нет, и свой исход отправитель узнаёт опросом +// готовности. func (s *TranscribeService) finish(ctx context.Context, r *entity.AudioRecord, holder string) (stepOutcome, error) { - text := "Ой, кажется, на аудиозаписи нет текста." - if r.TranscriptTextID != nil { - stored, err := s.repos.Texts.GetByID(*r.TranscriptTextID) - if err != nil { - s.logger.Error("Failed to read transcript", "error", err, "record_id", r.Id) - return outcomeDone, err - } - if stored.Contents != "" { - text = stored.Contents - } - } - r.MoveToState(entity.StateDone) if err := s.repos.Records.Save(r, holder); err != nil { s.logger.Error("Failed to save record", "error", err, "record_id", r.Id) return outcomeDone, err } - return outcomeDone, s.send(r, text) + return outcomeDone, nil } -// failStep останавливает запись приговором шага и сообщает отправителю. +// failStep останавливает запись приговором шага. // // Исход возвращается **явно**: остановка отказом шага не является — шаг // рассудил об этой записи окончательно, — но и работой она не была, и // засчитывать её успехом нельзя. -func (s *TranscribeService) failStep(r *entity.AudioRecord, holder, step string, stepErr error, humanText string) (stepOutcome, error) { - s.halt(r, holder, step, entity.HaltReasonStepFailed, stepErr.Error(), - fmt.Sprintf("При обработке записи произошла ошибка: %s", humanText)) +func (s *TranscribeService) failStep(r *entity.AudioRecord, holder, step string, stepErr error) (stepOutcome, error) { + s.halt(r, holder, step, entity.HaltReasonStepFailed, stepErr.Error()) return outcomeHalted, nil } -// halt ставит признак остановки, считает её отказом, пишет строку журнала -// событий и сообщает отправителю. +// halt ставит признак остановки, считает её отказом и пишет строку журнала +// событий. // -// Сообщение уходит при **любой** причине остановки: инвариант проекта «Принятая -// запись не теряется молча» допускает два исхода — запись пригодна к повтору -// либо об отказе сказано, — а остановленная запись захвату не выдаётся, значит -// первый исход исключён. +// Отправителю отсюда ничего не уходит: инвариант проекта «Принятая запись не +// теряется молча» держится теперь опросом готовности — остановка видна там +// признаком — и журналом владельца, где у неё стоит причина. // // Счётчик растит **сама остановка**, а не воркер, и это не стилистика. // Остановка по сторожам наступает в захвате, до всякого шага, и воркер о ней // узнаёт признаком «работы нет» — а считать его в метрику запрещено инвариантом // «`NoopJobError` — не ошибка». Значит единственное место, где известны и факт // остановки, и рубеж, — здесь. -func (s *TranscribeService) halt(r *entity.AudioRecord, holder, step, reason, errText, humanText string) { +func (s *TranscribeService) halt(r *entity.AudioRecord, holder, step, reason, errText string) { // Рубеж читается до остановки: она его не двигает, но читать состояние после // мутации — привычка, из-за которой метка счётчика уже однажды разъехалась. stage := r.State @@ -823,8 +804,6 @@ func (s *TranscribeService) halt(r *entity.AudioRecord, holder, step, reason, er outcome = entity.EventOutcomeFailed } s.appendEvent(r, entity.EventOriginPipeline, step, outcome, reason, 0) - - s.notify(r, humanText+"\nПожалуйста, попробуйте еще раз.") } // scheduleRetry снимает захват с отказавшей записи и ставит нарастающую паузу. @@ -885,69 +864,6 @@ func (s *TranscribeService) appendEvent(r *entity.AudioRecord, origin, step, out } } -// send отвечает отправителю там, откуда пришла запись. Отказ отправки поднимает -// вверх: он принадлежит шагу. -// -// Кроме недоставки — её шаг записывает и завершается без отказа. Ответ уходит -// после того, как достигнутый рубеж сохранён: работа к этой минуте сделана, и -// объявленный отказ засчитался бы воркеру сбоем и лёг бы владельцу записью -// отказа. Повтор делу не помогает — ни бот, ни адресат от ожидания не появятся, -// — поэтому причина недоставки живёт в журнале, а не в рубеже записи. -func (s *TranscribeService) send(r *entity.AudioRecord, text string) error { - if r.Source != entity.SourceTelegram { - return nil - } - - // Адресата у записи нет: отвечать некуда, и повторять нечего. Уровень здесь - // выше, чем у неподнятого канала, и это не педантизм: пустой чат у записи - // из Telegram — симптом порчи записи. - if r.TgChatId == nil { - s.undelivered(r, slog.LevelError, "chat is not specified") - return nil - } - - if err := s.tgSender.Send(text, *r.TgChatId, r.TgReplyMessageId); err != nil { - // Канал не поднят: сервис работает без этого входа, и это объявленный - // режим, а не поломка. - if errors.Is(err, contract.ErrDeliveryChannelDown) { - s.undelivered(r, slog.LevelWarn, "delivery channel is down") - return nil - } - - s.logger.Error("Failed to sent message to client", "record_id", r.Id) - return fmt.Errorf("failed to sent message to client, record id: %s, err: %w", r.Id, err) - } - - return nil -} - -// undelivered записывает недоставленный ответ и считает его в метрику. Уровень -// приходит от причины: объявленный режим — «может стать проблемой», порча -// записи — событие для разбора. -// -// Идентификатор записи обязателен, иначе владелец видит, что ответ не ушёл, но -// не может найти, чей; текста ответа в записи нет — он содержимое чужой записи. -func (s *TranscribeService) undelivered(r *entity.AudioRecord, level slog.Level, reason string) { - metrics.UndeliveredReplyCounter.WithLabelValues(reason).Inc() - - switch level { - case slog.LevelError: - s.logger.Error(undeliveredMessage, "record_id", r.Id, "reason", reason) - default: - s.logger.Warn(undeliveredMessage, "record_id", r.Id, "reason", reason) - } -} - -const undeliveredMessage = "Reply was not delivered" - -// notify отвечает отправителю там, где поднимать отказ некуда: запись уже -// доведена до своего исхода, и отказ отправки остаётся записью в журнале. -func (s *TranscribeService) notify(r *entity.AudioRecord, text string) { - if err := s.send(r, text); err != nil { - s.logger.Error("Failed to notify sender", "error", err, "record_id", r.Id) - } -} - // closeWork убирает рабочую копию. Отказ уборки не роняет шаг, но и не // проглатывается: забытая копия это шестичасовая запись во временном каталоге. func (s *TranscribeService) closeWork(work contract.WorkFile) { @@ -956,16 +872,6 @@ func (s *TranscribeService) closeWork(work contract.WorkFile) { } } -// ownerOf — владелец записи строкой; пустая значит «владельца нет», и таковы -// записи, принятые ботом. Файл наследует владельца своей записи: правило -// просмотра коллекции файлов сужено этой колонкой. -func ownerOf(r *entity.AudioRecord) string { - if r.OwnerID == nil { - return "" - } - return *r.OwnerID -} - // formatOf приводит расширение к виду колонки формата: без точки, в нижнем // регистре. Наружу оно выходит только приведённым к перечню известных форматов — // это делает метка метрики. diff --git a/internal/service/undelivered_test.go b/internal/service/undelivered_test.go deleted file mode 100644 index a9d662d..0000000 --- a/internal/service/undelivered_test.go +++ /dev/null @@ -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", "недоставки не было") -} diff --git a/main.go b/main.go index 8556d62..e03737b 100644 --- a/main.go +++ b/main.go @@ -20,7 +20,6 @@ import ( pbrepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase" "git.vakhrushev.me/av/transcriber/internal/config" httpcontroller "git.vakhrushev.me/av/transcriber/internal/controller/http" - tgcontroller "git.vakhrushev.me/av/transcriber/internal/controller/tg" "git.vakhrushev.me/av/transcriber/internal/controller/worker" "git.vakhrushev.me/av/transcriber/internal/metrics" "git.vakhrushev.me/av/transcriber/internal/service" @@ -60,14 +59,6 @@ func main() { os.Exit(1) } - // Включённый вход без ключа доступа — ошибка настройки, а не режим: бот по - // пустому ключу не появится, а тихий подъём без него оставил бы отправителей - // без ответов. - if err := cfg.Telegram.Validate(); err != nil { - logger.Error("Unable to start with incomplete telegram settings", "error", err) - os.Exit(1) - } - // Числа конвейера проверяются здесь же: ноль воркеров — объявленный режим, а // отрицательное число и нулевой предел простоя — опечатка, и подниматься с // ней значит остановить всякую запись первым же захватом. @@ -112,12 +103,6 @@ func main() { metaviewer := ffmpegmv.NewFfmpegMetaViewer() converter := ffmpegconv.NewFfmpegConverter() - tgBot, tgSender, err := buildTelegram(cfg.Telegram, logger) - if err != nil { - logger.Error("Failed to create Telegram bot", "error", err) - os.Exit(1) - } - recognizer, err := yandex.NewYandexAudioRecognizerService(yandex.YandexAudioRecognizerConfig{ Region: cfg.Yandex.ObjStorageRegion, AccessKey: cfg.Yandex.ObjStorageAccessKey, @@ -147,7 +132,6 @@ func main() { metaviewer, converter, recognizer, - tgSender, cfg.Pipeline.StuckLimits(), logger, ) @@ -159,32 +143,6 @@ func main() { // Создаем WaitGroup для ожидания завершения всех воркеров var wg sync.WaitGroup - tgConfig := tgcontroller.TelegramConfig{ - UpdateTimeout: cfg.Telegram.UpdateTimeout, - UserWhiteList: cfg.Server.UsersWhiteList, - } - - // Транспорт поднимается только там, где есть клиент: о том, что бота нет, - // сказано выше единственной записью, и вторая здесь была бы записью о том - // же факте. - var tgController *tgcontroller.TelegramController - if tgBot != nil { - tgController, err = tgcontroller.NewTelegramController(tgConfig, tgBot, transcribeService, logger) - if err != nil { - logger.Error("Failed to create Telegram controller", "error", err) - os.Exit(1) - } - - // Запускаем Telegram бот в отдельной горутине - wg.Add(1) - go func() { - defer wg.Done() - logger.Info("Starting Telegram bot") - tgController.Start(ctx) - logger.Info("Telegram bot stopped gracefully") - }() - } - // Пул одинаковых воркеров: специализации у них нет, шаг выбирается по рубежу // самой записи. Число приходит настройкой, ноль — законное значение. pool := worker.NewPool(cfg.Pipeline.Workers, transcribeService.RunStep, logger) @@ -194,9 +152,9 @@ func main() { pool.Start(ctx) }() - // Вход по HTTP поднимается всегда: он основной, и отдельного разреза у него - // нет. Признак ставится рядом с признаком Telegram, чтобы владелец судил об - // обоих входах одним отбором. + // Вход у сервиса один — приём по HTTP, — и метка ставится только ему. Метки + // убранного входа Telegram здесь нет намеренно: ноль читался бы как поломка, + // а признак существует ради того дня, когда входов снова станет больше. metrics.IntakeUpGauge.WithLabelValues("http").Set(1) // Наши маршруты живут на роутере хранилища: панель отдаётся тем же портом, @@ -305,11 +263,6 @@ func main() { logger.Error("HTTP server stopped unexpectedly, shutting down") } - if tgController != nil { - logger.Info("Shutting down Telegram bot...") - tgController.Stop() - } - // Создаем контекст с таймаутом для graceful shutdown HTTP сервера shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), time.Duration(cfg.Server.ShutdownTimeout)*time.Second) defer shutdownCancel() diff --git a/openspec/changes/archive/2026-08-15-remove-telegram-intake/.openspec.yaml b/openspec/changes/archive/2026-08-15-remove-telegram-intake/.openspec.yaml new file mode 100644 index 0000000..4af8641 --- /dev/null +++ b/openspec/changes/archive/2026-08-15-remove-telegram-intake/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-14 diff --git a/openspec/changes/archive/2026-08-15-remove-telegram-intake/design.md b/openspec/changes/archive/2026-08-15-remove-telegram-intake/design.md new file mode 100644 index 0000000..cb8b887 --- /dev/null +++ b/openspec/changes/archive/2026-08-15-remove-telegram-intake/design.md @@ -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`: обе теряют предмет до возвращения входа. + Решает владелец; это изменение их не трогает. diff --git a/openspec/changes/archive/2026-08-15-remove-telegram-intake/proposal.md b/openspec/changes/archive/2026-08-15-remove-telegram-intake/proposal.md new file mode 100644 index 0000000..ef5acec --- /dev/null +++ b/openspec/changes/archive/2026-08-15-remove-telegram-intake/proposal.md @@ -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` + теряют предмет до возвращения бота. Судьбу их решает владелец — это изменение + их не закрывает. diff --git a/openspec/changes/archive/2026-08-15-remove-telegram-intake/review/report.md b/openspec/changes/archive/2026-08-15-remove-telegram-intake/review/report.md new file mode 100644 index 0000000..cfcaa74 --- /dev/null +++ b/openspec/changes/archive/2026-08-15-remove-telegram-intake/review/report.md @@ -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= + ПОСЛЕ ШАГА: строка на месте, 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` подтверждена падающим +тестом и живёт в сервисе прямо сейчас. diff --git a/openspec/changes/archive/2026-08-15-remove-telegram-intake/specs/access/spec.md b/openspec/changes/archive/2026-08-15-remove-telegram-intake/specs/access/spec.md new file mode 100644 index 0000000..ce3d69b --- /dev/null +++ b/openspec/changes/archive/2026-08-15-remove-telegram-intake/specs/access/spec.md @@ -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** ответ на обе тот же, что и на неизвестный идентификатор diff --git a/openspec/changes/archive/2026-08-15-remove-telegram-intake/specs/intake/spec.md b/openspec/changes/archive/2026-08-15-remove-telegram-intake/specs/intake/spec.md new file mode 100644 index 0000000..9e23048 --- /dev/null +++ b/openspec/changes/archive/2026-08-15-remove-telegram-intake/specs/intake/spec.md @@ -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, если они в базе есть, остаются нетронутыми и +достаются своему владельцу; ответ в чат по ним не уходит. Возврат входа заводится +новым изменением вместе со связью чата и учётной записи. diff --git a/openspec/changes/archive/2026-08-15-remove-telegram-intake/specs/pipeline/spec.md b/openspec/changes/archive/2026-08-15-remove-telegram-intake/specs/pipeline/spec.md new file mode 100644 index 0000000..dc31509 --- /dev/null +++ b/openspec/changes/archive/2026-08-15-remove-telegram-intake/specs/pipeline/spec.md @@ -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**: Отправитель узнаёт об остановке признаком в ответе опроса +готовности. Владелец сервиса видит остановку записью журнала и полем причины у +самой записи — как и прежде. diff --git a/openspec/changes/archive/2026-08-15-remove-telegram-intake/specs/storage/spec.md b/openspec/changes/archive/2026-08-15-remove-telegram-intake/specs/storage/spec.md new file mode 100644 index 0000000..b280114 --- /dev/null +++ b/openspec/changes/archive/2026-08-15-remove-telegram-intake/specs/storage/spec.md @@ -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** шаг завершается без отказа diff --git a/openspec/changes/archive/2026-08-15-remove-telegram-intake/tasks.md b/openspec/changes/archive/2026-08-15-remove-telegram-intake/tasks.md new file mode 100644 index 0000000..ace13d9 --- /dev/null +++ b/openspec/changes/archive/2026-08-15-remove-telegram-intake/tasks.md @@ -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` зелёный целиком. diff --git a/openspec/specs/access/spec.md b/openspec/specs/access/spec.md index 2fa482d..9a74108 100644 --- a/openspec/specs/access/spec.md +++ b/openspec/specs/access/spec.md @@ -10,12 +10,10 @@ OIDC, чем предъявляется сессия, что её прекращ только на вопрос «узнан ли пришедший», но и на «чьё он смотрит». Запись из веба принадлежит тому, кто её принёс, и чужая неотличима от несуществующей. -Записи, принятые ботом, владельца не имеют вовсе и по API не достаются никому: -связи чата Telegram с учётной записью приложения сервис не ведёт, её заводит -отдельная задача. - -Вход из Telegram эта capability не нормирует: бот проверяет отправителя своим -белым списком, и с учётной записью приложения тот список не связан. +Записи без владельца у сервиса не бывает: колонка владельца пустого значения не +принимает, и норму эту держит capability `storage`. Прежде такие записи заводил +вход Telegram — связи чата с учётной записью сервис не вёл, — и 2026-08-14 вход +убран вместе с этим исключением. ## Requirements ### Requirement: Вход через внешнего провайдера @@ -350,13 +348,14 @@ MUST не делать. Кто допущен, определяет правил ### Requirement: У записи есть владелец, и чужую ей не отдают -Сервис SHALL заводить у каждой записи, принятой **по HTTP**, — владельца, то -есть учётную запись, от имени которой запись принята, — и MUST отдавать данные -такой записи только её владельцу. Владелец назначается один раз, при приёме, и -MUST не меняться у записи, у которой владелец есть: совместного доступа, ролей и -передачи записи другому сервис не знает. Оговорка не случайна — назначить -владельца записи, у которой его нет, вправе задача, заводящая связь чата -Telegram с учётной записью. +Сервис SHALL заводить у каждой принятой записи владельца — учётную запись, от +имени которой запись принята, — и MUST отдавать данные такой записи только её +владельцу. Запись без владельца MUST не заводиться ничем — ни приёмом, ни конвейером, ни +рукой в панели: колонка владельца пустого значения не принимает, и норму эту +держит capability `storage`. + +Владелец назначается один раз, при приёме, и MUST не меняться: совместного +доступа, ролей и передачи записи другому сервис не знает. Владелец MUST браться из предъявленной сессии и ниоткуда больше. Владелец, пришедший полем запроса, дал бы всякому вошедшему право завести запись на чужое @@ -368,16 +367,12 @@ Telegram с учётной записью. что разграничение прячет. Каким именно ответом это выражено, нормирует capability `intake`: там живёт адрес опроса, и держатель нормы обязан быть один. -Пустой владелец MUST не совпадать ни с одной записью — ни со своей, ни с чужой, -ни с ничьей. Правило записано со стороны **спрашивающего**, а не со стороны -записи: обязательность владельца, которую держит одна лишь подпись метода, пустую -строку пропускает, и первый же вызывающий без учётной записи получил бы ровно -множество записей без владельца, то есть все записи бота. - -Записи, принятые из Telegram, владельца не имеют: связи чата с учётной записью -приложения сервис не ведёт. Такая запись MUST не доставаться по API никому — -ответ на неё тот же, что и на несуществующую, — а её расшифровка уезжает -отправителю в чат, как и прежде. +Пустой владелец MUST не совпадать ни с одной записью. Правило записано со стороны +**спрашивающего** и остаётся в силе, хотя записей без владельца в хранилище +больше нет: спрашивающий с пустым владельцем — это вызов, у которого нет учётной +записи, и отвечать ему надо отказом, а не выборкой. Держится оно отдельно от +схемы намеренно: схема запрещает **заводить** ничью запись, а это правило +запрещает **спрашивать** ничьим именем, и одно другое не заменяет. #### Scenario: Своя запись доступна @@ -396,15 +391,13 @@ capability `intake`: там живёт адрес опроса, и держат - **WHEN** запрос на приём записи несёт своё значение владельца - **THEN** владельцем принятой записи становится предъявитель сессии -#### Scenario: Запись из Telegram не достаётся по API +#### Scenario: Ничью запись завести нечем -- **GIVEN** запись принята ботом -- **WHEN** её состояние спрашивает вошедший человек -- **THEN** ответ тот же, что и на неизвестный идентификатор +- **WHEN** запись пытаются завести с пустым владельцем +- **THEN** хранилище её не сохраняет #### Scenario: Пустой владелец не открывает ничего -- **GIVEN** заведены три задачи: своя, чужая и принятая ботом +- **GIVEN** заведены две записи: своя и чужая - **WHEN** состояние каждой спрашивают с пустым владельцем -- **THEN** ответ на все три тот же, что и на неизвестный идентификатор - +- **THEN** ответ на обе тот же, что и на неизвестный идентификатор diff --git a/openspec/specs/intake/spec.md b/openspec/specs/intake/spec.md index b53b9cd..27b4478 100644 --- a/openspec/specs/intake/spec.md +++ b/openspec/specs/intake/spec.md @@ -6,12 +6,9 @@ записью, что уезжает в ответ и что происходит, когда запись не удалось прочитать. Плюс наличие входов: с каким из них сервис вправе подняться. -Приём по существу описан пока **только для HTTP** — того, что нормируют -проверки. Про вход Telegram нормировано одно: настроен он или нет и что из этого -следует для подъёма. Кто допущен к боту и как забирается присланная им запись, -требованиями по-прежнему не описано — требование, написанное без проверки, это -предположение, а не норма. Первая задача, которая трогает поведение приёма из -Telegram, дописывает его сюда. +Вход у сервиса один — приём по HTTP, — и описан он тем, что нормируют проверки. +Второй вход, Telegram, убран 2026-08-14 вместе со своими требованиями; его +возвращение заводит их заново, вместе со связью чата и учётной записи. ## Requirements ### Requirement: Приём записи по HTTP @@ -40,10 +37,12 @@ Telegram, дописывает его сюда. Куда именно ложится принятая запись, приёму не принадлежит: раскладку выбирает хранилище, и нормирует её capability `storage`. -Владельцем принятой записи приём SHALL назначать предъявителя сессии. Проверка -стоит здесь, а не только в схеме хранилища: колонка владельца допускает пустое -значение ради записей из Telegram, и приём по HTTP — то место, где -обязательность держится. +Владельцем принятой записи приём SHALL назначать предъявителя сессии. Обязательность +владельца при этом MUST держаться и схемой хранилища: колонка владельца пустого +значения не принимает вовсе, и норму эту держит capability `storage`. Проверка в +приёме от этого не лишняя — она отвечает отправителю понятным отказом до того, как +запись попадёт в память, а схема отвечала бы отказом сохранения после укладки +файла. Предъявитель, чья сессия не даёт учётной записи пользователя, MUST получать отказ `403` и MUST получать его **до чтения тела** — там же, где стоит отказ по @@ -154,11 +153,9 @@ Telegram, дописывает его сюда. нормирует требование ниже; наружу расширение выходит только приведённым к известному виду — этому отдано отдельное требование. -Сценарии судят приём по HTTP, потому что имя, данное отправителем, доходит до -сервиса только оттуда: из Telegram приходит путь, выданный самим Telegram, а не -имя человека. Правка при этом ложится на общий шаг заведения задачи, через -который идут оба входа, поэтому своей нормы приём из Telegram здесь не получает — -её напишет задача, которая тронет его поведение. +Оговорка про второй вход из требования ушла вместе с ним: имя, данное +отправителем, доходит до сервиса единственным путём — приёмом по HTTP, — и +сценарии судят именно его. #### Scenario: Имя записи не видно в журнале принятой записи @@ -265,15 +262,15 @@ Telegram, дописывает его сюда. Остановленная запись MUST отдавать рубеж, на котором она остановлена, и MUST нести признак остановки отдельным полем `halted` со значением истины. Машинный текст отказа MUST в ответ не попадать: он принадлежит журналу владельца сервиса, -а не отправителю. Отправитель узнаёт о неудаче ответом там, откуда пришла -запись, — это нормирует capability `pipeline`. +а не отправителю. Этот адрес — **единственное** место, где отправитель узнаёт о +неудаче: доставки ответа отправителю у сервиса больше нет, и признак остановки +здесь несёт всю обязанность целиком. Отказ без сессии MUST не зависеть от того, есть такая запись или нет: иначе по кодам ответа перебирается список заведённых записей. Запись, принадлежащая другому, MUST отвечать тем же, чем отвечает неизвестный -идентификатор, — кодом `404` и тем же телом. То же MUST относиться к записи без -владельца: запись, принятая ботом, по этому адресу не достаётся никому. +идентификатор, — кодом `404` и тем же телом. #### Scenario: Запись найдена @@ -325,117 +322,25 @@ Telegram, дописывает его сюда. ### Requirement: Поднятые входы видны наблюдателю Сервис SHALL отдавать признак поднятости по каждому входу приёма отдельной -метрикой. Признак MUST выставляться при сборке входа и MUST различать поднятый -вход и неподнятый. +метрикой и MUST выставлять метку только тому входу, который у сервиса есть. +Метки убранного входа в метриках MUST не быть вовсе: признак со значением нуля +читался бы как «вход есть, но не поднялся», то есть как поломка, а вечная +единица рядом с ним — как исправность того, чего нет. -Требование стоит на том, что иначе потерянный вход не виден ничем: проба -здоровья отвечает «сервис работает» и при неподнятом боте, а запись журнала -живёт до ротации и вопрос «работает ли вход сейчас» не отвечает. Метрика — -единственный канал наблюдения, который у владельца автоматизирован. +Проверяемое здесь одно — **набор меток**, и это честнее прежнего. Вход остался +один, страница метрик отдаётся тем же сервером, что и приём, и значение нуля у +единственной метки недостижимо: чтобы прочитать признак, надо дотянуться до +входа, о котором он сообщает. Прежнее обоснование — «иначе потерянный вход не +виден ничем» — было верно, пока входов было два; сегодня неподнятый вход виден +неудачей чтения самих метрик. -#### Scenario: Вход Telegram не поднят +Различать поднятый и неподнятый вход признак MUST снова, как только входов у +сервиса станет больше одного. -- **GIVEN** сервис поднялся без Telegram +#### Scenario: В метриках только оставшийся вход + +- **GIVEN** сервис поднялся - **WHEN** наблюдатель читает метрики -- **THEN** признак поднятости входа Telegram равен нулю -- **AND** признак поднятости входа HTTP равен единице - -### Requirement: Признак включения решает, поднимается ли вход Telegram - -Намерение владельца SHALL объявляться отдельным признаком включения входа -Telegram, а ключ доступа MUST означать только доступ. При выключенном входе -сервис MUST подниматься без Telegram и MUST не смотреть на ключ доступа вовсе. -При включённом входе пустой ключ MUST быть отказом старта: сообщение называет имя -незаполненного ключа и MUST не нести его значения. - -Признак включения MUST быть в настройках задан. Умолчания у него нет: файл, где -признака нет вовсе, негоден, и сервис MUST выходить с ошибкой настройки, назвав -недостающий ключ. Умолчание здесь было бы угаданным намерением, а признак заведён -затем, чтобы намерение объявляли: любое умолчание делает одну из двух ошибок -тихой — либо бот молча пропадает, либо файл без признака молча работает. - -Выключенный вход MUST быть назван в журнале **ровно одной** записью уровня `INFO` -при старте. Это выбор владельца, а не отклонение, и предупреждать о нём не о чем; -предупреждение остаётся за тем, чего владелец не выбирал. - -При включённом входе сервис SHALL подниматься, когда вход поднять не удалось, и -MUST продолжать работу оставшимся входом: приём по HTTP, опрос готовности и -конвейер расшифровки работают в полном объёме. Неподнятый вход MUST быть назван в -журнале **ровно одной** записью уровня `WARN` при старте — с причиной и без -значения ключа. - -Исключение одно, и оно проходит по тому, **ответил ли Telegram**. Ответ «такого -бота нет» — ошибка настройки: бот по этому ключу не появится ни от ожидания, ни -от повтора, и старт MUST кончаться отказом. Сервис, молча потерявший бота после -опечатки в ключе, перестаёт отвечать своим отправителям, и узнать об этом было бы -неоткуда. - -Всё прочее — недоступность: сеть, DNS, авария Bot API, истёкший срок ожидания. -Она MUST не влиять на подъём. Основной вход сервиса — не Telegram, и ронять его -целиком из-за чужой аварии нельзя: перезапуск в такую минуту оставил бы без -работы и приём по HTTP, и панель, и конвейер, которому Telegram не нужен вовсе. - -Ожидание при сборке MUST быть ограничено сроком. Без него недоступность -неотличима от подъёма: обращение к Telegram стоит на пути старта, и молчащий -собеседник останавливал бы его бессрочно — без записи, без порта и без пробы -здоровья. - -Требование нормирует **наличие входа**, а не приём из него. - -#### Scenario: Вход выключен - -- **GIVEN** в настройках сервиса вход Telegram выключен -- **WHEN** сервис запускается -- **THEN** он поднимается и принимает записи по HTTP -- **AND** конвейер расшифровки работает -- **AND** бот не заведён, а в журнале ровно одна запись уровня `INFO` о том, что - вход выключен настройкой - -#### Scenario: Вход выключен, а ключ доступа задан - -- **GIVEN** в настройках сервиса вход Telegram выключен -- **AND** ключ доступа при этом заполнен -- **WHEN** сервис запускается -- **THEN** он поднимается без Telegram, и бот не заводится -- **AND** к Telegram не уходит ни одного обращения - -#### Scenario: Вход включён, а ключа доступа нет - -- **GIVEN** в настройках сервиса вход Telegram включён -- **AND** ключ доступа пуст -- **WHEN** сервис запускается -- **THEN** старт кончается отказом -- **AND** сообщение об отказе называет имя незаполненного ключа - -#### Scenario: Признака включения в настройках нет - -- **GIVEN** в настройках сервиса нет признака включения входа Telegram -- **AND** ключ доступа заполнен и Telegram признаёт по нему бота -- **WHEN** сервис запускается -- **THEN** старт кончается отказом настройки -- **AND** сообщение об отказе называет недостающий ключ - -#### Scenario: Вход включён и ключ годен - -- **GIVEN** в настройках сервиса вход Telegram включён -- **AND** стоит ключ, по которому Telegram признаёт бота -- **WHEN** сервис запускается -- **THEN** он поднимается и работает обоими входами - -#### Scenario: Telegram не отвечает - -- **GIVEN** в настройках сервиса вход Telegram включён и ключ непуст -- **AND** Telegram недоступен либо не отвечает дольше отведённого срока -- **WHEN** сервис запускается -- **THEN** он поднимается и принимает записи по HTTP -- **AND** бот не заведён, а в журнале запись уровня `WARN` с причиной -- **AND** запись не несёт значения ключа - -#### Scenario: Telegram ответил, что такого бота нет - -- **GIVEN** в настройках сервиса вход Telegram включён и ключ непуст -- **AND** Telegram отвечает отказом на этот ключ -- **WHEN** сервис запускается -- **THEN** старт кончается отказом -- **AND** ни журнал, ни текст отказа не несут значения ключа +- **THEN** признак поднятости несёт метку входа HTTP со значением единицы +- **AND** метки убранного входа Telegram в метриках нет вовсе diff --git a/openspec/specs/pipeline/spec.md b/openspec/specs/pipeline/spec.md index 743bd23..15a0499 100644 --- a/openspec/specs/pipeline/spec.md +++ b/openspec/specs/pipeline/spec.md @@ -3,14 +3,14 @@ ## Purpose Конвейер расшифровки: как аудиозапись движется по рубежам, что делает воркер, -когда работы нет, что считается отказом шага и что бывает с ответом отправителю, -когда доставить его некуда. +когда работы нет, и что считается отказом шага. Описаны цепочка рубежей и смысл рубежа, остановка признаком и её причины, оба сторожа — число отказов и время в рубеже, — откладывание работы отдельно от перехода, неделимость захвата и срок его протухания, условие записи результата держателем захвата, нарастающая пауза перед повтором, число воркеров настройкой, -журнал событий записи и недоставка ответа при неподнятом входе. +журнал событий записи и молчание конвейера наружу: обращений к отправителю он не +делает вовсе, и свой исход тот узнаёт опросом готовности. Сознательно не описаны: освобождение ресурсов внешних клиентов и **какие отказы считаются приговором записи, а какие поводом к повтору**. Второе — не пробел @@ -101,7 +101,7 @@ захват, перевыданный другому — по протуханию срока или после того, как человек снял признак остановки в панели, — обязан обращать запись первого в отказ. Условие, проверяющее лишь непустоту признака или срок, пропустило бы обоих, и -два шага записали бы в одну запись и оба ответили бы отправителю. +два шага записали бы в одну запись по очереди, испортив её результат. Одна и та же запись MUST доставаться ровно одному захватившему. Двум вызывающим, пришедшим за работой одновременно, запись MUST достаться одному, а второй MUST @@ -155,14 +155,18 @@ всё ещё принадлежит ему. Запись MUST быть условна по **признаку этого захвата** — значению, уникальному для каждого захвата, — а не по занятости записи вообще. Шаг, чей захват за время работы достался другому, MUST завершиться без записи -результата и без ответа отправителю. +результата. Требование закрывает то, чего неделимость захвата не закрывает: захват протухает не только у мёртвого воркера, но и у живого — шаг, идущий дольше своего срока, теряет запись, продолжая работать. Снять захват может и человек, вернувший остановленную запись в работу. Без условия по уникальному признаку два воркера -пишут в одну запись по очереди, счётчик отказов сбрасывает тот, кто уже не -владелец, а отправитель получает два ответа на одну запись. +пишут в одну запись по очереди, а счётчик отказов сбрасывает тот, кто уже не +владелец. + +Довод про два ответа отправителю из требования ушёл вместе с доставкой: обращений +наружу шаг не делает. Требование от этого не ослабло — порча записи двумя +пишущими остаётся его предметом целиком. Шаг MUST записывать только те поля, которыми распоряжается сам. Запись он держит снимком с момента захвата и до записи — это часы, — и безусловная запись снимка @@ -185,7 +189,6 @@ - **AND** за это время та же запись досталась другому захвату - **WHEN** первый шаг доходит до записи результата - **THEN** результат не записывается -- **AND** отправителю ничего не отправляется #### Scenario: Человек снял остановку под работающим шагом @@ -263,89 +266,18 @@ MUST расти с числом её отказов до объявленног - **THEN** задержка до следующей проверки каждый раз одна и та же - **AND** число отказов записи не растёт -### Requirement: Недоставленный ответ не роняет шаг - -Шаг конвейера SHALL доводить запись до достигнутого рубежа, когда ответ -отправителю доставить не удалось, и MUST не считать недоставку отказом шага. -Недоставка MUST быть записана в журнал владельца, MUST нести идентификатор -записи, MUST называть причину и MUST считаться отдельной метрикой с причиной -меткой. - -Причин у недоставки две, и исход у них общий: **вход отправителя не поднят** — -запись заведена прошлым запуском, а сервис поднялся без этого входа; и **адресат -у записи не назван** — источником значится Telegram, а чата в записи нет. - -Уровень записи MUST различать эти причины. Неподнятый вход — объявленный режим, -и его уровень «может стать проблемой». Неназванный адресат — симптом порчи -записи: у записи из Telegram чат есть всегда, и пропасть он может только от -дефекта, самый коварный источник которого назван инвариантом проекта про колонки -очереди. Один уровень на обе причины утопил бы этот сигнал в потоке штатных -записей о ненастроенном боте. - -Общий исход — не упрощение, а следствие момента: ответ уходит **после** того, как -достигнутый рубеж сохранён. Работа к этой минуте сделана, и объявленный отказ -засчитался бы воркеру сбоем и лёг бы владельцу записью отказа — то есть соврал бы -про исход дважды. Повтор делу не помогает: ни бот, ни адресат от ожидания не -появятся. Поэтому запись остаётся на достигнутом рубеже, в повтор не уходит и -**признака остановки не получает**, а причина недоставки живёт в записи журнала, -а не в рубеже записи. - -То же MUST относиться к недоставке сообщения об **остановке**: остановка уже -сохранена, и недоставка её MUST не отменять. - -Идентификатор записи в этой строке обязателен: без него владелец видит, что -ответ не ушёл, но не может найти, чей. Текст расшифровки и сообщение отправителя -в эту запись MUST не попадать — приватность содержимого записи требование не -ослабляет. - -Отложенной доставки это требование не заводит: ответ, не ушедший сегодня, не -уходит и потом. Забрать расшифровку можно там же, где лежат остальные. - -#### Scenario: Вход отправителя не поднят - -- **GIVEN** запись принята входом Telegram прошлым запуском сервиса -- **AND** сервис поднялся без этого входа -- **WHEN** шаг конвейера доходит до ответа отправителю -- **THEN** шаг завершается без отказа, и воркер не считает прогон сбоем -- **AND** запись остаётся на достигнутом рубеже, в повтор не уходит и признака - остановки не получает -- **AND** в журнале есть запись уровня `WARN` о недоставке с идентификатором - записи и причиной -- **AND** счётчик недоставленных ответов вырос с этой причиной меткой -- **AND** ни текста расшифровки, ни сообщения отправителя в этой записи нет - -#### Scenario: Адресат у записи не назван - -- **GIVEN** у записи источником значится Telegram, а чат не назван -- **WHEN** шаг конвейера доходит до ответа отправителю -- **THEN** шаг завершается без отказа, и воркер не считает прогон сбоем -- **AND** запись остаётся на достигнутом рубеже -- **AND** в журнале есть запись уровня `ERROR` о недоставке с идентификатором - записи и причиной: неназванный адресат — симптом порчи записи - -#### Scenario: Не доехало сообщение об остановке - -- **GIVEN** запись остановлена признаком -- **AND** вход отправителя не поднят -- **WHEN** шаг доходит до ответа отправителю -- **THEN** признак остановки у записи остаётся -- **AND** в журнале есть запись о недоставке с идентификатором записи и причиной - -#### Scenario: Отвечать некуда, потому что запись пришла не из Telegram - -- **GIVEN** запись принята по HTTP -- **WHEN** шаг конвейера доходит до ответа отправителю -- **THEN** шаг завершается без отказа и без записи о недоставке - ### Requirement: Выборка воркера владельцем не сужается Воркер SHALL брать записи всех владельцев подряд и MUST не учитывать владельца -при выборе очередной записи. Запись без владельца — принятая ботом — MUST -обрабатываться наравне с прочими. +при выборе очередной записи. Владелец решает, кому запись показывать, а не кому её считать. Сужение выборки -владельцем остановило бы расшифровку записей бота вовсе, а записи остальных -поставило бы в зависимость от того, кто первым завёл учётную запись. +владельцем поставило бы записи одних людей в зависимость от того, кто первым +завёл учётную запись. + +Оговорка про записи без владельца из требования ушла: заводить их стало нечем — +колонка владельца пустого значения не принимает, и норму держит capability +`storage`. Владелец записи MUST переживать работу конвейера: шаг, сохраняющий свой результат, владельца не трогает и не затирает. @@ -356,12 +288,6 @@ MUST расти с числом её отказов до объявленног - **WHEN** воркер забирает работу - **THEN** ему достаются обе, в порядке заведения -#### Scenario: Запись без владельца обрабатывается - -- **GIVEN** заведена запись, принятая ботом, — без владельца -- **WHEN** воркер забирает работу -- **THEN** она достаётся ему наравне с прочими - #### Scenario: Шаг конвейера владельца не затирает - **GIVEN** запись с владельцем прошла шаг конвейера @@ -463,38 +389,6 @@ MUST расти с числом её отказов до объявленног - **THEN** число отказов, пауза и время входа в рубеж сброшены - **AND** ближайший захват выдаёт запись, а не останавливает её снова -### Requirement: Всякая остановка сообщает отправителю - -Остановка записи по любой причине SHALL сообщать отправителю о неудаче ровно -так же, как сообщает о ней отказ шага, и MUST быть видна владельцу сервиса -записью в журнале. - -Требование стоит на инварианте проекта «Принятая запись не теряется молча»: -инвариант допускает два исхода — запись пригодна к повтору либо об отказе -сказано, — а остановленная запись захвату не выдаётся, значит первый исход -исключён. - -Причин остановки больше одной, и обязанность общая для всех: исчерпанные -отказы, застревание в рубеже, приговор шага. Обязанность, записанная у одной -причины, у остальных читалась бы как снятая. - -Ответ уходит **после** того, как признак остановки сохранён, и недоставка этого -ответа MUST не отменять остановку: её нормирует требование «Недоставленный ответ -не роняет шаг». - -#### Scenario: Остановка по отказам сообщает отправителю - -- **GIVEN** запись остановлена по исчерпании отказов -- **WHEN** шаг доходит до ответа отправителю -- **THEN** отправитель получает сообщение о неудаче - -#### Scenario: Остановка по времени сообщает отправителю - -- **GIVEN** запись остановлена по пределу времени в рубеже -- **WHEN** шаг доходит до ответа отправителю -- **THEN** отправитель получает сообщение о неудаче -- **AND** в журнале владельца есть запись об остановке с причиной - ### Requirement: Время в рубеже ограничено У аудиозаписи SHALL быть время входа в рубеж, и оно MUST ставиться только при @@ -674,8 +568,8 @@ MUST не быть привязаны к отдельному шагу: кажд Запись, захваченная с числом отказов сверх заданного предела, MUST останавливаться признаком тем, кто её захватил, и MUST не отдаваться шагу в -работу. Об этой остановке отправителю сообщается наравне с прочими — норму -держит требование «Всякая остановка сообщает отправителю». +работу. Остановка эта видна отправителю опросом готовности наравне с прочими — +норму держит capability `intake`. Этот сторож MUST отвечать только за повторы внутри шага. Время, проведённое записью в рубеже, MUST мериться отдельным сторожем: одно число не справляется ни @@ -689,7 +583,7 @@ MUST не быть привязаны к отдельному шагу: кажд - **WHEN** запись проходит заданное число отказов - **THEN** у неё появляется признак остановки - **AND** следующий захват её не выдаёт -- **AND** отправитель получает сообщение о неудаче +- **AND** опрос готовности отдаёт владельцу записи признак остановки #### Scenario: Шаг уносит процесс, не объявив отказа @@ -710,3 +604,39 @@ MUST не быть привязаны к отдельному шагу: кажд - **WHEN** смотрят её число отказов - **THEN** оно не приблизилось к пределу +### Requirement: Конвейер ответа отправителю не шлёт + +Шаг конвейера SHALL доводить запись до достигнутого рубежа и MUST не обращаться +к отправителю вовсе — ни с готовым текстом, ни с сообщением о неудаче. Исход +своей записи отправитель узнаёт опросом готовности и в панели владельца; адрес +опроса и содержимое ответа нормирует capability `intake`. + +Требование заведено взамен доставки в чат, убранной вместе с входом Telegram. +Без него молчание конвейера читалось бы как недоделка: прежде ответ уходил, и +всякий, кто помнит это, ищет в шаге отправку, а её отсутствие принимает за +потерянную ветку. + +Инвариант проекта «Принятая запись не теряется молча» держится теперь опросом +готовности — там остановка видна признаком — и журналом владельца, где у неё +стоит причина. Обязанность при этом сменила направление: прежде об отказе +сообщали, теперь отказ доступен спросившему. Отправитель, который не +спрашивает, об остановке не узнаёт. + +Записи, которой этот канал недоступен, не бывает: у каждой записи есть владелец, +и опрос отдаёт ему её исход. Держится это обязательностью владельца в схеме +хранилища — норму держит capability `storage`. + +#### Scenario: Готовый текст отправителю не уходит + +- **GIVEN** запись дошла до конечного рубежа +- **WHEN** шаг конвейера её завершает +- **THEN** ни одного обращения наружу с текстом расшифровки не уходит +- **AND** текст достаётся опросом готовности + +#### Scenario: Остановка видна опросом, а не сообщением + +- **GIVEN** запись остановлена по исчерпании отказов +- **WHEN** владелец записи спрашивает её рубеж +- **THEN** ответ несёт достигнутый рубеж и признак остановки +- **AND** в журнале владельца сервиса есть запись об остановке с причиной + diff --git a/openspec/specs/storage/spec.md b/openspec/specs/storage/spec.md index 6dddad5..a69369b 100644 --- a/openspec/specs/storage/spec.md +++ b/openspec/specs/storage/spec.md @@ -17,8 +17,8 @@ ### Requirement: Сервис поднимается на чистом каталоге данных Сервис SHALL приводить хранилище в рабочий вид сам: на пустом каталоге данных он -MUST завести свою схему и принимать записи обоими входами без единого ручного -шага до первого запуска. +MUST завести свою схему и принимать записи своим входом — приёмом по HTTP — без +единого ручного шага до первого запуска. Прежние данные не переносятся. Каталог, оставшийся от прежней раскладки, MUST не читаться и не считаться источником: сервис начинает с чистого листа, и это @@ -267,14 +267,14 @@ MUST завести свою схему и принимать записи об печатается оно в журнал контейнера, откуда строку не убрать: бессрочное отдало бы панель всякому читателю логов навсегда. -Пока владелец пароля не задал, сервис MUST работать обоими входами: панель без -владельца не мешает принимать записи. +Пока владелец пароля не задал, сервис MUST принимать записи: панель без владельца +приёму не мешает. #### Scenario: Владелец пароля ещё не задал - **GIVEN** каталог данных пуст и владелец панели не заведён - **WHEN** сервис запускается -- **THEN** он принимает записи обоими входами +- **THEN** он принимает записи - **AND** ни один ключ конфигурации не несёт пароля от панели #### Scenario: Владелец заведён, приглашение больше не печатается @@ -289,10 +289,13 @@ MUST завести свою схему и принимать записи об учётной записью, — и эта колонка MUST не иметь умолчания: запись, чей владелец не назван, не достаётся никому по недосмотру схемы. -Колонка MUST допускать пустое значение, и это решение с названной ценой: записи, -принятые ботом, владельца не имеют, потому что связи чата Telegram с учётной -записью сервис не ведёт. Обязательность для приёма по HTTP держит сама -capability `intake`, а не схема. +Колонка MUST не допускать пустого значения. Прежде допускала, и цену платили за +записи, принятые ботом: связи чата с учётной записью сервис не вёл. С убранным +входом заводить ничью запись стало некому, и обязательность переезжает из одного +лишь приёма в схему — туда, где её держит хранилище, а не договорённость. Разница +не косметическая: пока обязательность жила в приёме, ничью запись заводили руками +в панели, и она уходила в конвейер, стоила денег на распознавание и не доставалась +потом никому. Владелец MUST не назначаться и не меняться конвейером. @@ -301,6 +304,14 @@ capability `intake`, а не схема. - **WHEN** сервис поднимается на чистом каталоге данных - **THEN** у аудиозаписи есть колонка владельца - **AND** умолчания у неё нет +- **AND** пустого значения она не принимает + +#### Scenario: Запись без владельца не сохраняется + +- **GIVEN** сервис поднят +- **WHEN** аудиозапись пытаются сохранить с пустым владельцем — приёмом, + конвейером или руками в панели +- **THEN** хранилище её не сохраняет #### Scenario: Конвейер владельца не назначает @@ -313,9 +324,16 @@ capability `intake`, а не схема. Хранилище SHALL держать владельца и у файла записи — той же связью с учётной записью, — и правило просмотра файлов MUST пускать к файлу только его владельца. -Владелец файла MUST назначаться там же, где владелец записи, — при приёме, из -предъявленной сессии, — и MUST оставаться пустым у файлов, заведённых конвейером -для записи без владельца. +Владелец файла MUST назначаться при приёме, из предъявленной сессии, а колонка +файла MUST не допускать пустого значения наравне с колонкой записи. Прежде пустое +значение оставалось у файлов, заведённых конвейером для записи без владельца; +таких записей больше не заводится, и разное правило у записи и у её файла +читалось бы как недосмотр. + +Файл, заведённый шагом конвейера, — приведённую копию заводит именно он — +MUST получать владельца своей записи. Иного источника владельца у файла нет, и +шаг, оставивший его пустым, упрётся в отказ сохранения: запись накопит отказы и +остановится признаком на первом же приведении. Ссылки на файлы у записи две — на принятую копию и на приведённую, — и обе живут до конца, но владелец файла MUST по-прежнему лежать своей колонкой, а не @@ -340,12 +358,18 @@ capability `intake`, а не схема. - **WHEN** он идёт по ссылке на файл своей записи со своим токеном - **THEN** содержимое отдаётся -#### Scenario: Файл записи из Telegram не отдаётся по API +#### Scenario: Файл без владельца не сохраняется -- **GIVEN** запись принята ботом, и владельца у неё нет -- **WHEN** вошедший человек идёт по ссылке на её файл со своим токеном -- **THEN** содержимого он не получает +- **GIVEN** сервис поднят +- **WHEN** файл записи пытаются сохранить с пустым владельцем +- **THEN** хранилище его не сохраняет +#### Scenario: Приведённая копия получает владельца записи + +- **GIVEN** запись с владельцем дошла до приведения +- **WHEN** шаг заводит приведённую копию файла +- **THEN** владельцем копии стоит владелец записи +- **AND** шаг завершается без отказа ### Requirement: Учётная запись с записями не удаляется Хранилище SHALL отвергать удаление учётной записи, у которой остались @@ -551,3 +575,31 @@ MUST быть помечено защищённым. - **WHEN** записи назначают шестую тему - **THEN** назначение не проходит +### Requirement: Пустой результат не кладётся поверх сохранённого + +Хранилище SHALL не заменять сохранённое содержимое приложения записи — текст и +структуру реплик — пустым. Замена пустым MUST оставлять прежнее значение и +считаться сделанной работой, а не отказом. + +Требование стоит на повторном опросе одной и той же операции распознавания. +Повтор — обычное дело: держатель захвата умер, сохранение рубежа отказало, +человек снял признак остановки в панели. Провайдер при этом вправе ответить +пустым потоком, отказом это не считается, и безусловная замена стирала бы +расшифровку живого человека — без следа и без возврата, потому что сервис +объявлен архивом и удаления по требованию не знает. + +Та же защита MUST стоять у сырого ответа провайдера: разное правило у двух +хранителей одного результата читается как недосмотр, и один из них молча теряет +то, ради чего второй заведён. + +Норма записана со стороны **хранилища**, а не шага: шагов, кладущих текст, +больше одного, и правило, записанное у одного из них, у остальных читалось бы +как снятое. + +#### Scenario: Пустой второй ответ не стирает расшифровку + +- **GIVEN** расшифровка записи сохранена +- **WHEN** ту же операцию опрашивают снова, и провайдер отвечает пустым +- **THEN** сохранённая расшифровка остаётся прежней +- **AND** шаг завершается без отказа + diff --git a/telegram_build.go b/telegram_build.go deleted file mode 100644 index 60c1d81..0000000 --- a/telegram_build.go +++ /dev/null @@ -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 - } -} diff --git a/telegram_build_test.go b/telegram_build_test.go deleted file mode 100644 index 9f3e867..0000000 --- a/telegram_build_test.go +++ /dev/null @@ -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, "это ошибка настройки, а не режим") -}