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

- убраны клиент бота, транспорт обновлений, отправитель сообщений, сборка
  входа при старте, секция настроек и зависимость go-telegram-bot-api; из
  конвейера ушла доставка ответа отправителю — исход виден опросом готовности.
  Колонки адресата и значение источника остались в схеме: применённые шаги не
  переписываются
- шаг 202608140003 запрещает пустого владельца у аудиозаписи и у файла;
  существующие строки он не проверяет, и это принято сознательно — искать их
  надо запросом до выкладки
- ревью нашло два пред-существующих дефекта, оба закрыты: пустой второй ответ
  распознавателя стирал сохранённую расшифровку, а пустая расшифровка перестала
  быть заметной вместе с убранной доставкой. Попутно поднят golang.org/x/image
  до v0.45.0 — красный шаг vulns, воспроизводился и на чистом master
This commit is contained in:
av
2026-08-15 07:24:35 +03:00
parent 97ceb7bb69
commit 8f7c3a057a
74 changed files with 2453 additions and 2733 deletions
-2
View File
@@ -154,8 +154,6 @@ linters:
- (*os.File).Close
- (io.ReadCloser).Close
- os.Remove
# Метод сам логирует ошибку отправки, вызывающему она не нужна
- (*git.vakhrushev.me/av/transcriber/internal/controller/tg.TelegramController).send
exclusions:
rules:
+35 -30
View File
@@ -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», «пять
прогонов ревью», «две типизированные ошибки» расходятся с действительностью на
первой же задаче, которая прибавит четвёртую, — и расходятся молча: машина
+6 -11
View File
@@ -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). Панель владельца — по адресу `/_/` того же
порта; пароль от неё задаёт сам владелец по приглашению, которое сервис печатает
-38
View File
@@ -85,41 +85,3 @@ redirect_url = "https://transcriber.example.com/auth/callback"
# Признак `Secure` у куки сессии. Умолчание true; false только для локального
# запуска по http://localhost, где браузер такую куку не сохранит
secure_cookie = true
# Telegram Bot Configuration
[telegram]
# Нужен ли сервису вход Telegram. Ключ **обязателен**: умолчания у него нет, и
# файл без него негоден — сервис выходит с ошибкой настройки, назвав недостающий
# ключ. Умолчание было бы угаданным намерением, а признак заведён затем, чтобы
# намерение объявляли: любое умолчание делает одну из двух ошибок тихой — либо
# бот молча пропадает, либо файл без признака молча работает.
#
# false — сервис поднимается без Telegram и работает одним входом, по HTTP. Бот
# не заводится, к Telegram не уходит ни одного обращения, записи из Telegram не
# принимаются, а ответы на задачи, принятые оттуда прежде, не уходят —
# недоставка видна записью журнала, расшифровка достаётся из панели и по HTTP.
# О выключенном входе сервис говорит одной записью журнала «к сведению»: это
# выбор владельца, а не отклонение.
#
# true — сервис поднимает бота. Пустой bot_token при этом роняет старт: бота по
# пустому ключу не существует. Старт роняет и ответ Telegram «такого бота нет» —
# это опечатка в ключе, ждать тут нечего. А вот недоступность Telegram (сеть,
# DNS, авария Bot API) подъёму не мешает: сервис встаёт без бота и предупреждает
# записью журнала, потому что основной вход у него другой.
#
# Локальный прогон идёт с false — боевым токеном запускаться запрещено: второй
# процесс с тем же токеном перехватывает обновления у работающего. Выключенного
# входа для подъёма мало: секции [auth] и [yandex] проверяются на старте и
# роняют процесс на пустых ключах. Наружу при старте не ходит ни одна из них,
# поэтому для локального прогона годятся выдуманные непустые значения — адреса
# [auth] должны лишь разбираться как ссылки. Расшифровка при выдуманных ключах
# не работает: её подменяют в коде.
enabled = false
# Токен Telegram бота (получить у @BotFather в Telegram). Только ключ доступа:
# включением входа он больше не заведует, этим занят enabled выше. При
# enabled = false не читается вовсе.
bot_token = ""
# Таймаут обновлений Telegram бота (в секундах)
update_timeout = 10
@@ -2,6 +2,7 @@
- **Дата:** 2026-08-13
- **Источник:** openspec/changes/archive/2026-08-13-telegram-enabled-flag/design.md
- **Статус:** устарело — вход Telegram убран решением [ADR-2026-08-15-telegram-intake-removed-temporarily](ADR-2026-08-15-telegram-intake-removed-temporarily.md); довод устоял и понадобится возврату входа
## Решение
@@ -2,6 +2,7 @@
- **Дата:** 2026-08-13
- **Источник:** openspec/changes/archive/2026-08-13-start-without-telegram-token/design.md
- **Статус:** устарело — вход Telegram убран решением [ADR-2026-08-15-telegram-intake-removed-temporarily](ADR-2026-08-15-telegram-intake-removed-temporarily.md); довод устоял и понадобится возврату входа
## Решение
@@ -0,0 +1,59 @@
# Обязательность владельца держит схема, а не приём
- **Дата:** 2026-08-15
- **Источник:** openspec/changes/archive/2026-08-15-remove-telegram-intake/design.md,
разделы «Схема теряет только необязательность владельца» и «Владелец записи
перестаёт быть необязательным и в модели»
## Решение
Колонка владельца у аудиозаписи и у файла перестала принимать пустое значение —
шагом схемы `202608140003`. Ничья запись не заводится ничем: ни приёмом, ни
конвейером, ни рукой в панели. Поле владельца в модели стало обычной строкой
вместо ссылки, которой позволено отсутствовать.
Существующие строки шаг **не проверяет**, и это принято сознательно: искать ничьи
строки надо запросом до выкладки.
## Почему
Цитата источника:
> **Держать обязательность одним приёмом, схему не трогать.** Так было задумано
> сперва, и это оставляло дыру: ничью запись заводили руками в панели, она
> уходила в конвейер, стоила денег на распознавание и не доставалась потом
> никому. Решение владельца от 2026-08-14 — обязательность держит схема.
Прежнее решение было обратным и записано спекой `storage`: «Колонка MUST
допускать пустое значение… Обязательность для приёма по HTTP держит сама
capability `intake`, а не схема». Цену за него платили записи входа Telegram — у
них владельца не было по построению. Вход убран
([ADR-2026-08-15-telegram-intake-removed-temporarily](ADR-2026-08-15-telegram-intake-removed-temporarily.md)),
исключение исчезло вместе с ним, и владелец сервиса подтвердил, что записей без
владельца в боевой базе нет.
Про непроверку существующих строк цитата источника:
> **Проверяется это запросом, а не прогоном шага**, и разница выяснилась ревью с
> оракулом: хранилище держит обязательность связи проверкой записи при
> сохранении, а не ограничением таблицы. Смена признака на базе с ничьей записью
> проходит зелёным и такую запись оставляет… Заставить шаг считать строки самому
> владелец решил не делать: безопасность держится ручной проверкой, и она названа
> первым шагом плана перехода.
Правило «пустой владелец не совпадает ни с одной записью» при этом осталось и
избыточным не стало: схема запрещает **заводить** ничью запись, а правило —
**спрашивать** ничьим именем.
## Последствия
- `+` значения «владельца нет» не существует ни на одном уровне: ни в схеме, ни в
модели, ни в отборе.
- `+` дыра «ничью запись заводят руками в панели» закрыта тем же механизмом, что
и приём, — одним, а не двумя.
- `` откат шага возвращает необязательность, но операционно недостижим: команд
библиотеки сервис не подключает, и это верно для всех шагов схемы проекта.
- `` ничья запись, если её проглядят перед выкладкой, становится незакрываемой:
захват выдаёт её воркеру, а всякое сохранение — включая то, которым ставится
признак остановки, — отказывает. Следа не остаётся ни в метрике, ни в журнале
событий, только строка в логе контейнера.
@@ -0,0 +1,40 @@
# Метка убранного входа не выставляется вовсе, а не обнуляется
- **Дата:** 2026-08-15
- **Источник:** openspec/changes/archive/2026-08-15-remove-telegram-intake/design.md,
раздел «Метка убранного входа не выставляется вовсе»
## Решение
Признак поднятого входа остался, а метки убранного входа в метриках нет вовсе —
ни со значением единицы, ни со значением нуля. Ряд `transcriber_intake_up` с
меткой `telegram` не появляется после выкладки.
## Почему
Цитата источника:
> Признак поднятого входа остаётся, метка `telegram` у него больше не появляется.
> Ноль вместо неё читается как «вход есть, но не поднялся», то есть как поломка;
> владелец, у которого на этот признак стоит отбор, увидел бы аварию на ровном
> месте.
Отвергнут очевидный подход — оставить ряд со значением нуля. Он выглядит
бережнее (отбор не ломается), но говорит неправду: значение нуля у этого признака
означает именно неподнятый вход, а не отсутствующий.
С единственным оставшимся входом проверяемым осталось только **множество меток**:
значение нуля у него недостижимо, потому что страница метрик отдаётся тем же
сервером, что и приём, — чтобы прочитать признак, надо дотянуться до входа, о
котором он сообщает. Различать поднятый и неподнятый вход признак станет снова,
когда входов у сервиса станет больше одного.
## Последствия
- `+` наблюдатель не видит вечного нуля, который читался бы как незакрытая
авария.
- `` отбор вида `transcriber_intake_up == 0` по убранному входу перестаёт
срабатывать молча: исчезновение ряда ловится `absent()`, а не сравнением.
Владельцу, если такой отбор был заведён, править его руками.
- `` требование «различать поднятый и неподнятый» стало непроверяемым до
возвращения второго входа, и это сказано в самом требовании прямо.
@@ -0,0 +1,63 @@
# Вход Telegram убран целиком, а не выключен признаком
- **Дата:** 2026-08-15
- **Источник:** openspec/changes/archive/2026-08-15-remove-telegram-intake/design.md,
разделы «Context» и «Формы решения, между которыми выбирали»
## Решение
Вход Telegram убран из сервиса целиком: клиент, транспорт обновлений, отправитель
сообщений, сборка входа при старте, список допущенных людей, секция настроек и
зависимость. Убран **временно** — возврат заводится новым изменением вместе со
связью чата с учётной записью.
Хранилище при этом не тронуто: колонки `tg_chat_id`, `tg_reply_message_id` и
значение `telegram` перечня источников остаются в схеме вместе с записями,
которые их заполнили.
## Почему
Цитата источника:
> Сервис принимает записи двумя входами, и входы расходятся в главном: у записи,
> пришедшей из приложения, есть владелец, а у записи, пришедшей от бота, владельца
> нет и быть не может — связи чата с учётной записью сервис не ведёт. Пока такие
> записи заводятся, правило «каждая запись принадлежит человеку» действует
> наполовину.
Отвергнуты две формы решения, обе с названной ценой:
> **Выключить вход признаком, код оставить.** Признак `telegram.enabled` заведён
> 2026-08-13 и обязателен, а приём по HTTP владельца уже требует: одна правка
> ключа в боевом файле даёт «новых записей без владельца не заводится» ценой ноля
> строк кода и мгновенным возвратом. Отвергнуто по причине из раздела «Why»:
> двойная модель остаётся в коде, и оговорку про бота продолжает платить каждая
> следующая задача.
>
> **Сузить бота до исходящего канала.** Приём убрать, отправку оставить с одним
> адресатом — чатом владельца строкой настроек. Отвергнуто потому, что заводит
> понятие «канал уведомления владельца», которое тут же переделает задача
> `ntfy-delivery`.
Решениями, которые это изменение отменяет, были
[ADR-2026-08-13-telegram-intent-declared-not-inferred](ADR-2026-08-13-telegram-intent-declared-not-inferred.md)
и
[ADR-2026-08-13-telegram-outage-does-not-block-startup](ADR-2026-08-13-telegram-outage-does-not-block-startup.md):
оба нормировали подъём входа, которого больше нет. Доводы их при этом устояли и
понадобятся возврату — оба продолжают отвечать на вопрос «что делать с входом,
чей внешний собеседник недоступен».
## Последствия
- `+` модель одна: оговорка про запись без владельца ушла из спек приёма,
доступа, конвейера и хранилища.
- `+` зависимость `go-telegram-bot-api` ушла из манифеста вместе с двумя путями
утечки токена, которые проект закрывал двумя задачами.
- `` у сервиса не осталось входа, которым человек может воспользоваться:
приложения нет, личных ключей для программ нет, и до этих задач запись кладут
собранным руками запросом с сессией из браузера. Владелец окно принял.
- `` записи, застрявшие в конвейере на минуту выкладки, доходят до текста, и
ответа в чат по ним не уходит. Смягчения нет: чат и есть убираемый вход.
- `` бот у Telegram остаётся зарегистрированным и на вид живым, а ключ доступа —
в настройках выкладки под возврат входа (решение владельца от 2026-08-14).
Отправитель голосового не получит ни ответа, ни отказа.
+5 -2
View File
@@ -35,12 +35,15 @@
| Дата | Запись | Статус |
| --- | --- | --- |
| 2026-08-15 | [Вход Telegram убран целиком, а не выключен признаком](ADR-2026-08-15-telegram-intake-removed-temporarily.md) | |
| 2026-08-15 | [Обязательность владельца держит схема, а не приём](ADR-2026-08-15-owner-required-by-schema.md) | |
| 2026-08-15 | [Метка убранного входа не выставляется вовсе, а не обнуляется](ADR-2026-08-15-removed-intake-has-no-metric-label.md) | |
| 2026-08-14 | [Предел простоя остаётся часом, хотя он короче самой работы](ADR-2026-08-14-stuck-limit-stays-an-hour.md) | |
| 2026-08-14 | [Ответ распознавателя хранится дословно, двоичной формой и вложением](ADR-2026-08-14-provider-payload-stored-verbatim.md) | |
| 2026-08-14 | [Остановка записи — признак, а не рубеж](ADR-2026-08-14-halt-is-a-flag-not-a-stage.md) | |
| 2026-08-14 | [Учётная запись с записями не удаляется, и это осознанный тупик](ADR-2026-08-14-account-with-records-is-not-deleted.md) | |
| 2026-08-13 | [Намерение объявляется признаком, а не выводится из ключа доступа](ADR-2026-08-13-telegram-intent-declared-not-inferred.md) | |
| 2026-08-13 | [Недоступность Telegram подъёму сервиса не мешает](ADR-2026-08-13-telegram-outage-does-not-block-startup.md) | |
| 2026-08-13 | [Намерение объявляется признаком, а не выводится из ключа доступа](ADR-2026-08-13-telegram-intent-declared-not-inferred.md) | устарело: вход убран [ADR-2026-08-15-telegram-intake-removed-temporarily](ADR-2026-08-15-telegram-intake-removed-temporarily.md) |
| 2026-08-13 | [Недоступность Telegram подъёму сервиса не мешает](ADR-2026-08-13-telegram-outage-does-not-block-startup.md) | устарело: вход убран [ADR-2026-08-15-telegram-intake-removed-temporarily](ADR-2026-08-15-telegram-intake-removed-temporarily.md) |
| 2026-08-12 | [Файл записи закрыт защищённым полем и отдаётся вошедшему по токену файла](ADR-2026-08-12-protected-file-behind-session.md) | |
| 2026-08-12 | [Сессия живёт семь суток и не продлевает саму себя](ADR-2026-08-12-session-without-refresh.md) | |
| 2026-08-12 | [Кого пускать в сервис, решает правило провайдера, а не сервис](ADR-2026-08-12-access-delegated-to-provider.md) | |
+37 -57
View File
@@ -17,16 +17,17 @@
- [intake](../openspec/specs/intake/spec.md) — **приём по HTTP плюс наличие
входов**: приём и опрос за сессией, имя отправителя не доходит ни до
хранилища, ни до журнала, метка метрики несёт только известное расширение, а
выключенный вход Telegram не мешает подъёму. Задачи
наблюдатель видит единственный поднятый вход. Задачи
`http-handler-tests-never-green` и `no-user-filename-in-log` 2026-08-11,
`pocketbase-storage` и `oidc-login` 2026-08-12,
`local-run-without-telegram-token` 2026-08-13. Приём из Telegram по существу —
кто допущен и как забирается запись — здесь по-прежнему не описан;
`local-run-without-telegram-token` 2026-08-13, `remove-telegram-intake`
2026-08-14;
- [pipeline](../openspec/specs/pipeline/spec.md) — пустой прогон воркера, захват
задачи и срок его протухания, число попыток, состояние «мертва», пауза перед
повтором и недоставленный ответ отправителю: задачи
`errors-as-instead-of-typecast` 2026-08-11, `pocketbase-storage` 2026-08-12 и
`local-run-without-telegram-token` 2026-08-13. Переходы состояний и отмена
задачи и срок его протухания, число попыток, остановка признаком, пауза перед
повтором и молчание конвейера наружу: задачи
`errors-as-instead-of-typecast` 2026-08-11, `pocketbase-storage` 2026-08-12,
`local-run-without-telegram-token` 2026-08-13 и `remove-telegram-intake`
2026-08-14. Переходы состояний и отмена
контекста посреди шага остаются
долгом; что именно не описано, перечисляет раздел `Purpose` самой спеки;
- [storage](../openspec/specs/storage/spec.md) — где живут запись, её метаданные
@@ -40,17 +41,17 @@
- [access](../openspec/specs/access/spec.md) — кто пришёл в сервис и пускают ли
его дальше: вход через внешнего провайдера OIDC, чем предъявляется сессия, что
её прекращает и какие адреса остаются открытыми. Задача `oidc-login`
2026-08-12. Здесь же разграничение записей по владельцу: запись из веба
принадлежит тому, кто её принёс, чужая неотличима от несуществующей, а запись
из Telegram владельца не имеет и по API не достаётся никому. Задача
`record-ownership` 2026-08-14.
2026-08-12. Здесь же разграничение записей по владельцу: принятая запись
принадлежит тому, кто её принёс, чужая неотличима от несуществующей, а ничьей
записи не бывает вовсе — колонка владельца пустого значения не принимает.
Задачи `record-ownership` и `remove-telegram-intake` 2026-08-14.
Поведение прочих узлов, включая приём из Telegram, по-прежнему живёт только в
коде. Задача, которая его трогает, дописывает спеку своей capability.
Поведение узла, которого нет в перечне выше, по-прежнему живёт только в коде.
Задача, которая его трогает, дописывает спеку своей capability.
## Принципы
- **Один процесс.** Бот, HTTP-сервер и фоновые воркеры живут в одном бинарнике и
- **Один процесс.** HTTP-сервер и фоновые воркеры живут в одном бинарнике и
делят одну базу. Отдельного воркер-процесса нет намеренно.
- **Очередь таблицей.** Состояние задачи лежит коллекцией хранилища; неделимость
захвата и порядок выборки нормирует
@@ -64,7 +65,7 @@
[pipeline](../openspec/specs/pipeline/spec.md), «Брошенная задача возвращается
в работу»; здесь это принцип письма шага, а не описание поведения.
- **Ядро зависит от интерфейсов.** `internal/service` знает только
`internal/contract`; ffmpeg, Yandex, Telegram и хранилище подставляются в
`internal/contract`; ffmpeg, Yandex и хранилище подставляются в
`main.go`. Правило механизировано тестами-сканерами `internal/archrules`, и
они же держат обратные направления: транспорты не знают друг о друге, адаптер
не знает ни ядра, ни транспортов.
@@ -77,17 +78,15 @@
Каждый — строкой со ссылкой на capability, а не пересказом её требований.
<!-- канон: поведение → openspec/specs/intake, pipeline, storage; ещё НЕ переехало: приём из Telegram, деление длинного текста по словам -->
<!-- канон: поведение → openspec/specs/intake, pipeline, storage; ещё НЕ переехало: приведение записи к рабочему формату -->
| Компонент | Где | Что делает |
| --- | --- | --- |
| Telegram-бот | `internal/controller/tg` | Принимает голосовые, аудиофайлы и документы с аудио, скачивает их, заводит задачу |
| HTTP API | `internal/controller/http` | Приём файла и опрос статуса задачи |
| Воркеры | `internal/controller/worker` | Пул одинаковых потоков: каждый берёт любую пригодную запись и опрашивает базу. Число — настройкой, ноль законен |
| Сервис расшифровки | `internal/service` | Конвейер: приём, приведение, отправка, опрос, завершение. Шаг выбирается по рубежу записи |
| Конвертер и метаданные | `internal/adapter/{converter,metaviewer}/ffmpeg` | `ffmpeg` в ogg/vorbis, `ffprobe` для длительности |
| Распознаватель | `internal/adapter/recognizer/yandex` | Заливка в Object Storage и отложенное распознавание SpeechKit; разбор ответа в реплики со временем |
| Отправитель Telegram | `internal/adapter/telegram` | Отправка текста, деление длинного по словам |
| Репозитории | `internal/adapter/repo/pocketbase` | Записи, файлы, тексты, структура, попытки распознавания и журнал событий — коллекциями хранилища; захват — сырым запросом |
| Шаги схемы | `internal/adapter/repo/pocketbase/migrations` | Файл на шаг, имя файла — имя шага; там же имена коллекций |
| Панель владельца | `internal/adapter/repo/pocketbase`, `panel.go` | Панель хранилища; правила правки записи нормирует [storage](../openspec/specs/storage/spec.md), «Владелец видит записи в панели» |
@@ -102,12 +101,6 @@
## Внешние границы и форматы
- **Telegram Bot API.** Вход — обновления длинным опросом, выход — сообщения.
Файл скачивается по ссылке `file.Link(token)` запросом с контекстом, клиентом
самого бота. Клиента заводит единая точка `internal/adapter/telegram`: токен
стоит в пути каждого обращения, и снятие адреса с отказа живёт там —
[conventions/logging.md](conventions/logging.md), «Безопасность: что не
логируем». Telegram не отдаёт файлы больше 20 МиБ — это потолок приёма из бота.
- **Yandex Object Storage.** S3-совместимый, клиент `aws-sdk-go-v2` с
`UsePathStyle`. Ключ объекта — имя файла, то есть UUID с расширением.
- **Yandex SpeechKit v3.** gRPC, `stt.api.cloud.yandex.net:443`, модель
@@ -121,26 +114,12 @@
- **Где работает, что рядом, кто перезапускает:** один контейнер на личном
сервере, разворачивает и перезапускает Ansible из `pet-project-server`. Рядом —
обратный прокси, который публикует HTTP-порт наружу.
- **Порядок выкладки задаётся по ключу, а не по файлу целиком.** Общего правила
«сперва образ» или «сперва конфиг» нет: два ключа секции Telegram требуют
противоположного, и оба правила действуют одновременно.
- **Признак включения `telegram.enabled` едет в конфиг раньше образа.** Он
обязателен с 2026-08-13, умолчания у него нет, и образ, который его ждёт,
без него выходит с кодом 1 **до** открытия порта — вместе с HTTP, панелью и
конвейером. Прежний образ лишний ключ TOML просто не читает, поэтому ранняя
правка конфига безопасна, а поздняя роняет сервис.
- **Пустой ключ доступа `telegram.bot_token` едет позже образа.** Образы
старше 2026-08-13 роняли старт на пустом ключе, тоже до открытия порта.
- **Откат при выключенном входе** допустим только на образ от 2026-08-13 и
новее. На более старом состояния «сервис поднят, бот опущен» не существует
вовсе: пустой ключ роняет старт, негодный роняет старт, годный поднимает
бота. Откат туда делают с непустым годным ключом, приняв, что бот поднимется.
- **Откат образа при `enabled = false` и заполненном ключе** отменяет решение
владельца молча: прежний образ признака не видит и поднимает бота. Если вход
был выключен потому, что бот с этим токеном поднят где-то ещё, два процесса
поделят один длинный опрос и часть ответов до людей не дойдёт.
Ревью кода воспроизвело порядок на прежней версии, живой прогон — на нынешней.
- **Порядок выкладки: конфиг после образа.** Прежде здесь стояло правило,
разное для двух ключей секции Telegram; с убранным входом оно потеряло предмет
целиком. Оставшиеся ключи, которых новый образ ждёт, в конфиге уже есть.
Секцию `[telegram]` и ключ `server.users_while_list` человек убирает из боевого
файла после выкладки: незнакомые ключи разбор настроек не судит, и файл с ними
сервис поднимает молча.
- **Откат образа через шаг схемы `202608140002` не работает и не говорит об
этом.** Шаг удаляет прежнюю коллекцию задач, а библиотека накатывает только
те шаги, которые знает сам бинарь: прежний образ шагов новее не видит,
@@ -160,16 +139,15 @@
| Зависимость | Падает | Отвечает медленно | Молчит | Отдаёт мусор |
| --- | --- | --- | --- | --- |
| Telegram Bot API | Сервис поднимается без Telegram и работает по HTTP; старт роняют только ошибки настройки — ответ «такого бота нет» и включённый вход с пустым ключом доступа. Норму держит [intake](../openspec/specs/intake/spec.md), «Признак включения решает, поднимается ли вход Telegram» | На старте — ждём не дольше срока, дальше поднимаемся без Telegram. У поднятого сервиса скачивание файла висит бесконечно: там срока нет | То же, что «отвечает медленно»: на старте — подъём без Telegram по истечении срока, у поднятого — длинный опрос пуст и новые задачи не заводятся | Файл скачался битым, отказ вылезет на конвертации |
| Yandex SpeechKit | Шаг возвращает ошибку, запись остаётся на повтор | Захват держится час, запись не двигается; по истечении предела простоя она останавливается с причиной «застряла», не теряя идентификатора операции | Операция вечно `in progress`, повтор каждые 5 секунд — до предела простоя в сутки | Пустой текст — запись завершается заглушкой «на записи нет текста» |
| Yandex SpeechKit | Шаг возвращает ошибку, запись остаётся на повтор | Захват держится час, запись не двигается; по истечении предела простоя она останавливается с причиной «застряла», не теряя идентификатора операции | Операция вечно `in progress`, повтор каждые 5 секунд — до предела простоя в сутки | Пустой текст — запись доходит до конечного рубежа без расшифровки, и в журнале стоит запись «может стать проблемой» с идентификатором записи; опрос готовности отдаёт рубеж `done` без поля текста |
| ↳ *остановка сервиса* | Принятие операции от отмены защищено своим пределом в 10 секунд: операцию там могли принять и начать считать деньги, а потерянный идентификатор заставил бы повтор оплатить ту же запись второй раз. Заливка в Object Storage отменяется штатно — её повтор бесплатен, объект ложится под тем же ключом | — | — | — |
| Yandex Object Storage | Заливка падает, запись остаётся на рубеже `normalized` | То же, что падение: висит до конца захвата | — | SpeechKit не прочитает объект и вернёт отказ операции |
| ffmpeg, ffprobe | Запись останавливается признаком с текстом «сбой конвертации файла» — рубеж при этом сохраняется, и снятие признака продолжает с него. Остановка сервиса — исход другой: процесс убивают контекстом, запись остаётся на повтор и отказа не тратит | Конвейер стоит: вызов синхронный | — | Выходной файл пуст, отказ вылезет на распознавании |
| Хранилище (файл на диске) | Приложение не стартует либо шаг падает на каждом запросе | Блокировка записи держит воркеры | — | — |
| Диск | Запись файла падает, задача не заводится | — | — | — |
- **Кто заметит отказ и когда:** пользователь Telegram — сразу, по молчанию бота
или по сообщению об ошибке. Владелец — по метрике
- **Кто заметит отказ и когда:** тот, кто загрузил запись, — опросом готовности:
остановленная запись отдаёт признак остановки. Владелец — по метрике
`transcriber_worker_job_count` с меткой `error="true"`, и метка `stage`
называет рубеж, с которого запись взята: с появлением пула одинаковых воркеров
имя потока перестало что-либо значить, а разрез по шагу — единственное, чем
@@ -178,15 +156,15 @@
- **Журнал событий записи** — второй канал наблюдения, `record_events`. Пишется
на смену рубежа, на остановку и на снятие остановки; читает его человек в
панели, ни один шаг конвейера на него не смотрит. Экрана у него пока нет.
- **Характер потока:** непрерывный, но разреженный. Бот держит длинный опрос,
воркеры опрашивают базу вхолостую с паузой из
- **Характер потока:** непрерывный, но разреженный. Воркеры опрашивают базу
вхолостую с паузой из
[database.md](database.md), «Настройки с числовым значением».
## Единые точки проекта
| Что | Где |
| --- | --- |
| Приём аудио и заведение записи | `TranscribeService.createRecord`через него идут оба входа |
| Приём аудио и заведение записи | `TranscribeService.createRecord`единственный путь, которым запись появляется в хранилище |
| Правка записи владельцем | панель хранилища; правка запросом проходит правила перехода (`pocketbase.BindPanelRules`), а шаг конвейера пишет только свои поля и правку владельца не стирает |
| Захват записи воркером | `AudioRecordRepository.FindAndAcquire` — один запрос с `RETURNING`, отдаёт идентификатор и признак захвата |
| Объявление рубежа | `internal/entity/stage.go` — выбор шага, отбор захвата, срок протухания и предел простоя выводятся отсюда |
@@ -194,7 +172,7 @@
| Рабочая копия файла на диске | `FileRepository.Localize`, `Stage`, `StageEmpty` — они же дают единственный способ её убрать (`WorkFile.Close`); зовёт его шаг |
| Переход записи на рубеж | `entity.AudioRecord.MoveToState` — чистит служебные поля прошлого рубежа и ставит время входа |
| Откладывание работы | `entity.AudioRecord.Postpone` — ставит паузу и снимает захват, рубежа не трогая |
| Остановка и перезапуск | `entity.AudioRecord.Halt` и `Resume`; ответ отправителю`TranscribeService.halt`, одно место на все причины |
| Остановка и перезапуск | `entity.AudioRecord.Halt` и `Resume`; запись причины и события`TranscribeService.halt`, одно место на все причины |
| Разбор конфигурации | `internal/config.LoadConfig` |
| Чтение времени | `internal/clock``Now` даёт метку в UTC, `Start` — начало измерения длительности; `time.Now` вне пакета запрещён правилом линтера |
| Метрики | `internal/metrics`, префикс имени `transcriber_` |
@@ -224,7 +202,8 @@
[access](../openspec/specs/access/spec.md), решения —
[ADR-2026-08-12-session-without-refresh](adr/ADR-2026-08-12-session-without-refresh.md)
и [ADR-2026-08-12-oidc-exchange-via-own-route](adr/ADR-2026-08-12-oidc-exchange-via-own-route.md).
**Не решено одно:** как связываются пользователь Telegram и пользователь веба.
**Не решено одно:** как связать чат Telegram с учётной записью — от этого
зависит возвращение убранного входа.
Панель администратора при этом Authelia не закрывает: у неё свой пароль
суперпользователя.
- **Приложение.** Экранов нет вовсе, есть только API. Решено делать SPA,
@@ -240,8 +219,8 @@
Push с VAPID отвергнут. Появляется внешняя зависимость, которой сегодня нет, и
текст расшифровки начинает уходить на сторону — сдвиг периметра
[security.md](security.md).
- **Долгие записи.** Потолок сегодня неизвестен и не замерялся: 20 МиБ на приём
из Telegram — точно, ограничения `deferred-general` по длине — нет. Расчётные
- **Долгие записи.** Потолок сегодня неизвестен и не замерялся: ограничения
`deferred-general` по длине не выяснены. Расчётные
шесть часов нормирует [storage](../openspec/specs/storage/spec.md), «Файл
записи живёт в хранилище»; откуда взято число —
[research/pocketbase-defaults.md](research/pocketbase-defaults.md), «Чего эта
@@ -265,8 +244,9 @@
- **Формат для распознавания.** Конвертер отдаёт ogg/vorbis (`libvorbis`), а
SpeechKit получает `ContainerAudio_OGG_OPUS`. Расхождение не разобрано: то ли
сервис определяет содержимое сам, то ли часть записей теряется на этом.
- **Видео.** Дорожку из видеофайла бот принимает по MIME-типу `video/`, но
конвертер этот случай не проверялся.
- **Видео.** Дорожка из видеофайла к приёму допускается — расширение он берёт из
имени и о годности содержимого спрашивает источник метаданных, — но конвертер
на этом случае не проверялся.
- **Очередь.** Модель очереди сделана задачей `pocketbase-storage` 2026-08-12
([ADR](adr/ADR-2026-08-11-queue-as-pocketbase-collection.md)), перестроена
вокруг аудиозаписи задачей `record-centric-model` 2026-08-14 и нормирована
+7 -18
View File
@@ -5,7 +5,7 @@
**Взято из проекта jellybit.** Расхождения с сегодняшним кодом названы по месту.
Главные: комментариями снабжена половина полей; единого места проверки на старте
нет: у секций `[auth]` и `[telegram]` свой `Validate()` в `main.go`, а пустые
нет: у секций `[auth]` и `[pipeline]` свой `Validate()` в `main.go`, а пустые
ключи `[yandex]` ловит конструктор распознавателя.
**Механизировано:** запрет `os.Getenv``forbidigo` в `.golangci.yml`
@@ -46,7 +46,6 @@
port = <N> # порт HTTP-сервера
shutdown_timeout = <N> # ждать мягкой остановки сервера, секунды
force_shutdown_timeout = <N> # ждать остановки воркеров, секунды
users_while_list = ["<@name>"] # кому отвечает бот; строка автора Telegram
```
Значения намеренно заменены плейсхолдерами: предмет конвенции — форма
@@ -58,9 +57,6 @@ users_while_list = ["<@name>"] # кому отвечает бот; стр
Секретные поля оставляем пустыми — значение приходит из выкладки (см. «Секреты»).
*Расхождение:* секции `[server]` в `config.example.toml` не хватает поля
`users_while_list`, из-за чего бот на свежем конфиге отвечает отказом всем.
*Расхождение:* адреса провайдера в секции `[auth]` образца заполнены примерами
вида `https://auth.example.com/...`, а не оставлены пустыми: пустой адрес не
говорит, какой формы значение здесь ждут. Пустым оставлен только
@@ -91,7 +87,7 @@ Ansible из `pet-project-server`). Приложение просто читае
секретов в коде нет. Источник истины секрета — внешнее хранилище выкладки, не
репозиторий и не окружение.
- Секретные поля transcriber: `telegram.bot_token`, `yandex.speech_kit_api_key`,
- Секретные поля transcriber: `yandex.speech_kit_api_key`,
`yandex.object_storage_access_key_id`,
`yandex.object_storage_secret_access_key`, `auth.client_secret`.
- Отрендеренный `config.toml` (с секретами) **не коммитится**; права `0600`,
@@ -130,16 +126,8 @@ Ansible из `pet-project-server`). Приложение просто читае
TOML. Пустые ключи Yandex ловятся в конструкторе распознавателя, и там процесс
выходит с кодом 1. Единого места проверки нет.
Под это расхождение больше не подпадают два ключа секции `[telegram]` — признак
включения и ключ доступа, — и проверок у них две. Третий ключ секции,
`update_timeout`, границ по-прежнему не проверяет никто, и ноль в нём обращает
длинный опрос в непрерывный. Обязательность признака включения судит загрузчик — только разбор отличает
«ключ не задан» от «ключ задан ложным», потому что нулевое значение `bool` у
обоих одинаковое. Заполненность ключа доступа судит `TelegramConfig.Validate()` из
`main.go`, рядом с проверкой `[auth]`: пустой `bot_token` при `enabled = true`
ошибка настройки и отказ старта. Непустой негодный по-прежнему судится при сборке
клиента, до подъёма сервера. Нормирует это `openspec/specs/intake`, «Признак
включения решает, поднимается ли вход Telegram».
Два ключа секции `[telegram]`, стоявшие здесь исключением, ушли вместе с самим
входом 2026-08-14: секции больше нет, и своей проверки у неё тоже.
Секция `[auth]` — первая, у которой проверка своя и стоит на старте:
`AuthConfig.Validate()` зовётся из `main.go` сразу после загрузки и роняет
@@ -165,5 +153,6 @@ TOML. Пустые ключи Yandex ловятся в конструкторе
комментария у самого поля), ни по нулевому значению типа; присутствие ключа
судит **разбор**`MetaData.IsDefined` из `toml.DecodeFile`, — потому что
значение отличить «не задано» от «задано нулём» не позволяет. В
`config.example.toml` у поля стоит значение свежей установки. Первое такое
поле `telegram.enabled`.
`config.example.toml` у поля стоит значение свежей установки. Первым таким
полем был `telegram.enabled`; секция убрана 2026-08-14, и живого примера у
правила сейчас нет.
+12 -14
View File
@@ -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`, процесс
живёт. Своего слоя мы не пишем. У воркеров и у бота такой границы **нет**:
паника в шаге конвейера роняет процесс целиком.
живёт. Своего слоя мы не пишем. У воркеров такой границы **нет**: паника в
шаге конвейера роняет процесс целиком.
## Несколько ошибок
+4 -4
View File
@@ -78,7 +78,7 @@
| --- | --- |
| Сравнение ошибок через `errors.Is` и `errors.As`, не `==` и не приведением типа | `.golangci.yml``errorlint` |
| Ошибка не узнаётся сравнением текста сообщения (`strings.Contains(err.Error(), …)`, `err.Error() == …`) | `internal/archrules``TestОшибкаНеУзнаётсяПоТексту` |
| Непроверенное возвращаемое значение ошибки | `.golangci.yml``errcheck`, включая присваивание в `_` (`check-blank`). Отказ, который решено не проверять, объявляют в `exclude-functions` поимённо — там сегодня `defer Close`, `os.Remove` и `send` |
| Непроверенное возвращаемое значение ошибки | `.golangci.yml``errcheck`, включая присваивание в `_` (`check-blank`). Отказ, который решено не проверять, объявляют в `exclude-functions` поимённо — там сегодня `defer Close` и `os.Remove` |
| Непроверенное приведение типа (`v := x.(T)`) | `.golangci.yml``errcheck` с `check-type-assertions`. Отдельная настройка, потому что такое приведение паникует, а не возвращает ошибку, и `check-blank` его не видит |
| Проверенный отказ не оборачивается в `return nil` | `.golangci.yml``nilerr`. Механизирует половину инварианта «принятая запись не теряется молча»: молчаливый успех после отказа |
| Отказ выборки из хранилища не теряется (`rows.Err()`), а сама выборка закрывается | `.golangci.yml``rowserrcheck`, `sqlclosecheck`. **Профилактические: предмета в коде сегодня нет** — выборки идут через `dbx` хранилища, а из `database/sql` употребляются только `sql.NullString` и `sql.ErrNoRows`. Правила заведены на будущий сырой запрос; мутацией проверены на пробе, а не на своём коде |
@@ -89,7 +89,7 @@
| Правило | Где механизировано |
| --- | --- |
| Ядро (`internal/service`) не знает ни адаптеров, ни транспортов | `internal/archrules``TestЯдроНеЗнаетОбАдаптерах`, `TestЯдроНеЗнаетОТранспортах` |
| Транспорты (`controller/http`, `controller/tg`, `controller/worker`) не знают друг о друге | `internal/archrules``TestТранспортыНеЗнаютДругОДруге` |
| Транспорты (`controller/http`, `controller/worker`) не знают друг о друге | `internal/archrules``TestТранспортыНеЗнаютДругОДруге` |
| Адаптер не знает ни ядра, ни транспортов | `internal/archrules``TestАдаптерыНеЗнаютНиЯдра_НиТранспортов` |
| Колонки записи согласованы: что пишет отображение ↔ что читает обратное ↔ что заводит шаг схемы | `internal/archrules` → правила о колонках. Закрывает инвариант «колонки записи правятся в двух местах» (CLAUDE.md, major), которого компилятор не держит. Литерал колонки ищется в телах нужных функций, а не в файле целиком |
| Рубежи согласованы: дескриптор ↔ таблица выбора шага, в обе стороны | `internal/archrules` → правила о рубежах. Закрывает инвариант «рубеж объявляется одним дескриптором» (CLAUDE.md, major). Рубеж без шага останавливает запись, не начав работы; шаг без рубежа недостижим — захват такую запись не выдаст никогда |
@@ -117,7 +117,7 @@
| --- | --- |
| Проверка судит ответ по готовому ответу (`Result()`), а не по живой карте заголовков обработчика | `.golangci.yml``forbidigo` с `analyze-types`, находки только в `*_test.go`. Судит по типу приёмника (`httptest.ResponseRecorder`), поэтому ловит любую форму: цепочкой, через переменную, по индексу карты, обходом, полем `HeaderMap`. Остаётся ревью проверка, идущая мимо recorder — через свой `http.ResponseWriter` |
| Форма утверждения в проверках: «ожидалось» и «получено» не перепутаны местами, отказ судится `NoError`, а не `Nil`, `require` не зовут из горутины | `.golangci.yml``testifylint` |
| Одновременный доступ проверен детектором, а не чтением кода | `Taskfile.yml` → шаг `tests` (`go test -race ./...`). Общее у воркеров — счётчики метрик, логгер и клиент бота; захват задачи в гонку не входит, он по построению её не даёт (одно состояние на воркер) — см. «Типовые ложноположительные» в [../review.md](../review.md). Что делает шаг без компилятора C и каким кодом краснеет — [CLAUDE.md](../../CLAUDE.md), «Гейт» |
| Одновременный доступ проверен детектором, а не чтением кода | `Taskfile.yml` → шаг `tests` (`go test -race ./...`). Общее у воркеров — счётчики метрик и логгер; захват задачи в гонку не входит, он по построению её не даёт (одно состояние на воркер) — см. «Типовые ложноположительные» в [../review.md](../review.md). Что делает шаг без компилятора C и каким кодом краснеет — [CLAUDE.md](../../CLAUDE.md), «Гейт» |
| Строчное подавление называет линтер и причину, а протухшее краснеет | `.golangci.yml``nolintlint` (`require-explanation`, `require-specific`, `allow-unused: false`) |
### Форма кода и файлов вне Go
@@ -152,7 +152,7 @@
| Подавлено | Где | Почему |
| --- | --- | --- |
| `errcheck` на `defer Close`, `os.Remove` и `send` | `.golangci.yml`, `exclude-functions` | Отказ, который решено не проверять, объявляют поимённо — так он заметен |
| `errcheck` на `defer Close` и `os.Remove` | `.golangci.yml`, `exclude-functions` | Отказ, который решено не проверять, объявляют поимённо — так он заметен |
| Правило о заголовках вне `*_test.go` | `.golangci.yml`, `exclusions` | В рабочем коде `Header()` и есть способ отдать заголовок |
| `time.Now` внутри `internal/clock` | там же | Единой точке чтения времени нечем читать время иначе |
| Чтение времени и окружения в `*_test.go` | там же | Проверка строит вход прогона — фикстуру времени, `PATH`, окружение дочернего процесса, — а не метку домена и не настройки приложения. Исключение объявлено по тексту сообщения: правило называет четыре имени, и исключение обязано покрывать те же четыре |
+16 -29
View File
@@ -28,7 +28,7 @@ OpenSpec.
`jq` без регулярных выражений.
```json
{"time":"2026-08-10T11:23:45.123456Z","level":"INFO","msg":"record accepted","capability":"intake","record_id":"…","source":"telegram","duration_seconds":137}
{"time":"2026-08-10T11:23:45.123456Z","level":"INFO","msg":"record accepted","capability":"intake","record_id":"…","source":"api","duration_seconds":137}
```
*Расхождение:* `main.go` ставит `slog.NewTextHandler(os.Stdout, …)`.
@@ -37,7 +37,7 @@ OpenSpec.
- `msg` — короткая константа в нижнем регистре: `record accepted`,
`recognition done`, `conversion failed`. Данные — в атрибутах:
`log.Info("record accepted", "record_id", id, "source", "telegram")`.
`log.Info("record accepted", "record_id", id, "source", "api")`.
- `msg` — чистая категория без префикса подсистемы: `recognition done`, а не
`recognize: done`. Подсистему выносим в поле `capability`, не в текст.
- **Смена состояния задачи — единая категория `state transition`** с полями
@@ -60,7 +60,7 @@ OpenSpec.
| `DEBUG` | разработчику при отладке; в продакшене выключен | `GET /health`, пустой прогон воркера, проверка готовности операции распознавания, тела запросов и ответов внешних сервисов |
| `INFO` | владельцу, разбор постфактум | приём записи, переход задачи, конвертация выполнена, текст отправлен, старт и остановка процессов, **событийный вызов внешнего сервиса** |
| `WARN` | владельцу, «может стать проблемой» | повтор внешнего вызова, задача досталась повторно по истечении захвата, пустой текст распознавания |
| `ERROR` | владельцу, в разбор | внешний сервис недоступен, задача ушла в `failed`, необработанная ошибка |
| `ERROR` | владельцу, в разбор | внешний сервис недоступен, запись остановлена признаком, необработанная ошибка |
Правила:
@@ -99,7 +99,7 @@ OpenSpec.
| Когда добавляем | Поля |
| --- | --- |
| на входящий HTTP-запрос | `transport` (`http`, `telegram`), `http.method`, `http.route`, `http.status_code`, `duration_ms` |
| на входящий HTTP-запрос | `transport` (`http`), `http.method`, `http.route`, `http.status_code`, `duration_ms` |
| на задачу | `capability` (значения — по именам заведённых capability в `openspec/specs/`), `record_id`, `file_id`, `source` |
| на запись об ошибке | `error` |
| на вызов внешнего сервиса | `ext.service`, `ext.operation`, `ext.status_code`, `duration_ms`, `retry` |
@@ -137,7 +137,7 @@ log := log.With("record_id", record.Id, "capability", "conversion")
- Логируем ошибку **один раз — на границе доменного слоя**, которая определяет
исход операции. Логирует эта единая точка, а не каждый транспорт — так
транспорты остаются тонкими. Границы в transcriber:
- приём записи (`CreateJobFromTelegram`, `CreateJobFromApi`);
- приём записи (`CreateJobFromApi`);
- **шаг конвейера** (`FindAndRunConversionJob`, `FindAndRunTranscribeJob`,
`FindAndRunTranscribeCheckJob`) — исход шага, вызванного циклом воркера;
- завершение и отказ задачи (`completeJob`, `failJob`).
@@ -169,9 +169,9 @@ log := log.With("record_id", record.Id, "capability", "conversion")
**Каждый** вызов внешнего сервиса логируется. Поля:
- `ext.service``telegram`, `speechkit`, `object-storage`, `ffmpeg`;
- `ext.operation` — логическая операция (`getFile`, `sendMessage`,
`RecognizeFile`, `GetOperation`, `PutObject`, `convert`);
- `ext.service``speechkit`, `object-storage`, `ffmpeg`;
- `ext.operation` — логическая операция (`RecognizeFile`, `GetOperation`,
`PutObject`, `convert`);
- `ext.status_code` — код ответа, если применим;
- `duration_ms` — длительность вызова;
- `retry` — номер попытки, если повторы были.
@@ -191,8 +191,7 @@ log := log.With("record_id", record.Id, "capability", "conversion")
*Расхождение:* обёртки `ext.*` нет. Из внешних вызовов логируется только
конвертация (через метрику длительности) и запуск распознавания; заливка в
Object Storage, скачивание файла из Telegram и опрос операции не логируются
никак.
Object Storage и опрос операции не логируются никак.
## HTTP и проверка здоровья
@@ -218,7 +217,6 @@ Object Storage, скачивание файла из Telegram и опрос оп
Никаких секретов в полях и сообщениях. Под запретом:
- токен бота Telegram;
- ключ SpeechKit и заголовок `Authorization`;
- пара ключей Object Storage;
- **сам текст расшифровки и имена файлов пользователя** — это содержимое личной
@@ -234,28 +232,17 @@ Object Storage, скачивание файла из Telegram и опрос оп
- При сомнении не логируем значение, логируем факт его наличия
(`"has_api_key", true`).
- **Ошибка HTTP-транспорта несёт URL — возможный носитель секрета.**
`*url.Error` из `net/http` встраивает полный URL запроса, а токен Telegram
живёт прямо в пути (`…/bot<TOKEN>/…`). Такую ошибку разворачивают в
`*url.Error` из `net/http` встраивает полный URL запроса, а секрет иногда
живёт прямо в пути. Такую ошибку разворачивают в
первопричину на границе клиента **до** лога и до обёртки: URL отбрасывается,
проверка `errors.Is` на причину сохраняется. Общее правило: **секрет не кладём
в URL, если у сервиса есть заголовок** — тогда его нет и в ошибке транспорта.
Обращения к Telegram этому правилу следуют, и точка чистки одна на все вызовы —
`internal/adapter/telegram`, `NewBot`. Токен стоит в пути **каждого** обращения к
Bot API, поэтому чистка на месте употребления закрывала бы один вызов из всех:
- отказ транспорта разворачивает в первопричину клиент бота (`safeClient`), а
библиотека отдаёт наш отказ вызывающему нетронутым — этим закрыты `getFile`,
`sendMessage`, скачивание записи и `getMe` из конструктора;
- длинный опрос печатает свои отказы **пакетным логгером самой библиотеки**,
минуя наш `slog`; логгер подменён на вычищающий (`tgbotapi.SetLogger`), и
замена точная — токен известен.
Прежде здесь стоял `http.Get(file.Link(token))`, отказ уезжал в журнал вместе с
токеном, а конвенция числила это расхождением с оценкой «не логируется», которая
была неверной. Запись — [../review.md](../review.md), 2026-08-13; оракулом
служат проверки `internal/adapter/telegram/bot_test.go`, судящие по тексту
отказа и строке журнала.
Живого случая у этого правила сейчас нет: единственный секрет, стоявший в пути
обращения, — токен бота, и он ушёл вместе с входом Telegram 2026-08-14. Разбор
случая и цена промаха записаны в [../review.md](../review.md), 2026-08-13:
конвенция числила утечку расхождением с оценкой «не логируется», и оценка была
неверной.
*Изъятие, а не расхождение:* расширение берётся из имени отправителя дословно
(`filepath.Ext`), поэтому имя `запись.тайное-слово` отдаёт приватный хвост
+1 -1
View File
@@ -108,5 +108,5 @@
узнала»).
- **Устройство service worker и версионирование статики** — задача
[installable-pwa](../../tasks/items/installable-pwa.md).
- **Как связываются пользователь Telegram и пользователь веба** — открытый вопрос
- **Как связать чат Telegram с учётной записью** — открытый вопрос; от него зависит возвращение убранного 2026-08-14 входа
«Учётные записи» в [../architecture.md](../architecture.md).
+15 -13
View File
@@ -47,7 +47,7 @@ CGO сборке не нужен.
| --- | --- | --- |
| `id` | TEXT PK | Идентификатор записи, выдаёт хранилище |
| `file` | file | Сам файл |
| `owner` | relation → `users` | Владелец файла; пусто у файлов записи, принятой ботом |
| `owner` | relation → `users` | Владелец файла; пустого значения не принимает |
| `location` | select | `local` или `s3` |
| `object_key` | TEXT | Ключ объекта; заведён прежним шагом и новым путём не заполняется |
| `size` | INTEGER | Размер в байтах |
@@ -66,8 +66,8 @@ capability, и третий смысл развёл бы одно слово п
| Поле | Тип | Что |
| --- | --- | --- |
| `id` | TEXT PK | Идентификатор записи, выдаёт хранилище |
| `owner` | relation → `users` | Владелец записи; пусто у записей, принятых ботом |
| `source` | select | `api`, `telegram`, `unknown` |
| `owner` | relation → `users` | Владелец записи; пустого значения не принимает |
| `source` | select | `api`, `unknown`; значение `telegram` осталось историческим — вход убран, новых записей с ним не появляется |
| `title`, `brief` | TEXT | Заголовок и краткое описание: читаются вместе со списком |
| `state` | select | Рубеж: `uploaded`, `normalized`, `submitted`, `transcribed`, `done`; перечень закрыт схемой |
| `state_entered_at` | DATETIME | Время входа в рубеж — сторож застревания |
@@ -84,8 +84,8 @@ capability, и третий смысл развёл бы одно слово п
| `structure` | relation → `structures` | Структура реплик |
| `recognition` | relation → `recognitions` | Попытка распознавания |
| `topics` | relation → `topics`, до 5 | Темы записи |
| `tg_chat_id` | INTEGER | Куда отправить результат |
| `tg_reply_message_id` | INTEGER | С каким сообщением связать |
| `tg_chat_id` | INTEGER | Адресат ответа у записи убранного входа; кодом не читается |
| `tg_reply_message_id` | INTEGER | Ответное сообщение у неё же; кодом не читается |
| `created`, `updated` | DATETIME | Проставляет хранилище |
Индекс один — по паре «рубеж и признак остановки»: выборка захвата идёт по ним,
@@ -188,10 +188,15 @@ capability, и третий смысл развёл бы одно слово п
висела бы в панели вторым домом для понятия, которого больше нет.
**Владелец записи** заведён шагом `202608140001` — связью с коллекцией `users` в
обеих таблицах. Пустое значение допустимо, и это решение с ценой: записи,
принятые ботом, владельца не имеют вовсе, потому что связи чата Telegram с
учётной записью сервис не ведёт. Обязательность для приёма по HTTP держит поэтому
сам приём, а не схема.
обеих таблицах, — и шагом `202608140003` пустого значения больше не принимает.
Прежде принимал, и цену за это платили записи входа Telegram: связи чата с
учётной записью сервис не вёл. Вход убран 2026-08-14, ничью запись заводить стало
некому, и обязательность переехала из приёма в схему — туда, где её держит
хранилище, а не договорённость.
**Колонки `tg_chat_id` и `tg_reply_message_id`** остались от убранного входа и
кодом больше не читаются. Из схемы они не убираются: заводили их применённые
шаги `202608110001` и `202608140002`, а применённый шаг не переписывается.
Выборка по владельцу сужает **чтение записи**: чужая, ничья и несуществующая
дают один и тот же отказ. Выборку воркера владелец не сужает — конвейер
@@ -288,11 +293,8 @@ capability, и третий смысл развёл бы одно слово п
| Задержка перед первой проверкой операции | 10 секунд | `service/transcribe.go` | как было |
| Задержка между проверками операции | 5 секунд | там же | как было |
| Пауза воркера между прогонами | 1 секунда | `controller/worker/worker.go` | как было |
| Предел длины сообщения Telegram | 4000 символов | `adapter/telegram/sender.go` | предел Telegram |
| Таймаут мягкой остановки | 5 секунд | конфиг, `[server] shutdown_timeout` | — |
| Таймаут жёсткой остановки | 20 секунд | конфиг, `[server] force_shutdown_timeout` | — |
| Таймаут обновлений Telegram | 10 секунд | конфиг, `[telegram] update_timeout` | — |
| Срок ожидания Telegram при сборке клиента | 10 секунд | `adapter/telegram.ProbeTimeout` | решение, не замер: одно обращение за `getMe` укладывается в доли секунды, дольше Telegram считается недоступным и сервис поднимается без него. Длинный опрос этим сроком не ограничен — клиент подменяется сразу после сборки |
| Качество кодирования vorbis | `-q:a 4` | `adapter/converter/ffmpeg/ffmpeg.go` | — |
| Жизнь приглашения завести владельца панели | 30 минут | умолчание PocketBase | — |
| Потолок размера одной записи | 8 ГиБ | `entity.MaxRecordSize` | расчётный потолок в шесть часов с запасом на видео |
@@ -320,4 +322,4 @@ capability, и третий смысл развёл бы одно слово п
Чего среди настроек **нет**: режим журналирования, таймаут занятости и размер
пула соединений задаёт хранилище своими умолчаниями, а не мы; срока хранения
файлов и объектов нет вовсе. Таймаутов у
обращений к Telegram, S3 и SpeechKit тоже нет — ни одного.
обращений к S3 и SpeechKit тоже нет — ни одного.
+13 -16
View File
@@ -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`.
## Референсы
+36 -3
View File
@@ -4,14 +4,16 @@
[записки разведки](pocketbase.md) отличаются предметом: та мерила, **что даёт
панель**, эта — **что библиотека делает молча**, если её не переубедить.
Все четыре наблюдения нашлись ревью, а не чтением документации: три из них
выглядят как «значение по умолчанию — нет ограничения», а значат обратное.
Наблюдения нашлись ревью, а не чтением документации, и все об одном роде промаха:
объявление библиотеки выглядит как «ограничения нет» либо «ограничение есть», а
значит обратное.
## Как снималось
Версия **0.39.10**, та же, что у первой записки. Прогоны — на пустом каталоге
данных во временном каталоге и на поднятом сервере `127.0.0.1:18099`; боевые
данные и ключи не участвовали. Числа ниже сняты 2026-08-11 и 2026-08-12.
данные и ключи не участвовали. Числа сняты 2026-08-11 и 2026-08-12, последнее
наблюдение — 2026-08-15.
## Нулевой потолок у поля файла значит 5 МиБ, а не «без предела»
@@ -86,6 +88,34 @@ core/field_file.go:310 if f.MaxSize <= 0 { return DefaultFileFieldMaxSize }
прогоном: при первом запуске строка со ссылкой в журнале есть, после заведения
владельца при следующем запуске её нет.
## Обязательность связи проверяется у записи, а не у колонки
`Required` у поля связи — правило **проверки записи при сохранении**, а не
ограничение таблицы. Шаг схемы, объявляющий колонку обязательной на базе, где уже
лежат строки с пустым значением, проходит **зелёным** и такие строки оставляет:
```
core/field_relation.go:156 ColumnType отдаёт TEXT DEFAULT '' NOT NULL — от Required не зависит
core/collection_validate.go ни одной проверки, читающей существующие строки
```
Проверено прогоном 2026-08-15 на копии хранилища во временном каталоге: строка с
пустым владельцем заведена до шага, шаг применён тем же кодом, что и на подъёме,
и вывод:
```
STEP 003 (Required=true) поверх ничьей записи: err=<nil>
ПОСЛЕ ШАГА: строка на месте, owner=""
Save остановленной ничьей записи: err=failed to update audio record: owner: cannot be blank.
```
Следствие для нас: оставленная строка становится **незакрываемой**. Захват идёт
сырым запросом мимо проверки и выдаёт её воркеру, а всякое сохранение отказывает —
включая то, которым ставится признак остановки. Искать такие строки надо запросом
до выкладки, а не прогоном самого шага: прогон чистую базу от грязной не
отличает. Цена решения записана в
[adr/ADR-2026-08-15-owner-required-by-schema.md](../adr/ADR-2026-08-15-owner-required-by-schema.md).
## Чего эта записка не узнала
- **Во что обходится потолок в 8 ГиБ на диске.** Число выбрано расчётом из
@@ -96,3 +126,6 @@ core/field_file.go:310 if f.MaxSize <= 0 { return DefaultFileFieldMaxSize }
— нет.
- **Поведение под одновременной правкой панели и конвейера в бою.** Проверено
тестом на одной машине, не живой нагрузкой.
- **Сколько строк с пустой связью выдерживает смена признака обязательности.**
Проверено на одной строке: суть наблюдения — сам факт отсутствия проверки, а не
её цена на объёме.
+91 -38
View File
@@ -51,14 +51,14 @@
- повтор шага на той же задаче не создаёт лишних файлов и записей;
- отвечает пользователю ровно один раз.
**Транспорт** (`internal/controller/tg`, `internal/controller/http`):
**Транспорт** (`internal/controller/http`):
- проверяет право отправителя до всякой работы;
- не логирует ошибку, которую уже залогировал доменный слой;
- переводит доменную ошибку в свой ответ, а не отдаёт сырой текст;
- закрывает то, что открыл, на всех ветках выхода.
**Клиент внешнего сервиса** (`adapter/recognizer/yandex`, `adapter/telegram`):
**Клиент внешнего сервиса** (`adapter/recognizer/yandex`):
- имеет таймаут и не виснет, когда внешний сервис не отвечает;
- не кладёт секрет в URL и не даёт ему утечь через ошибку транспорта;
@@ -121,17 +121,19 @@
- **«Файлы и объекты не удаляются, диск растёт».** Факт верный и записан в
[database.md](database.md); срок хранения не задан сознательно, задачи на него нет.
Новой находкой это не считается, пока не измерен рост.
- **«Запись без владельца не достаётся никому».** Не дефект: владельца не имеют
записи, принятые ботом, — связи чата Telegram с учётной записью приложения
сервис не ведёт, её заводит `telegram-account-link`. Ответ такой записи по API
совпадает с ответом на несуществующую, и это норма — расшифровку отправитель
получает в чат.
- **«Запись без владельца не достаётся никому».** Строка отменена **дважды**, и
обе отмены оставлены намеренно: прогон, помнящий любую из прежних редакций,
иначе выбросил бы настоящую находку как известную.
**Прежняя редакция этой строки отменена 2026-08-14.** До задачи
`record-ownership` здесь стояло «вошедший видит чужие записи — не дефект и не
новость»: разграничения не было сознательно. Теперь оно есть, и такая находка
настоящая. Строка оставлена вместо удаления намеренно: прогон, помнящий её
прежний вид, выбросил бы регрессию не глядя.
До задачи `record-ownership` здесь стояло «вошедший видит чужие записи — не
дефект и не новость»: разграничения не было сознательно. Первая отмена
2026-08-14 завела разграничение и объявила не дефектом уже другое — запись без
владельца, принятую ботом.
Вторая отмена того же дня, задачей `remove-telegram-intake`, сняла и это:
колонка владельца пустого значения больше не принимает, ничьих записей у
сервиса не бывает вовсе. **Запись без владельца сегодня — настоящая находка**,
а не известное исключение.
### Вопросы по темам
@@ -145,10 +147,10 @@
делает с задачей, деньгами и ответом отправителю» (чтение `worker.go` и
`transcribe.go`, 2026-08-13; прежняя запись от 2026-08-10 устарела вместе с
дефектом «остановка хоронила запись»).
- `operations`: появился ли таймаут у обращения к Telegram, S3 и SpeechKit — ни у
одного из них таймаута нет, и проброс контекста на этот вопрос **не отвечает**:
контекст здесь несёт жизнь процесса, а не дедлайн вызова (чтение `tg.go`,
`s3.go`, `speechkit.go`, 2026-08-13).
- `operations`: появился ли таймаут у обращения к S3 и SpeechKit — ни у одного
из них таймаута нет, и проброс контекста на этот вопрос **не отвечает**:
контекст здесь несёт жизнь процесса, а не дедлайн вызова (чтение `s3.go`,
`speechkit.go`, 2026-08-13).
- `operations`: не удвоилась ли запись об одном сбое — шаг логирует ошибку и
возвращает её воркеру, который логирует снова (чтение `transcribe.go`,
2026-08-10).
@@ -161,14 +163,12 @@
- `security`: не уходит ли значение, пришедшее снаружи, меткой метрики — страница
метрик отдаётся без проверки отправителя, и метка это поверхность пошире
журнала (журнал, запись 2026-08-11 про хвост имени).
- `architecture`: не появился ли второй путь приёма мимо
`createTranscribeJob` — сегодня через него идут оба входа
- `architecture`: не появился ли второй путь приёма мимо `createRecord` — сегодня
он единственный, которым запись попадает в хранилище
([architecture.md](architecture.md), «Единые точки проекта»).
- `architecture`: не поехало ли поведение в `architecture.md` вместо спеки —
заведены четыре capability (`intake`, `pipeline`, `storage`, `access`), и
первые две описаны частично. Поведение прочих узлов, включая
приём из Telegram, живёт в обзоре под маркерами долга, а соблазн дописать туда
ещё — самый большой.
заведённые capability описывают поведение не целиком, и остаток живёт в обзоре
под маркерами долга, а соблазн дописать туда ещё — самый большой.
- `conventions`: новая колонка правится в обоих местах репозитория, а новый
рубеж — одним дескриптором
(CLAUDE.md, «Инварианты»).
@@ -205,12 +205,12 @@
- замена хранилища или переход на PocketBase — любой её кусок;
- смена модели очереди: захват, повторы и воркеры разом;
- каркас приложения: сборка фронтенда, раздача статики и шаг гейта разом;
- изменение, трогающее оба входа сразу — Telegram и HTTP.
- изменение, убирающее или возвращающее вход приёма целиком.
**Незнакомое здесь** (поднимает до `large`, ось формы решения):
- вход через OIDC и разграничение доступа: как связаны пользователь Telegram и
пользователь приложения, до начала работы назвать нельзя;
- вход через OIDC и разграничение доступа: как связать чат Telegram с учётной
записью, до начала работы назвать нельзя;
- всё, что делается на выбранном фреймворке впервые: правила
[conventions/web-ui.md](conventions/web-ui.md) выведены из выбора и из замера
на пробном экране, а не из написанного кода, и первая же задача проверяет их
@@ -224,7 +224,6 @@
**Мелкое здесь** (опускает до `small`):
- правка текста, который видит пользователь Telegram;
- новая метрика в `internal/metrics`;
- правка `config.example.toml` и умолчаний `defaultConfig()` без нового поля;
- правка документов канона.
@@ -259,19 +258,19 @@ API и имя не откатываются обратной правкой по
`adapter/metaviewer/ffmpeg` нет; решение и его цена — в
[adr/ADR-2026-08-11-stub-adapters-in-tests.md](adr/ADR-2026-08-11-stub-adapters-in-tests.md);
- **работа сервиса с настоящими внешними собеседниками.** Сам сервис поднять
теперь можно: с `telegram.enabled = false` он встаёт и работает одним входом
(`openspec/specs/intake`, «Признак включения решает, поднимается ли вход
Telegram»). Живой прогон — осмотр HTTP, панели, журнала и остановки — доступен
теперь любой задаче. Прежняя формулировка «всё, что требует поднять сервис целиком»
снята задачей `local-run-without-telegram-token` 2026-08-13; рецепт прогона
сменился с пустого ключа доступа на выключенный вход задачей
`telegram-enabled-flag` того же дня.
можно: он встаёт своим единственным входом на выдуманных непустых ключах
секций `[auth]` и `[yandex]` — наружу они на старте не ходят. Живой прогон —
осмотр HTTP, панели, журнала, метрик и остановки — доступен любой задаче.
Прежняя формулировка «всё, что требует поднять сервис целиком» снята задачей
`local-run-without-telegram-token` 2026-08-13; рецепт прогона менялся дважды —
с пустого ключа доступа на выключенный вход (`telegram-enabled-flag` того же
дня), а 2026-08-14 признак включения ушёл вместе с самим входом.
**Остаток**: за настоящий Telegram, SpeechKit и Object Storage живой прогон
по-прежнему не отвечает — боевым токеном запускаться запрещено, ключи Yandex в
прогоне выдуманные, а распознавание подменяют в коде. Проверить живьём можно
подъём, отказ старта, маршруты и остановку; нельзя — приём из Telegram,
расшифровку и заливку.
**Остаток**: за настоящие SpeechKit и Object Storage живой прогон по-прежнему
не отвечает — ключи Yandex в прогоне выдуманные, а распознавание подменяют в
коде. Проверить живьём можно подъём, отказ старта, маршруты, метрики и
остановку; нельзя — расшифровку и заливку. Вход через живого провайдера OIDC
тоже недоступен: сессию в прогоне выдать нечем.
## Журнал дефектов
@@ -281,6 +280,60 @@ API и имя не откатываются обратной правкой по
истории git 2026-08-10: поле «Чем воспроизведён» называет у них коммит, а не
оракул, и выдумывать оракул задним числом нельзя.
## 2026-08-15 — пустой второй ответ распознавателя стирал сохранённую расшифровку [пойман ревью]
- **Где:** `internal/adapter/repo/pocketbase/text_repo.go`, `TextRepository.Put`
и `StructureRepository.Put`; путь до них — `poll``storeOutcome` в
`internal/service/transcribe.go`. Кода задачи `remove-telegram-intake` дефект не
касался: она этот путь не трогала
- **Симптом:** поток от SpeechKit, закрывшийся на первом же ответе, отказом не
считается — наружу уходит пустой результат без отказа. Замена содержимого шла
безусловно, и повторный опрос той же операции клал пустое поверх сохранённой
расшифровки. Шаг при этом объявлял запись готовой: рубеж двигался, опрос
готовности отдавал `done` без текста
- **Причина:** соседний хранитель того же результата — сырой ответ провайдера —
от пустого значения защищён условием `len(raw) > 0` с самого заведения, а текст
и структура реплик такого условия не имели. Разное правило у двух хранителей
одного результата
- **Чем воспроизведён:** падающий тест враждебного прохода, переснятый триажем, —
`expected: "Личный разговор." actual: ""`. Оракул закреплён в дереве:
`internal/service/recognition_test.go`, `TestEmptySecondAnswerKeepsArchivedText`;
он же проверяет, что до второго ответа дело действительно дошло
- **Почему не поймали раньше:** повторный опрос одной операции — не редкость, но
и не штатный путь: он наступает, когда держатель захвата умер, сохранение рубежа
отказало либо человек снял остановку в панели. Ни один прогон до этого не строил
такого входа, а от чтения кода защита у соседа выглядела общей
- **Что меняем:** правило «пустое не кладётся поверх сохранённого» записано
нормой в спеку `storage` и держится **хранилищем**, а не шагом: шагов, кладущих
текст, больше одного, и правило у одного из них у остальных читалось бы как
снятое. Дефект пред-существующий, чинился решением владельца от 2026-08-14 в
задаче, которая его нашла
## 2026-08-15 — пустая расшифровка перестала быть заметной вместе с убранным входом [пойман ревью]
- **Где:** `internal/service/transcribe.go`, шаг завершения; документы
`docs/conventions/logging.md` и `docs/architecture.md`
- **Симптом:** запись с пустым распознаванием доходила до конечного рубежа и от
успешной не отличалась ничем — ни строкой журнала, ни ответом опроса
- **Причина:** единственным следом этого случая был текст, уходивший отправителю
в чат («на записи нет текста»). Задача убрала доставку целиком, и след исчез
вместе с ней — при том, что конвенция журнала называет пустой текст
распознавания поимённым примером уровня «может стать проблемой», а обзор
архитектуры обещал заглушку
- **Чем воспроизведён:** `internal/service/recognition_test.go`,
`TestEmptyRecognitionIsNamedInJournal` — подставной распознаватель отдаёт
готовую операцию с пустым результатом, проверка судит уровень строки и
идентификатор записи
- **Почему не поймали раньше:** удаление сняло **последнего потребителя** видимого
признака, а не сам признак; такое не видно ни компилятору, ни грепу по
удаляемому имени. Нашёл проход конвенций, сверив таблицу уровней журнала с тем,
что осталось в коде
- **Что меняем:** шаг опроса пишет строку уровня «может стать проблемой» с
идентификатором записи; строка обзора архитектуры переписана на фактическое
поведение. Класс общий: **удаляя канал, проверь, не был ли он единственным
потребителем сигнала** — сигнал переживает канал только там, где его переносят
руками
## 2026-08-13 — сторож инварианта про секрет искал подстроку, которой не бывает [пойман ревью]
- **Где:** `internal/config/config_test.go`, проверка «значение ключа доступа не
+19 -44
View File
@@ -15,11 +15,10 @@
записи, а чужая отвечает «не найдено». Целевому периметру недостаёт теперь второго уровня
доступа — страницы расхода для владельца сервиса.
Записи, принятые ботом, владельца не имеют и по API не достаются никому: связи
чата с учётной записью приложения нет, её заводит `telegram-account-link`.
Разграничение доступа в Telegram осталось прежним — белым списком, и с учётной
записью приложения он не связан.
Ничьих записей у сервиса больше не бывает: колонка владельца пустого значения
не принимает, и держит это схема хранилища. Прежде такие записи заводил вход
Telegram — связи чата с учётной записью сервис не вёл, — и 2026-08-14 вход убран
вместе с этим исключением.
**Целевой периметр шире сегодняшнего не только входом.** Содержимое записи
начинает уходить на три новые стороны — языковой модели, в канал уведомлений и
@@ -59,9 +58,7 @@
| --- | --- | --- |
| Аудиофайл и его имя | `POST /api/audio`, multipart-поле `audio` | Любой вошедший через OIDC; без сессии — `401` до чтения тела |
| Идентификатор задачи | `GET /api/status/:id` | Любой вошедший через OIDC; без сессии — `401`, одинаковый для заведённой и незаведённой задачи |
| Голосовое, аудио, документ | Telegram, длинный опрос | Любой пользователь Telegram; обрабатывается только из белого списка |
| Имя файла в Telegram | Поле `file_path` ответа Bot API | Telegram, а через него — отправитель |
| Содержимое аудио | Файл, скармливаемый `ffmpeg` и `ffprobe` | Отправитель по любому из каналов |
| Содержимое аудио | Файл, скармливаемый `ffmpeg` и `ffprobe` | Отправитель |
| Текст расшифровки | Поток gRPC от SpeechKit | Yandex, а через него — содержимое записи |
Что добавится вместе с целевым периметром — каждый вход появляется своей
@@ -82,9 +79,10 @@
## Куда уходит содержимое записи
Сегодня запись и её текст покидают наш сервер тремя путями: файл уезжает в
Yandex Object Storage, оттуда его читает SpeechKit, а текст возвращается в
Telegram отправителю.
Сегодня запись покидает наш сервер двумя путями: файл уезжает в Yandex Object
Storage, оттуда его читает SpeechKit. Третий путь — ответ в Telegram — исчез
2026-08-14 вместе с убранным входом: текст теперь достаётся только по опросу
готовности и в панели владельца.
Целевой периметр добавляет три пути, каждый — своей задачей:
@@ -156,10 +154,6 @@ Telegram отправителю.
## Что разграничивает доступ
- **Telegram** — белый список `[server] users_while_list`. Сверяется со строкой
автора сообщения (`update.Message.From.String()`, то есть `@username` либо имя
с фамилией), а не с числовым идентификатором. Имя пользователя Telegram
меняется владельцем в любой момент: список привязан к изменяемому значению.
- **HTTP API** — сессия, заведённая входом через OIDC у Authelia. Предъявляется
кукой `transcriber_session`, обесценивается выходом, срок жизни назначен числом
([database.md](database.md), «Настройки с числовым значением»).
@@ -203,9 +197,6 @@ Telegram отправителю.
| Личный токен | Права своего владельца программе, без браузерной сессии | `api-tokens` |
| Признак владельца сервиса | Страницу расхода и сводку по всем пользователям | `admin-stats-screen` |
Белый список Telegram при этом перестаёт быть отдельным механизмом: право
писать боту выводится из учётной записи (`telegram-account-link`).
Признак владельца сервиса — **второй уровень доступа**, которого в сегодняшней
модели нет вовсе: до него всё разграничение сводилось к «свой или чужой».
Откуда он берётся — из группы OIDC или из конфигурации — не решено
@@ -229,10 +220,10 @@ Telegram отправителю.
`record_events` (журнал событий, содержимого не несёт) и `topics` (словарь
тем человека). Всякая новая коллекция, куда содержимое переезжает, закрывается
наравне с записью — норму держит спека `storage`.
2. **Токен бота Telegram.** Даёт полный доступ к боту и к перепискам с ним.
3. **Ключи Yandex Cloud**`speech_kit_api_key` и пара ключей Object Storage.
2. **Ключи Yandex Cloud**`speech_kit_api_key` и пара ключей Object Storage.
Утечка оплачивается деньгами и доступом к бакету.
4. **Белый список пользователей**сам по себе перечень имён.
3. **Секрет клиента OIDC** — вместе с адресами провайдера открывает вход в
приложение от чужого имени.
Всё перечисленное лежит в `config.toml`. Файл в `.gitignore`, на сервер его
кладёт Ansible; `gitleaks` на pre-commit смотрит только индекс коммита.
@@ -288,30 +279,14 @@ Telegram отправителю.
приведения хвост читал бы кто угодно из интернета, а множеством значений метки
распоряжался бы анонимный отправитель.
Приём из Telegram имени, данного человеком, до сервиса не доводит: оттуда
приходит путь, выданный самим Telegram. Настоящее имя документа дальше проверки
типа файла не идёт.
Два пути утечки токена бота — адрес Bot API в отказе транспорта и отказ сборки
клиента — закрыты задачами `no-user-filename-in-log` и
`local-run-without-telegram-token` 2026-08-13 и потеряли предмет 2026-08-14
вместе с убранным входом: ни клиента, ни токена у сервиса больше нет. Разбор
случая остался в [review.md](review.md) — он про класс, а не про Telegram.
Токен бота стоит в пути **каждого** обращения к Bot API (`bot<TOKEN>/getFile`,
`…/sendMessage`, `…/getMe`, `…/getUpdates`) и в ссылке на скачивание
(`file.Link(token)`). Сами адреса нигде не логируются, но до 2026-08-13 их
уносил **отказ транспорта**: `*url.Error` встраивает адрес целиком, а отказы
скачивания и отправки пишутся в журнал. Теперь адрес на границе клиента снимает
свой `Do``internal/adapter/telegram`, `NewBot`: он чистит отказ, а
подменённый логгер библиотеки вычищает токен из строк длинного опроса, которые
она печатает сама. Транспорт бота токена больше не получает вовсе: клиента ему
отдают готовым. Правило — [conventions/logging.md](conventions/logging.md),
случай — [review.md](review.md), оракул — `internal/adapter/telegram/bot_test.go`.
Ещё один путь закрыт задачей `local-run-without-telegram-token` 2026-08-13, и до
неё он был открыт: токен, не разбирающийся как часть адреса (перенос строки из
шаблона выкладки, невычищенная `%`-последовательность), роняет сборку клиента
**раньше** обращения к нему — то есть мимо чистки на границе клиента. Отказ
конструктора теперь чистится отдельно. Нашло это ревью кода тремя проходами
независимо; оракул — там же, в `bot_test.go`.
Третий путь закрыт задачей `telegram-enabled-flag` 2026-08-13, и он **шире
токена бота**: до неё утечь мог любой секрет конфига. Отказ разбора файла
Путь, который остался, закрыт задачей `telegram-enabled-flag` 2026-08-13, и он
**шире всякого одного ключа**: до неё утечь мог любой секрет конфига. Отказ разбора файла
настроек пересказывался как есть, а библиотека разбора собирает текст отказа из
разбираемого куска — `toml.ParseError` кладёт в сообщение само значение. Строка
секретного ключа с оборванной кавычкой — типовая поломка криво собранного
+2 -4
View File
@@ -10,7 +10,6 @@ require (
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.18.3
github.com/aws/aws-sdk-go-v2/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
+8 -10
View File
@@ -74,8 +74,6 @@ github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre
github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
github.com/go-sql-driver/mysql v1.9.2 h1:4cNKDYQ1I84SXslGddlsrMhc8k4LeDVj6Ad6WRjiHuU=
github.com/go-sql-driver/mysql v1.9.2/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 h1:wG8n/XJQ07TmjbITcGiUaOtXxdrINDz1b0J1w0SzqDc=
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1/go.mod h1:A2S0CWkNylc2phvKXWBBdD3K0iGnDBGbzRpISP2zBl8=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
@@ -162,10 +160,10 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.44.0 h1:+tDekMZED9+LrtB3G5xzRggpVh9CARjZqROla3R3R+I=
golang.org/x/image v0.44.0/go.mod h1:V8K3KE9KKKE+pLpQDOeN18w9oacNSvy1tDOirTu4xtY=
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
golang.org/x/image v0.45.0 h1:FMb1nTbH5H9vF55SriQHgFw5GnNL9Jg6L25BwXKzhB0=
golang.org/x/image v0.45.0/go.mod h1:n62x/7RqlwXDvGsSU4u6IUTUf6KghUZ9Bt7cG/T9Fx4=
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
@@ -178,11 +176,11 @@ golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
@@ -0,0 +1,77 @@
package migrations
import (
"errors"
"fmt"
"github.com/pocketbase/pocketbase/core"
)
// up202608140003 запрещает пустого владельца у аудиозаписи и у её файла.
//
// Прежде пустое значение допускалось, и цену за это платили записи, принятые
// ботом: связи чата Telegram с учётной записью сервис не вёл, и владельца у них
// не было вовсе. Вход Telegram убран, заводить ничью запись стало некому, и
// обязательность переезжает из приёма в схему — туда, где её держит хранилище, а
// не договорённость. Разница не косметическая: пока обязательность жила в
// приёме, ничью запись заводили руками в панели, она уходила в конвейер, стоила
// денег на распознавание и не доставалась потом никому.
//
// Существующих строк шаг **не смотрит**, и это проверено прогоном: хранилище
// держит обязательность связи проверкой записи при сохранении, а не ограничением
// таблицы, поэтому смена признака на базе с ничьей записью проходит зелёным и
// такую запись оставляет. Искать ничьи строки надо до выкладки и запросом —
// `SELECT count(*) FROM audio_records WHERE owner = ”` и то же по `files`;
// прогон самого шага на копии этого не показывает.
//
// Оставленная ничья запись становится незакрываемой: захват идёт сырым запросом
// мимо проверки и выдаёт её воркеру, а всякое сохранение — включая то, которым
// ставится признак остановки, — отказывает. Порядок выкладки поэтому начинается
// с проверки данных, а не с прогона шага.
func up202608140003(app core.App) error {
for _, name := range []string{RecordsCollection, FilesCollection} {
if err := setOwnerRequired(app, name, true); err != nil {
return err
}
}
return nil
}
// down202608140003 возвращает колонке необязательность. Записей это не касается:
// пустых значений среди них нет, а появиться им теперь неоткуда.
func down202608140003(app core.App) error {
for _, name := range []string{RecordsCollection, FilesCollection} {
if err := setOwnerRequired(app, name, false); err != nil {
return err
}
}
return nil
}
// setOwnerRequired правит признак обязательности у колонки владельца одной
// коллекции. Колонка ищется по имени и приводится к типу связи: шаг, молча
// пропустивший чужой тип, оставил бы схему в состоянии, о котором никто не
// узнает.
func setOwnerRequired(app core.App, collectionName string, required bool) error {
collection, err := app.FindCollectionByNameOrId(collectionName)
if err != nil {
return fmt.Errorf("failed to find collection %s: %w", collectionName, err)
}
field := collection.Fields.GetByName("owner")
if field == nil {
return errors.New("collection " + collectionName + " has no owner field")
}
relation, ok := field.(*core.RelationField)
if !ok {
return errors.New("owner field of collection " + collectionName + " is not a relation")
}
relation.Required = required
if err := app.Save(collection); err != nil {
return fmt.Errorf("failed to change owner requirement in %s: %w", collectionName, err)
}
return nil
}
@@ -49,6 +49,7 @@ func init() {
pbmigrations.Register(up202608120001, down202608120001, "202608120001_oidc_login.go")
pbmigrations.Register(up202608140001, down202608140001, "202608140001_record_owner.go")
pbmigrations.Register(up202608140002, down202608140002, "202608140002_record_centric_model.go")
pbmigrations.Register(up202608140003, down202608140003, "202608140003_owner_required.go")
}
func ptr[T any](v T) *T { return &v }
+41 -11
View File
@@ -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)
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")
field := collection.Fields.GetByName("owner")
require.NotNil(t, field, "колонка владельца заведена")
relation, ok := field.(*core.RelationField)
require.True(t, ok, "владелец — связь с учётной записью, а не строка")
assert.False(t, relation.Required, "пустое значение допустимо ради записей бота")
assert.True(t, relation.Required, "пустое значение колонка не принимает")
assert.False(t, relation.CascadeDelete, "удаление учётной записи не уносит архив следом")
})
}
}
// Та же норма со стороны сохранения: схема отвергает запись без владельца, а не
// только объявляет колонку обязательной.
func TestStorageRefusesRecordWithoutOwner(t *testing.T) {
app := newTestStorage(t)
record := &entity.AudioRecord{
State: entity.StateUploaded,
StateEnteredAt: clock.Now(),
Source: entity.SourceApi,
}
require.Error(t, NewAudioRecordRepository(app).Create(record),
"ничья запись в хранилище не ложится")
}
// И файл — наравне с записью: разное правило у них читалось бы как недосмотр.
func TestStorageRefusesFileWithoutOwner(t *testing.T) {
app := newTestStorage(t)
repo := NewFileRepository(app)
work, err := repo.Stage(".mp3", strings.NewReader("запись"))
require.NoError(t, err)
defer func() { require.NoError(t, work.Close()) }()
_, err = repo.Create("sample.mp3", work, contract.FileMeta{Format: "mp3"}, "")
require.Error(t, err, "ничей файл в хранилище не ложится")
}
@@ -69,7 +69,7 @@ func updateByRequest(t *testing.T, app core.App, recordID string, body map[strin
func haltedRecord(t *testing.T, app core.App) *entity.AudioRecord {
t.Helper()
record := newRecordOf(t, app, "")
record := newRecordOf(t, app, newAccount(t, app).Id)
record.MoveToState(entity.StateNormalized)
record.Attempts = 4
record.AcquisitionID = ptrOf("прежний-захват")
@@ -143,7 +143,7 @@ func TestPanelResumeIsLogged(t *testing.T) {
func TestPanelStateEditClearsGuards(t *testing.T) {
app := newPanelStorage(t)
record := newRecordOf(t, app, "")
record := newRecordOf(t, app, newAccount(t, app).Id)
record.Attempts = 4
record.AcquisitionID = ptrOf("прежний-захват")
record.AcquireExpiresAt = ptrOf(clock.Now().Add(8 * time.Hour))
@@ -162,7 +162,7 @@ func TestPanelStateEditClearsGuards(t *testing.T) {
func TestPanelKeepsGuardsOnUnrelatedEdit(t *testing.T) {
app := newPanelStorage(t)
record := newRecordOf(t, app, "")
record := newRecordOf(t, app, newAccount(t, app).Id)
record.Attempts = 3
record.AcquisitionID = ptrOf("живой-захват")
require.NoError(t, NewAudioRecordRepository(app).Save(record, ""))
@@ -181,7 +181,7 @@ func TestAcquireCarriesStageDeadline(t *testing.T) {
app := newTestStorage(t)
repo := NewAudioRecordRepository(app)
record := newRecordOf(t, app, "")
record := newRecordOf(t, app, newAccount(t, app).Id)
acquired, err := repo.FindAndAcquire(entity.WorkingStages())
require.NoError(t, err)
@@ -205,7 +205,7 @@ func TestAcquireHandsRecordToExactlyOne(t *testing.T) {
app := newTestStorage(t)
repo := NewAudioRecordRepository(app)
newRecordOf(t, app, "")
newRecordOf(t, app, newAccount(t, app).Id)
first, err := repo.FindAndAcquire(entity.WorkingStages())
require.NoError(t, err, "первому запись досталась")
@@ -223,7 +223,7 @@ func TestRottenAcquisitionIsHandedOutAgain(t *testing.T) {
app := newTestStorage(t)
repo := NewAudioRecordRepository(app)
record := newRecordOf(t, app, "")
record := newRecordOf(t, app, newAccount(t, app).Id)
first, err := repo.FindAndAcquire(entity.WorkingStages())
require.NoError(t, err)
@@ -50,18 +50,16 @@ func applyToRecord(record *core.Record, r *entity.AudioRecord) {
// Владелец кладётся только здесь, при заведении. В applyOwnedByPipeline его
// нет намеренно: конвейер владельца не назначает и не меняет, а снимок шага,
// записанный поверх, стёр бы его молча.
record.Set("owner", derefString(r.OwnerID))
record.Set("owner", r.OwnerID)
record.Set("source", r.Source)
record.Set("title", derefString(r.Title))
record.Set("brief", derefString(r.Brief))
record.Set("tg_chat_id", derefInt64(r.TgChatId))
record.Set("tg_reply_message_id", derefInt(r.TgReplyMessageId))
}
func recordToAudioRecord(record *core.Record) *entity.AudioRecord {
return &entity.AudioRecord{
Id: record.Id,
OwnerID: nilIfEmpty(record.GetString("owner")),
OwnerID: record.GetString("owner"),
Source: record.GetString("source"),
Title: nilIfEmpty(record.GetString("title")),
Brief: nilIfEmpty(record.GetString("brief")),
@@ -80,8 +78,6 @@ func recordToAudioRecord(record *core.Record) *entity.AudioRecord {
LiteraryTextID: nilIfEmpty(record.GetString("literary_text")),
StructureID: nilIfEmpty(record.GetString("structure")),
RecognitionID: nilIfEmpty(record.GetString("recognition")),
TgChatId: nilIfZero64(int64(record.GetInt("tg_chat_id"))),
TgReplyMessageId: nilIfZeroInt(record.GetInt("tg_reply_message_id")),
CreatedAt: record.GetDateTime("created").Time(),
UpdatedAt: record.GetDateTime("updated").Time(),
}
@@ -94,20 +90,6 @@ func derefString(v *string) string {
return *v
}
func derefInt64(v *int64) int64 {
if v == nil {
return 0
}
return *v
}
func derefInt(v *int) int {
if v == nil {
return 0
}
return *v
}
// dateOrEmpty отдаёт пустое значение вместо нулевой даты: пустая колонка даты в
// хранилище это пустая строка, и она же значит «времени нет».
func dateOrEmpty(v *time.Time) any {
@@ -135,17 +117,3 @@ func timeOrNil(v types.DateTime) *time.Time {
t := v.Time()
return &t
}
func nilIfZero64(v int64) *int64 {
if v == 0 {
return nil
}
return &v
}
func nilIfZeroInt(v int) *int {
if v == 0 {
return nil
}
return &v
}
@@ -58,7 +58,7 @@ func (repo *AudioRecordRepository) Create(r *entity.AudioRecord) error {
// перевыданный другому — по протуханию срока или после того, как человек снял
// признак остановки в панели, — обязан обратить запись первого в отказ; условие
// по непустоте признака пропустило бы обоих, и два шага записали бы в одну
// запись и оба ответили бы отправителю.
// запись по очереди, портя её результат.
func (repo *AudioRecordRepository) Save(r *entity.AudioRecord, holder string) error {
return repo.app.RunInTransaction(func(txApp core.App) error {
record, err := txApp.FindRecordById(migrations.RecordsCollection, r.Id)
@@ -89,9 +89,10 @@ func (repo *AudioRecordRepository) Save(r *entity.AudioRecord, holder string) er
// по разнице ответов иначе перебирается список заведённых записей, а
// идентификатор записи и есть то, что разграничение прячет.
//
// Пустой ownerID отсекается **до** чтения и не совпадает ни с чем: иначе
// вызывающий без учётной записи получил бы ровно множество записей без
// владельца, то есть все записи бота.
// Пустой ownerID отсекается **до** чтения и не совпадает ни с чем. Правило это
// не стало избыточным с обязательностью колонки: схема запрещает **заводить**
// ничью запись, а здесь запрещено **спрашивать** ничьим именем — иначе
// вызывающий без учётной записи получил бы выборку вместо отказа.
func (repo *AudioRecordRepository) GetByID(id, ownerID string) (*entity.AudioRecord, error) {
if ownerID == "" {
return nil, &contract.JobNotFoundError{Message: "record not found"}
+20 -1
View File
@@ -26,6 +26,15 @@ func NewTextRepository(app core.App) *TextRepository {
// Замена, а не вставка: пара «запись и вид» уникальна, и повтор прерванного шага
// иначе завёл бы второй комплект строк — тогда вопрос «какой текст отдавать
// человеку» стал бы вопросом порядка записи, а не состояния.
//
// **Пустое не кладётся поверх непустого**, и это не осторожность, а защита
// архива. Повторный опрос той же операции — обычное дело: держатель захвата
// умер, сохранение рубежа отказало, человек снял остановку в панели. Провайдер
// при этом вправе ответить пустым потоком, отказом это не считается, и
// безусловная замена стирала бы сохранённую расшифровку живого человека без
// следа и без возврата. Та же защита стоит у сырого ответа провайдера
// (`RecognitionRepository.Finish`), и разное правило у двух хранителей одного
// результата читалось бы как недосмотр.
func (repo *TextRepository) Put(recordID, kind, contents string) (*entity.Text, error) {
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)
-27
View File
@@ -1,27 +0,0 @@
package telegram
import (
"git.vakhrushev.me/av/transcriber/internal/contract"
)
// AbsentMessageSender подставляется вместо отправителя Telegram, когда вход
// выключен признаком `telegram.enabled` либо Telegram оказался недоступен, и
// клиента заводить не из чего. Он ничего не отправляет и на всякий ответ отдаёт
// `contract.ErrDeliveryChannelDown`.
//
// Заглушка, а не пустой отправитель: необязательная зависимость, доехавшая до
// ядра нулём, роняет процесс на первой же задаче из Telegram, а проверка на
// месте употребления завела бы в ядре знание о том, как собран сервис.
//
// Молчит он намеренно. Записать недоставку заглушке нечем: контракт отправки
// несёт текст, чат и сообщение для ответа, а идентификатора задачи в нём нет.
// Пишет поэтому шаг конвейера, который задачу знает.
type AbsentMessageSender struct{}
func NewAbsentMessageSender() *AbsentMessageSender {
return &AbsentMessageSender{}
}
func (s *AbsentMessageSender) Send(_ string, _ int64, _ *int) error {
return contract.ErrDeliveryChannelDown
}
-37
View File
@@ -1,37 +0,0 @@
package telegram
import (
"log/slog"
"net/http"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"git.vakhrushev.me/av/transcriber/internal/contract"
)
// Заглушка отдаёт «канал не поднят» и молчит: записать недоставку ей нечем —
// идентификатора задачи контракт отправки не несёт, и пишет её шаг конвейера.
func TestAbsentSenderReportsChannelDown(t *testing.T) {
sender := NewAbsentMessageSender()
err := sender.Send("расшифровка записи", 100, nil)
require.ErrorIs(t, err, contract.ErrDeliveryChannelDown)
}
// Непустой годный токен по-прежнему даёт настоящего отправителя: прежний путь
// сохранён, и меняется только то, что клиента теперь отдают готовым.
func TestSenderIsBuiltFromLiveBot(t *testing.T) {
bot, _ := newProbeBot(t, func(w http.ResponseWriter, _ *http.Request) {
if _, err := w.Write([]byte(getMeResponse)); err != nil {
t.Errorf("подставной Telegram не смог ответить: %v", err)
}
})
sender := NewTelegramMessageSender(bot, slog.New(slog.DiscardHandler))
require.NotNil(t, sender)
assert.Same(t, bot, sender.bot, "отправитель говорит с тем же клиентом, что и транспорт")
}
-134
View File
@@ -1,134 +0,0 @@
package telegram
import (
"errors"
"fmt"
"log/slog"
"net/http"
"net/url"
"strings"
"time"
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
)
// ErrEmptyToken — ключ доступа пуст при включённом входе, то есть **ошибка
// настройки**: старт роняется. Отдельным значением, чтобы отличаться от
// недоступности Telegram, у которой исход обратный — подъём без бота.
//
// Отказ от входа Telegram этим значением больше не выражается: намерение
// объявляет признак включения `telegram.enabled`, и выключенный вход отсеивается
// до всякого обращения сюда. Пустой ключ ловит проверка настроек ещё раньше,
// поэтому сюда он доходит только в обход проверки.
var ErrEmptyToken = errors.New("telegram bot token is empty")
// NewBot заводит клиента Bot API — и это **единая точка**, через которую с
// библиотекой разговаривают оба пакета: адаптер отправки и транспорт бота.
//
// Точка нужна ради инварианта «секрет не покидает конфиг». Токен живёт в пути
// каждого обращения к Bot API (`https://api.telegram.org/bot<TOKEN>/getFile`),
// а `http.Client` кладёт адрес запроса в `*url.Error` целиком. Библиотека
// отдаёт этот отказ вызывающему как есть, поэтому чистка на месте употребления
// закрывает ровно один вызов из пяти: остаются `getFile`, `sendMessage`,
// `getMe` из конструктора и длинный опрос. Здесь закрыты все.
func NewBot(token string, logger *slog.Logger) (*tgbotapi.BotAPI, error) {
return newBot(token, tgbotapi.APIEndpoint, logger)
}
// newBot принимает адрес отдельно — иначе проверка утечки токена ходила бы за
// подтверждением в живой Telegram, а боевым токеном запускаться запрещено.
func newBot(token, endpoint string, logger *slog.Logger) (*tgbotapi.BotAPI, error) {
if token == "" {
return nil, ErrEmptyToken
}
// Длинный опрос живёт внутри библиотеки и печатает свой отказ пакетным
// логгером в stderr (`GetUpdatesChan`), минуя и наш `slog`, и чистку выше.
// Это самый частый путь: опрос идёт непрерывно, а скачивание — только когда
// кто-то прислал запись. Логгер пакетный, поэтому и подменяется один раз.
if err := tgbotapi.SetLogger(&redactingLogger{token: token, logger: logger}); err != nil {
return nil, fmt.Errorf("failed to set telegram logger: %w", err)
}
// Сборка ходит за `getMe` и стоит на пути старта — раньше HTTP-сервера,
// панели и воркеров. Без срока ожидания молчащий Telegram (соединение
// принято, ответа нет) вешал бы весь подъём бессрочно: порт не слушается,
// проба здоровья не отвечает, а в журнале ни строки.
probe := &safeClient{inner: &http.Client{Timeout: ProbeTimeout}}
// Отказ конструктора чистится здесь, а не клиентом: адрес собирается
// строкой с токеном внутри, и `http.NewRequest` падает на его разборе
// **до** обращения к клиенту — то есть мимо `safeClient`. Токен с
// управляющим символом или неверной `%`-последовательностью иначе уезжает
// в журнал целиком: перенос строки в конце значения ловится так же.
bot, err := tgbotapi.NewBotAPIWithClient(token, endpoint, probe)
if err != nil {
return nil, WithoutURL(err)
}
// Дальше живёт длинный опрос, и срок ему не нужен: он ждёт обновлений
// столько, сколько задано настройкой, и клиент со сроком рвал бы его.
bot.Client = &safeClient{inner: &http.Client{}}
return bot, nil
}
// ProbeTimeout — сколько ждём Telegram при сборке клиента. Число выбрано
// решением, а не замером: одно обращение за `getMe` укладывается в доли
// секунды, а десять секунд — потолок, после которого Telegram считается
// недоступным и сервис поднимается без него.
const ProbeTimeout = 10 * time.Second
// safeClient — клиент, чей отказ не несёт адреса. Библиотека объявляет
// зависимость интерфейсом `HTTPClient` и возвращает наш отказ вызывающему
// нетронутым, поэтому чистка отсюда доходит до каждого вызова Bot API.
type safeClient struct {
inner *http.Client
}
func (c *safeClient) Do(req *http.Request) (*http.Response, error) {
resp, err := c.inner.Do(req)
if err != nil {
return nil, WithoutURL(err)
}
return resp, nil
}
// WithoutURL снимает с отказа адрес запроса, сохраняя причину. Стандартный
// клиент кладёт в `*url.Error` полный URL, а в ссылке Telegram стоит токен
// бота: без этой чистки первый же сбой сети печатает секрет в журнал.
// Причина остаётся и узнаётся `errors.Is` по-прежнему.
func WithoutURL(err error) error {
var urlErr *url.Error
if errors.As(err, &urlErr) {
return urlErr.Err
}
return err
}
// redactingLogger отдаёт сообщения библиотеки нашему журналу, вычеркнув токен.
// Здесь чистится текст, а не ошибка: библиотека печатает уже отформатированную
// строку, и разбирать в ней `*url.Error` нечего. Замена точная — токен известен.
type redactingLogger struct {
token string
logger *slog.Logger
}
const redactedToken = "«токен»"
func (l *redactingLogger) Println(v ...any) {
l.write(strings.TrimSuffix(fmt.Sprintln(v...), "\n"))
}
func (l *redactingLogger) Printf(format string, v ...any) {
l.write(fmt.Sprintf(format, v...))
}
// write пишет на WARN: это сбой фонового цикла со штатным повтором, а не
// событие, требующее разбора (docs/conventions/logging.md, «Уровень —
// это адресат»). Сообщение нейтрально: тем же логгером библиотека печатает и
// отладку, если её включить, а разделить их она не даёт.
func (l *redactingLogger) write(message string) {
l.logger.Warn("Telegram library log",
"message", strings.ReplaceAll(message, l.token, redactedToken))
}
-172
View File
@@ -1,172 +0,0 @@
package telegram
import (
"bytes"
"errors"
"log/slog"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// Токен виден в пути каждого обращения к Bot API, а `http.Client` кладёт путь в
// `*url.Error` целиком. Проверки ниже судят по тексту: секрет не должен
// встречаться ни в отказе, ни в строке журнала. Утечка необратима — утёкший
// токен отзывают руками (CLAUDE.md, «Инварианты», critical).
const probeToken = "7654321:AAHsecretBOTtokenVALUE"
// getMeResponse — ответ, которым подставной Telegram пускает конструктор
// дальше: `NewBotAPIWithClient` ходит за `getMe` прежде, чем отдать клиента.
const getMeResponse = `{"ok":true,"result":{"id":1,"is_bot":true,"first_name":"probe","username":"probe_bot"}}`
func newProbeBot(t *testing.T, handler http.HandlerFunc) (*tgbotapi.BotAPI, *httptest.Server) {
t.Helper()
server := httptest.NewServer(handler)
t.Cleanup(server.Close)
bot, err := newBot(probeToken, server.URL+"/bot%s/%s", slog.New(slog.DiscardHandler))
require.NoError(t, err)
return bot, server
}
// Отказ транспорта на любом вызове Bot API не несёт токена: чистка стоит на
// границе клиента, а не у места употребления, поэтому закрыты все вызовы разом.
func TestBotAPIFailureDoesNotCarryToken(t *testing.T) {
bot, server := newProbeBot(t, func(w http.ResponseWriter, _ *http.Request) {
if _, err := w.Write([]byte(getMeResponse)); err != nil {
t.Errorf("подставной Telegram не смог ответить: %v", err)
}
})
// Собеседник исчез — так выглядит обрыв сети, DNS-сбой и недоступность
// api.telegram.org.
server.Close()
t.Run("getFile", func(t *testing.T) {
_, err := bot.GetFile(tgbotapi.FileConfig{FileID: "any"})
require.Error(t, err)
assert.NotContains(t, err.Error(), probeToken, "токен уехал в отказ: %v", err)
})
t.Run("sendMessage", func(t *testing.T) {
_, err := bot.Send(tgbotapi.NewMessage(1, "текст"))
require.Error(t, err)
assert.NotContains(t, err.Error(), probeToken, "токен уехал в отказ: %v", err)
})
}
// Токен, ломающий разбор адреса, — второй путь отказа конструктора, и до
// недавнего он был открыт: `http.NewRequest` падает раньше обращения к клиенту,
// то есть мимо чистки на его границе. Так выглядит перенос строки, приехавший
// с секретом из шаблона выкладки, и невычищенная `%`-последовательность.
func TestBotConstructionFailureOnUnparsableTokenDoesNotCarryToken(t *testing.T) {
broken := map[string]string{
"перенос строки": probeToken + "\n",
"негодная escape-пара": "7654321:AAH%zzSECRETtokenVALUE",
}
for name, token := range broken {
t.Run(name, func(t *testing.T) {
_, err := newBot(token, tgbotapi.APIEndpoint, slog.New(slog.DiscardHandler))
require.Error(t, err)
assert.NotContains(t, err.Error(), token, "токен уехал в отказ: %v", err)
assert.NotContains(t, err.Error(), "api.telegram.org", "адрес остался в отказе: %v", err)
})
}
}
// Отказ конструктора несёт тот же путь: `NewBotAPIWithClient` ходит за `getMe`,
// и контейнер, стартующий раньше сети, печатал бы токен в первую же секунду.
func TestBotConstructionFailureDoesNotCarryToken(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
server.Close()
_, err := newBot(probeToken, server.URL+"/bot%s/%s", slog.New(slog.DiscardHandler))
require.Error(t, err)
assert.NotContains(t, err.Error(), probeToken, "токен уехал в отказ конструктора: %v", err)
}
// Длинный опрос печатает свои отказы пакетным логгером самой библиотеки, минуя
// наш `slog`. Логгер подменён — значит, и эта строка идёт через вычистку.
func TestLibraryLoggerRedactsToken(t *testing.T) {
journal := &bytes.Buffer{}
logger := slog.New(slog.NewTextHandler(journal, nil))
redacting := &redactingLogger{token: probeToken, logger: logger}
redacting.Println(errors.New(`Post "https://api.telegram.org/bot` + probeToken + `/getUpdates": dial tcp: refused`))
redacting.Printf("Failed to get updates from %s", "https://api.telegram.org/bot"+probeToken+"/getUpdates")
written := journal.String()
assert.NotContains(t, written, probeToken, "токен уехал в журнал: %s", written)
assert.Equal(t, 2, strings.Count(written, redactedToken), "вместо токена стоит пометка")
assert.Contains(t, written, "dial tcp", "причина отказа осталась")
}
// Пустой токен — законный исход подъёма без Telegram, и узнаётся он по смыслу.
// Обратное тоже нормируется: отказ негодного токена не должен читаться как
// отказ от входа, иначе сборка при старте подставит заглушку там, где нужен
// отказ, и молча потеряет бота.
func TestEmptyTokenIsRecognizedByValue(t *testing.T) {
_, err := NewBot("", slog.New(slog.DiscardHandler))
require.ErrorIs(t, err, ErrEmptyToken)
server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
server.Close()
_, err = newBot(probeToken, server.URL+"/bot%s/%s", slog.New(slog.DiscardHandler))
require.Error(t, err)
require.NotErrorIs(t, err, ErrEmptyToken)
}
// WithoutURL снимает адрес, но не причину: `errors.Is` по цепочке продолжает
// работать, иначе чистка стоила бы узнаваемости отказа.
func TestWithoutURLKeepsCause(t *testing.T) {
cause := errors.New("dial tcp: connection refused")
wrapped := &url.Error{Op: "Post", URL: "https://api.telegram.org/bot" + probeToken + "/getMe", Err: cause}
cleaned := WithoutURL(wrapped)
assert.NotContains(t, cleaned.Error(), probeToken)
require.ErrorIs(t, cleaned, cause)
assert.Equal(t, cause, WithoutURL(cause), "отказ без адреса не трогают")
}
// Стык, которого не сторожил никто: подмена пакетного логгера держится одной
// строкой в `NewBot`, а снятие этой строки не роняло ни одной проверки. Оракул
// косвенный по необходимости — библиотека не отдаёт установленный логгер
// обратно, — поэтому он смотрит на исход: её собственная строка обязана
// оказаться в нашем журнале.
func TestLibraryLoggerIsActuallyInstalled(t *testing.T) {
journal := &bytes.Buffer{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
if _, err := w.Write([]byte(getMeResponse)); err != nil {
t.Errorf("подставной Telegram не смог ответить: %v", err)
}
}))
t.Cleanup(server.Close)
bot, err := newBot(probeToken, server.URL+"/bot%s/%s", slog.New(slog.NewTextHandler(journal, nil)))
require.NoError(t, err)
// Отладку библиотека печатает тем же логгером, что и отказы: включаем её,
// чтобы строка появилась без обрыва сети.
bot.Debug = true
_, err = bot.GetMe()
require.NoError(t, err)
assert.Contains(t, journal.String(), "Telegram library log",
"строка библиотеки прошла мимо нашего журнала: логгер не подменён")
}
-66
View File
@@ -1,66 +0,0 @@
package telegram
import (
"log/slog"
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
)
const (
TextLengthLimit = 4000
)
type TelegramMessageSender struct {
bot *tgbotapi.BotAPI
logger *slog.Logger
}
// NewTelegramMessageSender принимает готового клиента, а не токен. Клиента
// заводит сборка при старте — одного на отправителя и на транспорт бота: пока
// его строили здесь и там порознь, два пути одного старта разошлись в том,
// терпеть ли негодный токен, и согласовывать их приходилось руками.
func NewTelegramMessageSender(bot *tgbotapi.BotAPI, logger *slog.Logger) *TelegramMessageSender {
return &TelegramMessageSender{
bot: bot,
logger: logger,
}
}
func (s *TelegramMessageSender) Send(text string, chatId int64, replyToMessageId *int) error {
// If message is short enough, send it directly
if len([]rune(text)) <= TextLengthLimit {
return s.sendSingleMessage(text, chatId, replyToMessageId)
}
// Split long message into parts
parts := s.splitMessageByWords(text, TextLengthLimit)
// Send each part
for i, part := range parts {
var replyId *int
// Only use replyToMessageId for the first part
if i == 0 {
replyId = replyToMessageId
}
err := s.sendSingleMessage(part, chatId, replyId)
if err != nil {
return err
}
}
return nil
}
// sendSingleMessage sends a single message
func (s *TelegramMessageSender) sendSingleMessage(text string, chatId int64, replyToMessageId *int) error {
resultMsg := tgbotapi.NewMessage(chatId, text)
if replyToMessageId != nil {
resultMsg.ReplyToMessageID = *replyToMessageId
}
_, err := s.bot.Send(resultMsg)
if err != nil {
s.logger.Error("Failed to send message to tg bot", "error", err)
return err
}
return nil
}
-62
View File
@@ -1,62 +0,0 @@
package telegram
// splitMessageByWords splits a message into parts of maxLen UTF-8 characters
// splitting by words to avoid cutting words in the middle
func (s *TelegramMessageSender) splitMessageByWords(text string, maxLen int) []string {
var parts []string
// If text is already short enough, return as is
if len([]rune(text)) <= maxLen {
return []string{text}
}
runes := []rune(text)
for len(runes) > 0 {
// Determine the end position for this part
end := len(runes)
if end > maxLen {
end = maxLen
}
// Try to find a good split point (word boundary)
splitPoint := end
for i := end - 1; i > end-20 && i > 0; i-- { // Look back up to 20 characters
// Check if this is a good split point (after a space)
if runes[i] == ' ' {
splitPoint = i + 1 // Include the space in the previous part
break
}
}
// If we couldn't find a good split point, just split at maxLen
if splitPoint == end && end == maxLen {
// Check if we're in the middle of a word
if end < len(runes) && runes[end] != ' ' && runes[end-1] != ' ' {
// Try to find a split point going forward
for i := end; i < len(runes) && i < end+20; i++ {
if runes[i] == ' ' {
splitPoint = i
break
}
}
}
}
// If still no good split point, use the original end
if splitPoint > len(runes) {
splitPoint = len(runes)
}
// Add this part
parts = append(parts, string(runes[:splitPoint]))
// Move to the next part
if splitPoint >= len(runes) {
break
}
runes = runes[splitPoint:]
}
return parts
}
-125
View File
@@ -1,125 +0,0 @@
package telegram
import (
"testing"
)
func TestTelegramMessageSender_splitMessageByWords(t *testing.T) {
sender := &TelegramMessageSender{}
tests := []struct {
name string
text string
maxLen int
expected []string
}{
{
name: "Short text should return as is",
text: "Привет мир",
maxLen: 25,
expected: []string{
"Привет мир",
},
},
{
name: "Text exactly at limit",
text: "Это тестовый текст который ровно соответствует лимиту",
maxLen: 35,
expected: []string{
"Это тестовый текст который ровно ",
"соответствует ",
"лимиту",
},
},
{
name: "Text with word boundaries",
text: "Это очень длинный текст для проверки работы функции разделения сообщения",
maxLen: 25,
expected: []string{
"Это очень длинный текст ",
"для проверки работы ",
"функции разделения ",
"сообщения",
},
},
{
name: "Text with long words",
text: "Этот текст содержит оченьдлинноеслово которое не должно быть разбито",
maxLen: 20,
expected: []string{
"Этот текст содержит ",
"оченьдлинноеслово ",
"которое не должно ",
"быть ",
"разбито",
},
},
{
name: "Text with multiple spaces",
text: "Этот текст имеет много пробелов",
maxLen: 20,
expected: []string{
"Этот текст ",
"имеет много ",
"пробелов",
},
},
{
name: "Text with Russian characters and punctuation",
text: "Привет! Как дела? Это тестовая строка для проверки работы функции.",
maxLen: 25,
expected: []string{
"Привет! Как дела? Это ",
"тестовая строка для ",
"проверки работы ",
"функции.",
},
},
{
name: "Single word longer than maxLen",
text: "Некотороедлинноеслово",
maxLen: 10,
expected: []string{
"Некотороед",
"линноеслов",
"о",
},
},
{
name: "Text with mixed Russian and English",
text: "Привет Hello мир World текст для проверки",
maxLen: 20,
expected: []string{
"Привет Hello мир ",
"World текст для ",
"проверки",
},
},
{
name: "Text with special characters",
text: "Тест с символами: @#$%^&*()_+-=[]{}|;':\",./<>?",
maxLen: 25,
expected: []string{
"Тест с символами: ",
"@#$%^&*()_+-=[]{}|;':\",./",
"<>?",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := sender.splitMessageByWords(tt.text, tt.maxLen)
if len(result) != len(tt.expected) {
t.Errorf("splitMessageByWords() length = %d, want %d", len(result), len(tt.expected))
return
}
for i, expectedPart := range tt.expected {
if result[i] != expectedPart {
t.Errorf("splitMessageByWords() part %d = %q, want %q", i, result[i], expectedPart)
}
}
})
}
}
+1 -2
View File
@@ -28,7 +28,7 @@ const (
)
// Ядро — `internal/service`: оно знает только интерфейсы `internal/contract`, а
// 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,
}
+1 -44
View File
@@ -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"`
}
@@ -64,7 +63,6 @@ type ServerConfig struct {
Port int `toml:"port"`
ShutdownTimeout int `toml:"shutdown_timeout"`
ForceShutdownTimeout int `toml:"force_shutdown_timeout"`
UsersWhiteList []string `toml:"users_while_list"`
}
// StorageConfig — единственный каталог данных: под ним лежат и база, и файлы
@@ -83,33 +81,6 @@ type YandexConfig struct {
ObjStorageEndpoint string `toml:"object_storage_endpoint"`
}
// TelegramConfig — вход Telegram. Признак включения объявляет намерение
// владельца, `BotToken` означает только доступ. Пока два значения жили в одном
// поле, пустой токен читался разом как «вход выключен» и как «ключ не доехал»,
// и сервис поднимался без бота в обоих случаях.
type TelegramConfig struct {
// Enabled — умолчания у него нет **намеренно**, и потому его нет в
// `defaultConfig()`: умолчание было бы угаданным намерением, а признак
// заведён затем, чтобы намерение объявляли. Отсутствие ключа в файле ловит
// `LoadConfig` — нулевое значение `bool` режима не выбирает.
Enabled bool `toml:"enabled"`
BotToken string `toml:"bot_token"`
UpdateTimeout int `toml:"update_timeout"`
}
// Validate проверяет ключ доступа против объявленного намерения. Пустой ключ
// при включённом входе — ошибка настройки: бот по нему не появится, а тихий
// подъём без бота оставил бы отправителей без ответов.
//
// Названо имя ключа, а не значение: значение `bot_token` в журнал попасть не
// должно.
func (c TelegramConfig) Validate() error {
if c.Enabled && c.BotToken == "" {
return errors.New("telegram: не заполнен ключ bot_token при enabled = true")
}
return nil
}
// AuthConfig — вход через внешнего провайдера OIDC. Адреса, идентификатор
// клиента и секрет приезжают сюда и приводятся к настройкам коллекции
// пользователей при каждом подъёме: применённый шаг схемы не переписывается, и
@@ -202,11 +173,6 @@ func defaultConfig() *Config {
ObjStorageRegion: "ru-central1",
ObjStorageEndpoint: "https://storage.yandexcloud.net/",
},
// Умолчания у `Enabled` здесь нет намеренно — причина у поля.
Telegram: TelegramConfig{
BotToken: "",
UpdateTimeout: 10,
},
Auth: AuthConfig{
SecureCookie: true,
},
@@ -223,19 +189,10 @@ func LoadConfig(path string) (*Config, error) {
config := defaultConfig()
// Load configuration from file
meta, err := toml.DecodeFile(path, &config)
if err != nil {
if _, err := toml.DecodeFile(path, &config); err != nil {
return nil, decodeError(path, err)
}
// Признак включения входа Telegram обязателен: умолчания у него нет, и
// отличить «не задан» от «задан ложным» умеет только разбор — нулевое
// значение `bool` в структуре у обоих одинаковое. Отсюда и `meta`: наружу
// она не отдаётся, приговор выносится здесь.
if !meta.IsDefined("telegram", "enabled") {
return nil, errors.New("telegram: не задан ключ enabled; он объявляет, нужен ли сервису вход Telegram")
}
return config, nil
}
+12 -107
View File
@@ -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 {
-4
View File
@@ -58,7 +58,3 @@ type AudioRecognizer interface {
// обращаясь к нему. По нему архив пересчитывается без единого рубля.
Parse(raw []byte) (*entity.RecognitionOutcome, error)
}
type TelegramMessageSender interface {
Send(text string, chatId int64, replyToMessageId *int) error
}
+3 -12
View File
@@ -5,15 +5,6 @@ import (
"fmt"
)
// 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
}
+5 -5
View File
@@ -44,11 +44,11 @@ type FileRepository interface {
// файле. Имя задаёт сервис: умолчание хранилища, строящее его из имени
// отправителя, не применяется.
//
// ownerID — владелец записи, которой файл принадлежит; пустой значит «файл
// без владельца», и таков всякий файл записи, принятой ботом. Владелец
// лежит своей колонкой, а не выводится через запись: файл переживает свою
// запись — шаг заводит его до сохранения, и потерянный захват оставляет файл
// с владельцем и без ссылки.
// ownerID — владелец записи, которой файл принадлежит, и он обязателен:
// колонка владельца пустого значения не принимает, пустой отвергается
// схемой. Владелец лежит своей колонкой, а не выводится через запись: файл
// переживает свою запись — шаг заводит его до сохранения, и потерянный
// захват оставляет файл с владельцем и без ссылки.
Create(name string, work WorkFile, meta FileMeta, ownerID string) (*entity.File, error)
GetByID(id string) (*entity.File, error)
// Open отдаёт содержимое хранимого файла потоком.
@@ -4,17 +4,13 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/pocketbase/pocketbase/core"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
pbrepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase"
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
"git.vakhrushev.me/av/transcriber/internal/clock"
"git.vakhrushev.me/av/transcriber/internal/contract"
"git.vakhrushev.me/av/transcriber/internal/entity"
)
@@ -81,33 +77,6 @@ func TestGetTranscribeJobStatus_ForeignJobLooksMissing(t *testing.T) {
assert.NotContains(t, foreign.Body.String(), "transcription_text")
}
// Задача, принятая ботом, владельца не имеет и не достаётся по API никому.
func TestGetTranscribeJobStatus_OwnerlessJobLooksMissing(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer())
fileRepo := pbrepo.NewFileRepository(env.app)
work, err := fileRepo.Stage(".ogg", strings.NewReader("запись"))
require.NoError(t, err)
defer func() { require.NoError(t, work.Close()) }()
// Файл записи из Telegram владельца тоже не имеет.
file, err := fileRepo.Create("voice.ogg", work, contract.FileMeta{Format: "ogg"}, "")
require.NoError(t, err)
job := &entity.AudioRecord{
State: entity.StateUploaded,
StateEnteredAt: clock.Now(),
Source: entity.SourceTelegram,
OriginalFileID: &file.Id,
}
require.NoError(t, env.handler.recordRepo.Create(job))
w := httptest.NewRecorder()
env.serve(w, httptest.NewRequest("GET", "/api/status/"+job.Id, http.NoBody))
assert.Equal(t, http.StatusNotFound, w.Code)
}
// Владельцем принятой записи становится предъявитель сессии — и у задачи, и у
// её файла.
func TestCreateTranscribeJob_OwnerIsSession(t *testing.T) {
+1 -9
View File
@@ -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))
-111
View File
@@ -1,111 +0,0 @@
package tg
import (
"io"
"log/slog"
"net/http"
"strings"
"testing"
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// probeClient подменяет клиента бота и запоминает, кого спрашивали. Через него
// проверяется стык: скачивание обязано идти клиентом бота, а не общим
// `http.DefaultClient` — чистку отказа от адреса с токеном несёт именно клиент
// (`internal/adapter/telegram`). Подмена на общий клиент правил гейта не
// нарушает, поэтому сторожить стык может только проверка.
type probeClient struct {
seen []string
download func(w *probeResponse)
}
type probeResponse struct {
status int
body string
}
func (c *probeClient) Do(req *http.Request) (*http.Response, error) {
c.seen = append(c.seen, req.URL.Path)
switch {
case strings.Contains(req.URL.Path, "/getMe"):
return jsonResponse(`{"ok":true,"result":{"id":1,"is_bot":true,"username":"probe_bot"}}`), nil
case strings.Contains(req.URL.Path, "/getFile"):
return jsonResponse(`{"ok":true,"result":{"file_id":"x","file_path":"voice/file_1.ogg"}}`), nil
}
answer := &probeResponse{status: http.StatusOK, body: "аудио"}
if c.download != nil {
c.download(answer)
}
return &http.Response{
StatusCode: answer.status,
Body: io.NopCloser(strings.NewReader(answer.body)),
Header: make(http.Header),
}, nil
}
func jsonResponse(body string) *http.Response {
header := make(http.Header)
header.Set("Content-Type", "application/json")
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader(body)),
Header: header,
}
}
func newProbeController(t *testing.T, client *probeClient) *TelegramController {
t.Helper()
// Клиент подставной, поэтому адрес значения не имеет — важно лишь, что
// библиотека соберёт из него разбираемый URL.
bot, err := tgbotapi.NewBotAPIWithClient(
"7654321:AAHsecretBOTtokenVALUE",
"http://telegram.probe/bot%s/%s",
client,
)
require.NoError(t, err)
return &TelegramController{
bot: bot,
logger: slog.New(slog.DiscardHandler),
}
}
// Скачивание идёт клиентом бота: иначе отказ пойдёт мимо чистки и унесёт токен.
func TestDownloadGoesThroughBotClient(t *testing.T) {
client := &probeClient{}
controller := newProbeController(t, client)
body, name, err := controller.downloadAudioFile(t.Context(), "file-id")
require.NoError(t, err)
t.Cleanup(func() {
if err := body.Close(); err != nil {
t.Errorf("не удалось закрыть тело: %v", err)
}
})
assert.Equal(t, "voice/file_1.ogg", name)
require.Len(t, client.seen, 3, "клиент бота видел все обращения: getMe, getFile и скачивание")
assert.Contains(t, client.seen[2], "voice/file_1.ogg", "скачивание ушло мимо клиента бота")
}
// Отказ выдачи файла — это не запись: тело такого ответа не должно доехать до
// хранилища и умереть на `ffprobe`, уведя диагностику к чужой причине.
func TestDownloadRejectsNonOKStatus(t *testing.T) {
client := &probeClient{download: func(w *probeResponse) {
w.status = http.StatusUnauthorized
w.body = `{"ok":false,"error_code":401,"description":"Unauthorized"}`
}}
controller := newProbeController(t, client)
body, _, err := controller.downloadAudioFile(t.Context(), "file-id")
require.Error(t, err)
assert.Nil(t, body, "тело отказа наружу не отдают")
assert.Contains(t, err.Error(), "401", "код ответа назван — по нему видно, что отказал Telegram")
}
-326
View File
@@ -1,326 +0,0 @@
package tg
import (
"context"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"slices"
"strings"
// Транспорт знает адаптер Telegram ровно ради единой точки чистки отказа:
// второй экземпляр той же функции здесь был бы вторым способом делать одно
// и то же, а секрет в журнале — необратим. Направление «транспорт не знает
// адаптера» правилом не держится и уже нарушено HTTP-поверхностью
// (docs/conventions/go-linters.md, «Что остаётся прозой»).
"git.vakhrushev.me/av/transcriber/internal/adapter/telegram"
"git.vakhrushev.me/av/transcriber/internal/service"
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
)
type TelegramController struct {
// deps
transcribeService *service.TranscribeService
logger *slog.Logger
// params
bot *tgbotapi.BotAPI
userWhiteList []string
updateTimeout int
}
type TelegramConfig struct {
UpdateTimeout int
UserWhiteList []string
}
// NewTelegramController принимает готового клиента, а не токен: клиента заводит
// единая точка `internal/adapter/telegram`, и только её отказ не несёт секрета.
// Токен сюда не приезжает вовсе — значит, и утечь отсюда ему неоткуда.
func NewTelegramController(
config TelegramConfig,
bot *tgbotapi.BotAPI,
transcribeService *service.TranscribeService,
logger *slog.Logger,
) (*TelegramController, error) {
if bot == nil {
return nil, errors.New("telegram bot is not created")
}
controller := &TelegramController{
bot: bot,
transcribeService: transcribeService,
logger: logger,
updateTimeout: config.UpdateTimeout,
userWhiteList: config.UserWhiteList,
}
return controller, nil
}
// Start принимает контекст жизни процесса и отдаёт его каждому обработчику:
// скачивание записи и разбор её метаданных — работа с внешним собеседником, и
// остановка сервиса обязана до неё доходить. Приём обновлений контекстом не
// правится: его прекращает Stop.
func (c *TelegramController) Start(ctx context.Context) {
c.logger.Info("Telegram bot started", "username", c.bot.Self.UserName)
u := tgbotapi.NewUpdate(0)
u.Timeout = c.updateTimeout
updates := c.bot.GetUpdatesChan(u)
for update := range updates {
if update.Message == nil { // ignore any non-Message updates
continue
}
author := update.Message.From.String()
c.logger.Info("New incoming message", "author", author)
if !slices.Contains(c.userWhiteList, author) {
c.logger.Info("User is not in white list, reject", "author", author)
c.handleForbiddenUser(update.Message)
continue
}
// Handle commands
if update.Message.IsCommand() {
// Extract the command from the Message
switch update.Message.Command() {
case "start":
c.handleStartCommand(update.Message)
case "help":
c.handleHelpCommand(update.Message)
}
continue
}
// Handle audio messages and files
if update.Message.Audio != nil {
c.handleAudioMessage(ctx, update.Message)
} else if update.Message.Voice != nil {
c.handleVoiceMessage(ctx, update.Message)
} else if update.Message.Document != nil {
c.handleDocumentMessage(ctx, update.Message)
}
}
}
func (c *TelegramController) Stop() {
c.bot.StopReceivingUpdates()
}
func (c *TelegramController) send(chattable tgbotapi.Chattable) (tgbotapi.Message, error) {
msg, err := c.bot.Send(chattable)
if err != nil {
c.logger.Error("Failed to send message to tg bot", "error", err)
}
return msg, err
}
func (c *TelegramController) handleStartCommand(message *tgbotapi.Message) {
msg := tgbotapi.NewMessage(message.Chat.ID, "Привет! Я бот для расшифровки аудиосообщений. Отправь мне голосовое сообщение или аудиофайл, и я пришлю тебе текст.")
msg.ReplyToMessageID = message.MessageID
c.send(msg)
}
func (c *TelegramController) handleForbiddenUser(message *tgbotapi.Message) {
msg := tgbotapi.NewMessage(message.Chat.ID, "Извини, тебе нельзя пользоваться этим ботом. Обратись к владельцу бота.")
msg.ReplyToMessageID = message.MessageID
c.send(msg)
}
func (c *TelegramController) handleHelpCommand(message *tgbotapi.Message) {
helpText := `Я бот для расшифровки аудиосообщений и аудиофайлов.
Просто отправь мне:
- Голосовое сообщение
- Аудиофайл (mp3, wav, ogg и др.)
Я пришлю тебе текст расшифровки.
Команды:
/start - Начало работы с ботом
/help - Показать эту справку`
msg := tgbotapi.NewMessage(message.Chat.ID, helpText)
msg.ReplyToMessageID = message.MessageID
c.send(msg)
}
func (c *TelegramController) handleAudioMessage(ctx context.Context, message *tgbotapi.Message) {
// Отправляем сообщение о начале обработки
progressMsg := tgbotapi.NewMessage(message.Chat.ID, "Обрабатываю аудиофайл...")
progressMsg.ReplyToMessageID = message.MessageID
sentProgressMsg, err := c.send(progressMsg)
if err != nil {
c.logger.Error("Failed to send progress message", "error", err)
return
}
// Скачиваем файл
fileReader, fileName, err := c.downloadAudioFile(ctx, message.Audio.FileID)
if err != nil {
c.logger.Error("Failed to download audio file", "error", err)
errorMsg := tgbotapi.NewMessage(message.Chat.ID, "Ошибка при скачивании аудиофайла. Попробуйте еще раз.")
c.send(errorMsg)
return
}
defer fileReader.Close()
// Обрабатываем файл
record, err := c.transcribeService.CreateJobFromTelegram(ctx, fileReader, fileName, message.Chat.ID, sentProgressMsg.MessageID)
if err != nil {
c.logger.Error("Failed to create audio record", "error", err)
errorMsg := tgbotapi.NewMessage(message.Chat.ID, "Ошибка при создании задачи на расшифровку. Попробуйте еще раз.")
c.send(errorMsg)
return
}
// Отправляем сообщение об успешном создании задачи
successMsg := tgbotapi.NewMessage(message.Chat.ID, fmt.Sprintf("Задача на расшифровку создана. ID задачи: %s", record.Id))
successMsg.ReplyToMessageID = message.MessageID
c.send(successMsg)
}
func (c *TelegramController) handleVoiceMessage(ctx context.Context, message *tgbotapi.Message) {
// Отправляем сообщение о начале обработки
progressMsg := tgbotapi.NewMessage(message.Chat.ID, "Обрабатываю голосовое сообщение...")
progressMsg.ReplyToMessageID = message.MessageID
sentProgressMsg, err := c.send(progressMsg)
if err != nil {
c.logger.Error("Failed to send progress message", "error", err)
return
}
// Скачиваем файл
fileReader, fileName, err := c.downloadAudioFile(ctx, message.Voice.FileID)
if err != nil {
c.logger.Error("Failed to download voice file", "error", err)
errorMsg := tgbotapi.NewMessage(message.Chat.ID, "Ошибка при скачивании голосового сообщения. Попробуйте еще раз.")
c.send(errorMsg)
return
}
defer fileReader.Close()
// Обрабатываем файл
record, err := c.transcribeService.CreateJobFromTelegram(ctx, fileReader, fileName, message.Chat.ID, sentProgressMsg.MessageID)
if err != nil {
c.logger.Error("Failed to create audio record", "error", err)
errorMsg := tgbotapi.NewMessage(message.Chat.ID, "Ошибка при создании задачи на расшифровку. Попробуйте еще раз.")
c.send(errorMsg)
return
}
// Отправляем сообщение об успешном создании задачи
successMsg := tgbotapi.NewMessage(message.Chat.ID, fmt.Sprintf("Задача на расшифровку создана. ID задачи: %s", record.Id))
successMsg.ReplyToMessageID = message.MessageID
c.send(successMsg)
}
func (c *TelegramController) handleDocumentMessage(ctx context.Context, message *tgbotapi.Message) {
// Проверяем, является ли документ аудиофайлом
if !c.isAudioDocument(message.Document) {
return
}
// Отправляем сообщение о начале обработки
progressMsg := tgbotapi.NewMessage(message.Chat.ID, "Обрабатываю аудиофайл...")
progressMsg.ReplyToMessageID = message.MessageID
sentProgressMsg, err := c.send(progressMsg)
if err != nil {
c.logger.Error("Failed to send progress message", "error", err)
return
}
// Скачиваем файл
fileReader, fileName, err := c.downloadAudioFile(ctx, message.Document.FileID)
if err != nil {
c.logger.Error("Failed to download document file", "error", err)
errorMsg := tgbotapi.NewMessage(message.Chat.ID, "Ошибка при скачивании аудиофайла. Попробуйте еще раз.")
c.send(errorMsg)
return
}
defer fileReader.Close()
// Обрабатываем файл
record, err := c.transcribeService.CreateJobFromTelegram(ctx, fileReader, fileName, message.Chat.ID, sentProgressMsg.MessageID)
if err != nil {
c.logger.Error("Failed to create audio record", "error", err)
errorMsg := tgbotapi.NewMessage(message.Chat.ID, "Ошибка при создании задачи на расшифровку. Попробуйте еще раз.")
c.send(errorMsg)
return
}
// Отправляем сообщение об успешном создании задачи
successMsg := tgbotapi.NewMessage(message.Chat.ID, fmt.Sprintf("Задача на расшифровку создана. ID задачи: %s", record.Id))
successMsg.ReplyToMessageID = message.MessageID
c.send(successMsg)
}
func (c *TelegramController) downloadAudioFile(ctx context.Context, fileID string) (io.ReadCloser, string, error) {
// Получаем информацию о файле
file, err := c.bot.GetFile(tgbotapi.FileConfig{FileID: fileID})
if err != nil {
return nil, "", fmt.Errorf("failed to get file info: %w", err)
}
// Скачиваем файл. Запрос заводится с контекстом: скачивание шестичасовой
// записи иначе продолжается и после остановки сервиса, а ссылка на файл
// несёт токен бота — держать её живой дольше нужного незачем.
//
// Клиент берётся у бота, а не `http.DefaultClient`: у бота он свой, и его
// отказ уже не несёт адреса (`internal/adapter/telegram`, единая точка).
fileURL := file.Link(c.bot.Token)
request, err := http.NewRequestWithContext(ctx, http.MethodGet, fileURL, nil)
if err != nil {
return nil, "", fmt.Errorf("failed to build download request: %w", telegram.WithoutURL(err))
}
resp, err := c.bot.Client.Do(request)
if err != nil {
return nil, "", fmt.Errorf("failed to download file: %w", err)
}
// Отказ выдачи файла — это не запись. Без проверки телом «записи» станет
// JSON вида `{"ok":false,…}`: он доедет до хранилища, ляжет рабочей копией
// и умрёт на `ffprobe`, а отправитель получит жалобу на свой файл вместо
// правды о протухшей ссылке.
if resp.StatusCode != http.StatusOK {
if err := resp.Body.Close(); err != nil {
c.logger.Error("Failed to close download response", "error", err)
}
return nil, "", fmt.Errorf("failed to download file: unexpected status %d", resp.StatusCode)
}
// Получаем имя файла из URL
fileName := file.FilePath
if fileName == "" {
fileName = "audio.ogg"
}
return resp.Body, fileName, nil
}
func (c *TelegramController) isAudioDocument(document *tgbotapi.Document) bool {
// Проверяем MIME-тип документа
if document.MimeType != "" {
return strings.HasPrefix(document.MimeType, "audio/") || strings.HasPrefix(document.MimeType, "video/")
}
// Проверяем расширение файла
audioExtensions := []string{".mp3", ".wav", ".ogg", ".flac", ".m4a", ".aac", ".wma"}
filename := document.FileName
for _, ext := range audioExtensions {
if len(filename) >= len(ext) && strings.ToLower(filename[len(filename)-len(ext):]) == ext {
return true
}
}
return false
}
+8 -7
View File
@@ -36,6 +36,9 @@ const (
const (
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
}
+5 -7
View File
@@ -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": {},
+5 -14
View File
@@ -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{
+1 -1
View File
@@ -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),
)
+2 -2
View File
@@ -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,
+10 -18
View File
@@ -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, "пустой владелец не совпадает ни с чем")
+74 -52
View File
@@ -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)
+90 -3
View File
@@ -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,
"пустой ответ провайдера не стирает сохранённую расшифровку")
}
+2 -3
View File
@@ -37,7 +37,7 @@ func TestShutdownDuringConversionKeepsRecordRetryable(t *testing.T) {
converter := &killedConverter{cancel: cancel}
env := newPipelineEnv(t, &okMetaViewer{}, converter)
record := newTelegramRecord(t, env)
record := newRecord(t, env)
err := env.service.RunStep(ctx)
@@ -51,14 +51,13 @@ func TestShutdownDuringConversionKeepsRecordRetryable(t *testing.T) {
assert.Nil(t, after.AcquisitionID, "захват снят: запись возьмёт следующий прогон")
assert.Equal(t, 0, after.Attempts, "остановка отказа не тратит")
assert.Nil(t, after.ErrorText)
assert.Empty(t, env.sender.sent(), "отправителю о несуществующем сбое не сообщают")
}
// Запись, которую шаг не успел взять, потому что нас уже остановили, остаётся
// нетронутой: захват не случился, отказ не потрачен.
func TestShutdownBeforeStepLeavesRecordUntouched(t *testing.T) {
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
record := newTelegramRecord(t, env)
record := newRecord(t, env)
ctx, cancel := context.WithCancel(t.Context())
cancel()
+45 -139
View File
@@ -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 приводит расширение к виду колонки формата: без точки, в нижнем
// регистре. Наружу оно выходит только приведённым к перечню известных форматов —
// это делает метка метрики.
-154
View File
@@ -1,154 +0,0 @@
package service
import (
"bytes"
"log/slog"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
"git.vakhrushev.me/av/transcriber/internal/contract"
"git.vakhrushev.me/av/transcriber/internal/entity"
)
// Ответ отправителю уходит после того, как достигнутый рубеж сохранён. Значит,
// недоставка не может быть отказом шага: объявленный отказ засчитался бы воркеру
// сбоем, лёг бы владельцу записью отказа и переписал бы служебные поля
// доведённой записи. Причин недоставки две, исход у них общий.
// downSender изображает неподнятый канал доставки: так ведёт себя заглушка,
// которую ядро получает вместо отправителя Telegram.
type downSender struct {
calls int
}
func (s *downSender) Send(string, int64, *int) error {
s.calls++
return contract.ErrDeliveryChannelDown
}
// journalEnv пересобирает сервис с названным отправителем и своим журналом:
// утверждения судят и состояние записи, и то, что увидел владелец.
func journalEnv(
t *testing.T,
env *pipelineEnv,
sender contract.TelegramMessageSender,
) (*TranscribeService, *bytes.Buffer) {
t.Helper()
journal := &bytes.Buffer{}
svc := NewTranscribeService(
env.repos,
&okMetaViewer{},
&okConverter{},
env.service.recognizer,
sender,
testLimits,
slog.New(slog.NewTextHandler(journal, &slog.HandlerOptions{Level: slog.LevelDebug})),
)
return svc, journal
}
// transcribedRecord доводит запись до рубежа, с которого уходит ответ.
func transcribedRecord(t *testing.T, env *pipelineEnv, svc *TranscribeService) *entity.AudioRecord {
t.Helper()
record := newTelegramRecord(t, env)
for range 5 {
clearDelay(t, env, record.Id)
require.NoError(t, svc.RunStep(t.Context()))
if readRecord(t, env, record.Id).State == entity.StateTranscribed {
return readRecord(t, env, record.Id)
}
}
t.Fatal("запись не дошла до рубежа расшифровки")
return nil
}
// Канал не поднят: запись доводится до конца, шаг отказа не объявляет, а
// владелец узнаёт о недоставке из журнала.
func TestUndeliveredOnDownChannelKeepsRecordDone(t *testing.T) {
env := newPipelineEnv(t, &okMetaViewer{}, &okConverter{})
sender := &downSender{}
svc, journal := journalEnv(t, env, sender)
record := transcribedRecord(t, env, svc)
// Шаг завершается без отказа — именно это воркер считает в свой счётчик.
clearDelay(t, env, record.Id)
require.NoError(t, svc.RunStep(t.Context()))
assert.Equal(t, 1, sender.calls, "ответ до отправителя доехал")
after := readRecord(t, env, record.Id)
assert.Equal(t, entity.StateDone, after.State, "запись осталась на достигнутом рубеже")
assert.False(t, after.IsHalted(), "отказ записи не приписан")
require.NotNil(t, after.TranscriptTextID, "расшифровка сохранена")
text, err := env.repos.Texts.GetByID(*after.TranscriptTextID)
require.NoError(t, err)
require.NotEmpty(t, text.Contents)
written := journal.String()
assert.Contains(t, written, "Reply was not delivered", "недоставка названа")
assert.Contains(t, written, record.Id, "запись несёт идентификатор")
assert.Contains(t, written, "level=WARN", "объявленный режим — «может стать проблемой»")
assert.NotContains(t, written, text.Contents, "текста расшифровки в журнале нет")
}
// Адресат у записи не назван: исход тот же. Прежде эта ветка объявляла отказ
// шага на уже завершённой работе.
func TestUndeliveredWithoutChatKeepsRecordDone(t *testing.T) {
env := newPipelineEnv(t, &okMetaViewer{}, &okConverter{})
sender := &downSender{}
svc, journal := journalEnv(t, env, sender)
record := transcribedRecord(t, env, svc)
// Запись из Telegram, у которой чат не назван: такую отдаёт правка в панели.
// Колонка чистится мимо захвата — иначе подготовка унесла бы запись у шага.
stored, err := env.app.FindRecordById(migrations.RecordsCollection, record.Id)
require.NoError(t, err)
stored.Set("tg_chat_id", nil)
require.NoError(t, env.app.Save(stored))
clearDelay(t, env, record.Id)
require.NoError(t, svc.RunStep(t.Context()))
assert.Equal(t, 0, sender.calls, "до отправителя дело не дошло: адресата нет")
after := readRecord(t, env, record.Id)
assert.Equal(t, entity.StateDone, after.State)
assert.False(t, after.IsHalted(), "отказ записи не приписан")
written := journal.String()
assert.Contains(t, written, "Reply was not delivered")
assert.Contains(t, written, record.Id)
assert.Contains(t, written, "chat is not specified", "причина названа")
assert.Contains(t, written, "level=ERROR",
"порча записи громче штатного «бот не настроен»: иначе сигнал утонет")
}
// Запись, принятая по HTTP, до отправителя не доходит вовсе: недоставки нет, и
// записи о ней в журнале быть не должно — иначе журнал владельца заполнят
// строки о записях основного входа.
func TestApiRecordDoesNotReachSenderAndLogsNothing(t *testing.T) {
env := newPipelineEnv(t, &okMetaViewer{}, &okConverter{})
record, err := env.service.CreateJobFromApi(t.Context(), strings.NewReader("запись"), "voice.ogg", newOwner(t, env.app))
require.NoError(t, err)
sender := &downSender{}
svc, journal := journalEnv(t, env, sender)
require.NoError(t, svc.send(record, "расшифровка записи"))
assert.Equal(t, 0, sender.calls, "отправителя не звали")
assert.NotContains(t, journal.String(), "Reply was not delivered", "недоставки не было")
}
+3 -50
View File
@@ -20,7 +20,6 @@ import (
pbrepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase"
"git.vakhrushev.me/av/transcriber/internal/config"
httpcontroller "git.vakhrushev.me/av/transcriber/internal/controller/http"
tgcontroller "git.vakhrushev.me/av/transcriber/internal/controller/tg"
"git.vakhrushev.me/av/transcriber/internal/controller/worker"
"git.vakhrushev.me/av/transcriber/internal/metrics"
"git.vakhrushev.me/av/transcriber/internal/service"
@@ -60,14 +59,6 @@ func main() {
os.Exit(1)
}
// Включённый вход без ключа доступа — ошибка настройки, а не режим: бот по
// пустому ключу не появится, а тихий подъём без него оставил бы отправителей
// без ответов.
if err := cfg.Telegram.Validate(); err != nil {
logger.Error("Unable to start with incomplete telegram settings", "error", err)
os.Exit(1)
}
// Числа конвейера проверяются здесь же: ноль воркеров — объявленный режим, а
// отрицательное число и нулевой предел простоя — опечатка, и подниматься с
// ней значит остановить всякую запись первым же захватом.
@@ -112,12 +103,6 @@ func main() {
metaviewer := ffmpegmv.NewFfmpegMetaViewer()
converter := ffmpegconv.NewFfmpegConverter()
tgBot, tgSender, err := buildTelegram(cfg.Telegram, logger)
if err != nil {
logger.Error("Failed to create Telegram bot", "error", err)
os.Exit(1)
}
recognizer, err := yandex.NewYandexAudioRecognizerService(yandex.YandexAudioRecognizerConfig{
Region: cfg.Yandex.ObjStorageRegion,
AccessKey: cfg.Yandex.ObjStorageAccessKey,
@@ -147,7 +132,6 @@ func main() {
metaviewer,
converter,
recognizer,
tgSender,
cfg.Pipeline.StuckLimits(),
logger,
)
@@ -159,32 +143,6 @@ func main() {
// Создаем WaitGroup для ожидания завершения всех воркеров
var wg sync.WaitGroup
tgConfig := tgcontroller.TelegramConfig{
UpdateTimeout: cfg.Telegram.UpdateTimeout,
UserWhiteList: cfg.Server.UsersWhiteList,
}
// Транспорт поднимается только там, где есть клиент: о том, что бота нет,
// сказано выше единственной записью, и вторая здесь была бы записью о том
// же факте.
var tgController *tgcontroller.TelegramController
if tgBot != nil {
tgController, err = tgcontroller.NewTelegramController(tgConfig, tgBot, transcribeService, logger)
if err != nil {
logger.Error("Failed to create Telegram controller", "error", err)
os.Exit(1)
}
// Запускаем Telegram бот в отдельной горутине
wg.Add(1)
go func() {
defer wg.Done()
logger.Info("Starting Telegram bot")
tgController.Start(ctx)
logger.Info("Telegram bot stopped gracefully")
}()
}
// Пул одинаковых воркеров: специализации у них нет, шаг выбирается по рубежу
// самой записи. Число приходит настройкой, ноль — законное значение.
pool := worker.NewPool(cfg.Pipeline.Workers, transcribeService.RunStep, logger)
@@ -194,9 +152,9 @@ func main() {
pool.Start(ctx)
}()
// Вход по HTTP поднимается всегда: он основной, и отдельного разреза у него
// нет. Признак ставится рядом с признаком Telegram, чтобы владелец судил об
// обоих входах одним отбором.
// Вход у сервиса один — приём по HTTP, — и метка ставится только ему. Метки
// убранного входа Telegram здесь нет намеренно: ноль читался бы как поломка,
// а признак существует ради того дня, когда входов снова станет больше.
metrics.IntakeUpGauge.WithLabelValues("http").Set(1)
// Наши маршруты живут на роутере хранилища: панель отдаётся тем же портом,
@@ -305,11 +263,6 @@ func main() {
logger.Error("HTTP server stopped unexpectedly, shutting down")
}
if tgController != nil {
logger.Info("Shutting down Telegram bot...")
tgController.Stop()
}
// Создаем контекст с таймаутом для graceful shutdown HTTP сервера
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), time.Duration(cfg.Server.ShutdownTimeout)*time.Second)
defer shutdownCancel()
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-08-14
@@ -0,0 +1,225 @@
## Context
Сервис принимает записи двумя входами. Приложение и внешняя программа приходят по
HTTP с сессией, и у их записей есть владелец; бот приходит от чата, и у его
записей владельца нет — связи чата с учётной записью сервис не ведёт. Отсюда
оговорка про бота в каждом решении о владельце: в спеке приёма, в спеке доступа,
в схеме хранилища и в отборе записей.
Вход Telegram при этом несёт собственный вес: клиент, транспорт обновлений,
разбиение длинного текста на сообщения, заглушка отправителя, сборка входа при
старте с разбором «недоступность или ошибка настройки», список допущенных людей,
доставка ответа из конвейера и правило о недоставленном ответе.
Ограничение, которое правит решением: применённый шаг схемы не переписывается —
это инвариант проекта, и он **critical**. Колонки `tg_chat_id`,
`tg_reply_message_id` и значение `telegram` перечня `source` заведены шагами
`202608110001` и `202608140002`, оба применены. В боевой базе по этим колонкам
лежат записи живых людей.
## Goals / Non-Goals
**Goals:**
- Убрать вход Telegram из кода, настроек и зависимостей целиком.
- Сделать владельца записи безусловным: завести запись без владельца сервису
нечем.
- Сделать владельца обязательным **в схеме**, а не только в приёме: ни одной
записи не потерять и ни одного применённого шага схемы не переписать.
- Оставить возврат входа дешёвым: удалённое лежит в истории git одним изменением,
и возвращается оно вместе со связью чата и учётной записи.
**Non-Goals:**
- Замена доставки ответа. Уведомлений взамен чата не заводим — на это стоит
задача `ntfy-delivery`.
- Чистка хранилища от колонок и значений бота. Ни нового шага схемы, ни правки
применённых.
- Переделка приёма по HTTP, конвейера, распознавания и панели.
- Связь чата Telegram с учётной записью. Она нужна возврату входа, и заводит её
своя задача.
## Decisions
### Схема теряет только необязательность владельца
Колонки чата и ответного сообщения остаются, значение `telegram` в перечне
источников остаётся, применённые шаги не переписываются. Новый шаг схемы у
изменения один, и делает он ровно одно: колонка владельца у аудиозаписи и у файла
перестаёт принимать пустое значение.
Шаг безопасен потому, что записей без владельца в боевой базе нет — это сказал
владелец сервиса, отвечая на прямой вопрос, и на этом ответе решение и стоит.
Будь такие записи, шаг пришлось бы либо отменить, либо оплатить необратимой
правкой чужих данных: назначить им владельца выдумкой или удалить.
**Проверяется это запросом, а не прогоном шага**, и разница выяснилась ревью с
оракулом: хранилище держит обязательность связи проверкой записи при сохранении,
а не ограничением таблицы. Смена признака на базе с ничьей записью проходит
зелёным и такую запись оставляет — то есть прогон шага на копии боевой базы
чистую базу от грязной не отличает. Заставить шаг считать строки самому владелец
решил не делать (решение от 2026-08-14): безопасность держится ручной проверкой,
и она названа первым шагом плана перехода.
Цена промаха, если ничью запись всё же проглядят: она становится незакрываемой.
Захват идёт сырым запросом мимо проверки и выдаёт её воркеру, а всякое сохранение
отказывает — включая то, которым ставится признак остановки. Запись повторяется
неограниченно, не оставляя следа ни в метрике, ни в журнале событий.
Колонка темы словаря обязательной была уже — разное правило у трёх колонок одного
смысла читалось бы как недосмотр, и теперь их правило одно.
Рассмотрено и отвергнуто:
- **Новый шаг, убирающий колонки бота.** Он законен — запрет стоит на правке
применённого шага, а не на новом, — но необратим по данным: колонки заполнены у
записей живых людей, и восстановить их после удаления неоткуда. Возврат входа
завёл бы их заново пустыми.
- **Сужение перечня `source` до `unknown` и `api`.** Строки со значением
`telegram` в базе есть, и после сужения перечня они перестают проходить
проверку схемы: панель откажется их сохранять, а поведение записи при чтении
зависит от библиотеки. Цена — молчаливая порча уже принятых записей.
Поля адресата уходят при этом из **модели** — колонки остаются в схеме, а
аудиозапись их больше не несёт. Это безопасно ровно потому, что колонки адресата
пишет только заведение записи: снимок шага конвейера кладётся отображением, где
их нет вовсе, и потому значение уже принятой записи он не затирает. Правила
`internal/archrules` такую пару держат: колонка, которую никто не пишет, законна,
незаконна колонка, которую пишут, но не читают.
Следствие: константа `entity.SourceTelegram` остаётся в модели. На неё ссылается
применённый шаг `202608140002`, и убрать её значило бы переписать применённый
шаг. В модели она получает комментарий об историческом значении: новых записей с
ним не появляется.
### Владелец записи перестаёт быть необязательным и в модели
Поле владельца в аудиозаписи становится обычной строкой вместо ссылки, которой
позволено отсутствовать. Модель здесь повторяет схему, а не расходится с ней:
значения «владельца нет» больше не существует ни на одном уровне.
Рассмотрено и отвергнуто:
- **Оставить ссылку, которой позволено отсутствовать.** Она заставляет каждого
читателя решать, что делать с отсутствием, хотя отсутствия больше не бывает.
Два способа выразить одно значение расходятся молча.
- **Держать обязательность одним приёмом, схему не трогать.** Так было задумано
сперва, и это оставляло дыру: ничью запись заводили руками в панели, она
уходила в конвейер, стоила денег на распознавание и не доставалась потом
никому. Решение владельца от 2026-08-14 — обязательность держит схема.
Правило «пустой владелец не совпадает ни с одной записью» при этом остаётся и не
становится избыточным: схема запрещает **заводить** ничью запись, а это правило
запрещает **спрашивать** ничьим именем. Снять его, сославшись на схему, значит
открыть выборку первому же вызывающему без учётной записи.
### Отправителя о готовности и об остановке узнаёт опрос, и другого канала нет
Доставка из конвейера убирается вместе с договором об отправителе сообщений.
Правило о недоставленном ответе уходит: недоставке взяться неоткуда.
Рассмотрено и отвергнуто:
- **Оставить заглушку отправителя.** Договор без единой реализации, кроме
пустой, — это мёртвый шов, который читается как незаконченная работа и
переживает не одну задачу.
- **Завести уведомление взамен сразу.** Это другой предмет со своей внешней
зависимостью; задача на него уже стоит в беклоге, и слепить их значит собрать
два изменения в одно.
### Метка убранного входа не выставляется вовсе
Признак поднятого входа остаётся, метка `telegram` у него больше не появляется.
Ноль вместо неё читается как «вход есть, но не поднялся», то есть как поломка;
владелец, у которого на этот признак стоит отбор, увидел бы аварию на ровном
месте.
### Незнакомые ключи настроек по-прежнему не судятся
Секция `[telegram]` и ключ `server.users_while_list` убираются из структуры
настроек и из образца. Имя ключа написано с опечаткой — `while` вместо `white`, —
и это записано «Расхождением» в `docs/conventions/config.md`; удаление ключа
закрывает и его. Файл, где их забыли, сервис поднимет молча: разбор настроек
незнакомые ключи не проверяет.
Рассмотрено и отвергнуто: **завести отказ старта по незнакомому ключу**. Это
новое поведение настроек, полезное само по себе, но чужое этой задаче: оно
касается всех ключей, а не убранных, и разбирается своей задачей.
## Risks / Trade-offs
- **У сервиса не остаётся входа, которым человек может воспользоваться.** Это
главный риск изменения, и он не смягчается ничем внутри задачи. Приложения нет,
своего токена у программы нет, значит после выкладки положить запись можно
единственным способом: собранным руками запросом с сессией, снятой из браузера
после входа. Срок этого состояния задаётся чужими задачами — экраном приложения
и личными токенами, — и внутри этой задачи не назначается. Прецедент в журнале
ревью: 2026-08-12 поверхность закрыли так, что войти не мог никто, и спасением
тогда был именно бот.
- **Боевой файл настроек переживёт выкладку с мёртвой секцией** → сервис
поднимется без бота, и это ровно то, чего мы хотим; секцию убирает человек при
выкладке, и об этом сказано в плане перехода.
- **Записи, застрявшие в конвейере на минуту выкладки, дойдут до текста, и ответа
в чат по ним не уйдёт** → отправитель в Telegram ответа не получит вовсе.
Смягчения нет и быть не может: чат — это и есть убираемый вход. Расшифровка не
теряется, владелец сервиса видит её в панели.
- **Ничья запись, если она всё же найдётся, шагом не ловится** → она остаётся в
базе и становится незакрываемой: сохранить её нечем, остановить тоже, а следа
не остаётся ни в метрике, ни в журнале событий. Смягчение одно и ручное —
проверка запросом первым шагом плана перехода. Заставить шаг считать строки
самому владелец решил не делать.
- **Возврат входа стоит восстановления кода** → удаление уезжает одним
изменением, и его архив вместе с историей git даёт точку возврата. Связь чата с
учётной записью всё равно потребует нового кода: без неё возвращать вход значит
возвращать записи без владельца.
- **Правила, потерявшие предмет, останутся в памятке и в модели угроз**
инварианты про белый список бота и про ответ отправителю убираются синком
документации тем же изменением, а не следующей задачей.
## Migration Plan
1. Проверить боевую базу запросом: `SELECT count(*) FROM audio_records WHERE
owner = ''` и то же по `files`. Оба должны отдать ноль. Прогон самого шага на
копии этого не показывает — он проходит зелёным и на грязной базе.
2. Собрать образ (`task image`) — сборка образа в гейт не входит, и человек
обязан прогнать её сам.
3. Выложить (`inv pl -- transcriber` из `pet-project-server`) — запускает человек.
4. Убрать из боевого файла настроек секцию `[telegram]` и ключ
`server.users_while_list`. Порядок с шагом 3 безразличен: оставленные ключи
сервис пропускает молча, и для подъёма этот шаг не нужен.
5. Ключ доступа к боту остаётся в хранилище выкладки под возврат входа — решение
владельца от 2026-08-14. Ротация не делается, бот у Telegram остаётся
зарегистрированным. Цена решения названа прямо: отправитель голосового не
получит ни ответа, ни отказа и не отличит выведенный вход от сломанного
сервиса. Оповещать людей из списка допущенных владелец не стал.
6. Откат — прежний образ. Он потребует вернуть настройки: сервис прошлой версии
без ключа `telegram.enabled` не поднимается. Схему откат вернёт своим
обратным шагом — колонка владельца снова примет пустое значение, — и на
записях это не сказывается: пустых среди них нет.
### Формы решения, между которыми выбирали
Форма выбрана не единственной рассмотренной, и остальные две названы здесь, а не
подразумеваются:
- **Выключить вход признаком, код оставить.** Признак `telegram.enabled` заведён
2026-08-13 и обязателен, а приём по HTTP владельца уже требует: одна правка
ключа в боевом файле даёт «новых записей без владельца не заводится» ценой ноля
строк кода и мгновенным возвратом. Отвергнуто по причине из раздела «Why»:
двойная модель остаётся в коде, и оговорку про бота продолжает платить каждая
следующая задача. Размен здесь честный — стоимость сопровождения против
стоимости отката, — и выбран он в пользу первой.
- **Сузить бота до исходящего канала.** Приём убрать, отправку оставить с одним
адресатом — чатом владельца строкой настроек; связь чата с учётной записью для
этого не нужна. Смягчает главный риск: человек хотя бы узнаёт исход. Отвергнуто
потому, что заводит понятие «канал уведомления владельца», которое тут же
переделает задача `ntfy-delivery`, и держит ради этого клиент, договор и
зависимость — плату за временный мост.
## Open Questions
- Критерии приёмки постановка не назвала: они предложены в `tasks.md` и
подтверждаются человеком на чекпоинте.
- Судьба задач беклога `telegram-account-link` и
`bot-api-only-through-bot-client`: обе теряют предмет до возвращения входа.
Решает владелец; это изменение их не трогает.
@@ -0,0 +1,80 @@
## Why
Сервис принимает записи двумя входами, и входы расходятся в главном: у записи,
пришедшей из приложения, есть владелец, а у записи, пришедшей от бота, владельца
нет и быть не может — связи чата с учётной записью сервис не ведёт. Пока такие
записи заводятся, правило «каждая запись принадлежит человеку» действует
наполовину: половину записей оно накрывает, а другую половину обходит, и каждое
решение о владельце приходится писать с оговоркой про бота.
Основной вход сегодня — приложение, и работа идёт над ним. Второй вход при этом
стоит денег на каждой задаче: его нельзя не учитывать в приёме, в доставке
ответа, в отборе записей и в настройках. Убираем его, чтобы модель стала одной, и
убираем **временно**: бот вернётся, когда появится связь чата с учётной записью.
## What Changes
- **BREAKING**: вход Telegram убирается целиком. Бот не поднимается, записи от
него не принимаются, ответ в чат не уходит.
- **BREAKING по смыслу, а не по разбору**: настройки входа Telegram и список
допущенных к боту людей уходят из конфигурации. Файл, где их забыли, сервис
примет молча — незнакомые ключи разбор настроек не судит, — но значить они
перестают что бы то ни было. Секцию убирает человек при выкладке.
- У записи появляется владелец на правах обязательного, и держит это хранилище:
колонка владельца у записи и у файла перестаёт принимать пустое значение. Завести
ничью запись нельзя ничем — ни приёмом, ни конвейером, ни рукой в панели.
- Из конвейера уходит доставка ответа отправителю: единственный оставшийся вход
узнаёт исход опросом готовности и из панели владельца. Вместе с доставкой
уходят правило про недоставленный ответ и обязанность остановки сообщать
отправителю.
- Наблюдатель видит один поднятый вход вместо двух.
- Записи с источником Telegram, если они есть, остаются нетронутыми: колонки чата
и ответного сообщения из схемы не убираются, и ни одна запись не удаляется.
Записей **без владельца** в базе при этом нет — так ответил владелец сервиса, и
на этом стоит обязательность колонки; прогон нового шага схемы на копии боевой
базы проверяет это до выкладки.
## Capabilities
### New Capabilities
Новых нет: изменение убирает поведение, а не заводит.
### Modified Capabilities
- `intake`: уходит требование о признаке включения входа Telegram; требование о
видимых наблюдателю входах говорит об одном входе.
- `pipeline`: уходят требования о недоставленном ответе и об обязанности всякой
остановки сообщать отправителю — адресата у сообщения не осталось. Взамен
появляется требование, что конвейер наружу не обращается вовсе. Из выборки
воркера и из требования о держателе захвата уходят оговорки про записи без
владельца и про ответ отправителю.
- `access`: запись без владельца перестаёт быть возможной. Правило «чужую запись
не отдают» не меняется; остаётся и правило «пустой владелец не совпадает ни с
одной записью» — оно запрещает спрашивать ничьим именем, а не заводить ничью
запись.
- `storage`: колонка владельца у аудиозаписи и у файла перестаёт принимать пустое
значение — это новый шаг схемы. Приведённая копия файла получает владельца
своей записи. Из требований о подъёме на чистом каталоге и о пароле владельца
уходит «обоими входами».
## Impact
- **Код**: удаляются клиент бота, отправитель сообщений, разбиение длинного
текста, заглушка отправителя, транспорт бота, сборка входа при старте и договор
об отправителе сообщений. Из службы расшифровки уходят приём записи от бота и
отправка текста в чат.
- **Настройки**: секция `[telegram]` целиком и ключ `server.users_while_list`
(имя ключа в коде написано с опечаткой, и она здесь воспроизведена намеренно —
искать в боевом файле надо именно его).
- **Зависимости**: `go-telegram-bot-api/telegram-bot-api/v5` уходит из манифеста.
- **Хранилище**: один новый шаг схемы, и он делает колонку владельца обязательной
у аудиозаписи и у файла. Применённые шаги не переписываются, колонки чата и
ответного сообщения остаются на месте вместе с записями, которые их заполнили.
- **Наблюдение**: метка `telegram` у признака поднятого входа больше не
выставляется.
- **Документы**: паспорт теряет потребителя и два типовых сценария; инвариант о
белом списке бота уходит из памятки; модель угроз теряет вход.
- **Задачи беклога**: `telegram-account-link` и `bot-api-only-through-bot-client`
теряют предмет до возвращения бота. Судьбу их решает владелец — это изменение
их не закрывает.
@@ -0,0 +1,192 @@
# Ревью remove-telegram-intake — триаж
## Сводка
- **Режим:** по графу. **Метка:** `large` (разметка `review-scope`). 50 файлов, −2394 строки, семь
удалённых файлов кода, новый шаг схемы; необратимый элемент — шаг схемы, переписаны инварианты
`CLAUDE.md`.
- **База диффа:** `origin/master` отстала на два закрытых изменения; сверка шла против `HEAD` плюс
незакоммиченное рабочее дерево.
- **Гейт:** зелёный целиком (`task gate` → 0).
- **Сигнал о заниженной метке:** `review-code` возражений не подал. `review-basics` не запускался —
второго независимого голоса о метке нет.
- **На вход:** 22 находки (specs 5, code 8, architecture 3, ops 2, adversary 4, autotests 0) плюс
4 замечания. После дедупликации по причине — 11 различных причин, одна выброшена как ошибочная.
### План с исходом по каждой теме
| тема | дом | глубина | кто закрывает | исход |
| --- | --- | --- | --- | --- |
| requirements | `openspec/specs/{intake,pipeline,access,storage}` + дельты | разбор | specs | закрыта, 5 находок |
| autotests | `CLAUDE.md` «Гейт»/«Команды» | — | autotests | закрыта, 0 находок + замечание о дрейфе `go-linters.md` |
| conventions + техника | `docs/conventions/` | разбор | code | закрыта, 7 находок + 1 ошибочная |
| architecture | `docs/architecture.md` (+ `passport.md`) | доказательство | architecture | закрыта, 3 находки |
| security | `docs/security.md` | доказательство | adversary | закрыта, 4 находки + 3 свойства без пути |
| operations | `docs/architecture.md` «Эксплуатация» (+ `database.md`) | доказательство | ops | закрыта, 2 находки |
Тем без отчёта нет. Тем без дома нет. `basics` не запускался — своих тем сверх ядра план ему не дал.
## Блокирует мердж
### Ничья запись переживёт новый шаг схемы и станет незакрываемой
- Файл: `internal/adapter/repo/pocketbase/migrations/202608140003_owner_required.go:20-30`;
`openspec/changes/remove-telegram-intake/design.md`, «Migration Plan», п. 1
- Severity: major. Confidence: high
- Оракул (переснят триажем, `go test ./internal/adapter/repo/pocketbase/ -run TestTriageProbeOwnerRequiredOverOwnerlessRow`):
```
PRE-STEP: ничья запись заведена, owner=""
STEP 003 (Required=true) поверх ничьей записи: err=<nil>
ПОСЛЕ ШАГА: строка на месте, owner=""
FindAndAcquire: выдана запись
Save остановленной ничьей записи: err=failed to update audio record: owner: cannot be blank.
```
- Последствие: `Required` у поля связи PocketBase — проверка при сохранении записи, а не ограничение
таблицы. Шаг проходит зелёным на базе с ничьей записью, и запись остаётся. Дальше она выдаётся
воркеру (захват идёт сырым запросом мимо валидации), любое сохранение падает, `halt()` перехватывает
отказ до строк, растящих `WorkerJobCounter` и пишущих `record_events`. Запись не останавливается,
берётся снова по истечении срока захвата и повторяется неограниченно: ни метрики, ни журнала
событий, только строка в логе контейнера. Предвыкладочная проверка, на которой стоит безопасность
шага, не отличает чистую базу от грязной.
- Найдено проходами: specs, code, ops (три прохода, одна причина).
- **Действие: развилка.** Три варианта: (1) шаг сам считает ничьи строки и отказывается;
(2) шаг остаётся, утверждение «падает на живой базе» уходит из комментария и плана, а в порядок
выкладки добавляется ручная проверка запросом; (3) шаг приводит данные — необратимо, решение
владельца обязательно.
### Второй ответ провайдера, приехавший пустым, стирает сохранённую расшифровку
- Файл: `internal/service/transcribe.go:716-733` (`poll``storeOutcome`),
`internal/adapter/repo/pocketbase/text_repo.go:40-53`
- Severity: critical. Confidence: high
- Оракул: падающий тест, переснят триажем — `expected: "Личный разговор." actual: ""`.
- Последствие: поток gRPC SpeechKit, закрывшийся на первом `Recv`, отказом не считается — `outcome`
пуст, `err == nil`. `Texts.Put` кладёт пустое поверх сохранённого безусловно, рубеж двигается,
запись доходит до `done` без текста. Сырой ответ уцелеет (`Finish` пишет вложение под условием
`len(raw) > 0`), но `ReadRaw`/`Parse` в боевом коде не зовёт никто.
- **Регрессией этого изменения не является:** `poll`, `storeOutcome` и `text_repo.go` диффом не
тронуты. Изменение сняло последний видимый признак вырожденного ответа — заглушку «на записи нет
текста», — но она уходила только в Telegram.
- Найдено проходом: adversary.
- **Действие: развилка.** (1) `storeOutcome` не пишет пустое поверх непустого; (2) вырожденный ответ
считается отказом шага; (3) задача, мердж как есть.
## Стоит исправить сейчас
### Пустая расшифровка перестала замечаться, а два документа обещают, что она замечена
- Файл: `internal/service/transcribe.go` (`finish`), `docs/conventions/logging.md:62`,
`docs/architecture.md:142`
- Severity: major. Confidence: high
- Оракул: `logging.md:62` называет «пустой текст распознавания» поимённым примером уровня `WARN`;
`architecture.md:142` утверждал «запись завершается заглушкой „на записи нет текста"». Заглушку
изменение убрало вместе с доставкой, замены не было.
- **Действие: инлайн. Исправлено:** `poll` пишет `WARN` с идентификатором записи при пустом
результате; строка `architecture.md:142` переписана на фактическое поведение; заведён оракул
`TestEmptyRecognitionIsNamedInJournal`.
### Приёмка переписанного инварианта «остановленная запись несёт причину» не может упасть
- Файл: `internal/service/pipeline_test.go`
- Severity: minor. Confidence: high
- Оракул: `Halt()` ставит `HaltedAt` и `HaltReason` одним движением, `IsHalted()` читает `HaltedAt`
значит `require.NotNil(HaltReason)` следует из `require.True(IsHalted())`. Независимый сигнал
(`sender.sent()`) убран вместе с доставкой.
- **Действие: инлайн. Исправлено:** вторым утверждением взята строка журнала событий — отдельное
сохранение, способное упасть само по себе; добавлена третья причина остановки.
### Удаление входа не доведено: подавление линтера, конвенции, фикстуры и комментарии
- Файл: `.golangci.yml:158`; `docs/conventions/go-linters.md:81,92,155`; `internal/contract/error.go`;
`internal/config/config_test.go`; `internal/service/transcribe.go`; `internal/contract/repository.go`;
`internal/metrics/format_label.go`; `internal/service/ownership_test.go`;
`openspec/specs/pipeline/spec.md:104`
- Severity: minor. Confidence: high
- Последствие: подавление `errcheck` по мёртвому символу — заряженная мина: вход убран временно, метод
`send` вернётся под тем же именем и молча окажется без проверки отказов. Остальное — документы и
комментарии, обещающие ответ отправителю, включая нормативный доклад `contract/repository.go`.
- Найдено проходами: specs, code, architecture, autotests (одна причина, четыре прохода).
- **Действие: инлайн. Исправлено целиком:** снято подавление и строки про `send`; `controller/tg`
убран из таблицы архправил; удалён `ErrDeliveryChannelDown`; переписаны комментарии; фикстуры
переведены с мёртвой секции; требование «Захват задачи неделим» внесено в дельту.
### Анонимный запрос кладёт до мегабайта своего текста в журнал контейнера одной строкой
- Файл: `main.go:184-202`
- Severity: major. Confidence: high
- Оракул: живой прогон — 900000 байт в пути → 404, журнал вырос с 1048 до 901194 байт.
- Последствие: длину строки журнала задаёт неузнанный посетитель; разбор инцидента по журналу
становится невозможен. Тот же класс назван недопустимым в `internal/controller/http/auth.go:277-284`.
- **Дефект пред-существующий:** `main.go` этим изменением правится только в части подъёма входов.
- **Действие: развилка.** Чинить здесь или заводить задачей.
## Гипотезы без доказательства
- **`transcribed` — лишний рубеж** (architecture): готовая расшифровка платит отдельный захват и
попадает под сторож застревания за проход, переставляющий одну колонку. Оракула, что это случалось,
нет. Вопрос владельцу — место ему в задаче.
- **Отказ приёма по пустому владельцу приходит новому вызывающему как 500** (specs): путь сегодня
недостижим, транспорт отвергает раньше.
- **`http.status_code=0` у всякого отвергнутого запроса** (adversary, `main.go:194-199`).
Пред-существующее.
- **Хвост имени отправителя уезжает в журнал внутри поля `error`** (adversary): инвариант приватности
не нарушен, нарушена форма изъятия. Пред-существующее.
- **Три свойства без построенного пути** (adversary): длительность из метаданных ничем не ограничена
(переполнение `int`); непустой, но более короткий ответ провайдера затирает и вложение;
`POST /api/collections/users/request-verification` открыт анониму.
**Выброшено как ошибочное:** находка `code` о `gofmt` на `pipeline_test.go``gofmt -l .` пуст,
проверено дважды.
**Выброшено как вкусовщина:** переименование `CreateJobFromApi`.
**Проектные ложноположительные:** строка «Запись без владельца не достаётся никому» в `docs/review.md`
отменена дважды, последний раз этой же задачей — находка о ничьей записи ей подтверждается, а не
отсеивается.
## Promote candidates
- **В `docs/conventions/logging.md`:** длину поля журнала не задаёт вызывающий — всё, что приходит
извне, уезжает в журнал усечённым до объявленного предела. Правила нет, случай второй.
- **Отклонённый кандидат:** страж существования символов в `exclude-functions` — заводить нельзя,
`CLAUDE.md` «Проверок над проверками не заводить» запрещает стражей предмета у правил.
## Границы покрытия
**Что запускалось.** Шесть проходов на метке `large`, режим по графу: specs, autotests, code,
architecture, adversary, ops. `basics` не запускался — план не дал ему тем сверх ядра. Корректор метки
(`review-code`) отработал, возражений не подал; второго корректора в прогоне не было.
**Потолки проходов.** Ни один проход не сообщил свой потолок и что осталось за срезом, хотя контракт
обязывает. Неизвестно, показал ли `code` все находки или только верхние.
**Что не влезло в потолок триажа** (названо, а не выброшено):
- откат образа после вычистки секции `[telegram]` из боевого конфига роняет старт прежнего бинаря
(`ops`, оракул — прогон исторического бинаря `9a964f2`);
- ряд метрики `intake_up{telegram}` исчезает, а не обнуляется — цена не названа в документах владельца;
- `down202608140003` не покрыт (0.0%) — так у всех четырёх шагов `down*`, и они операционно
недостижимы: cobra-команды PocketBase не подключены;
- частичное покрытие двух требований дельты: «Поднятые входы видны наблюдателю» (оракул только живой)
и «Приведённая копия получает владельца записи» (косвенно).
**Что осталось целиком на человеке** — из `docs/review.md`, «Недоступно проверке»: поведение внешних
сервисов под нагрузкой и на границах, реальный профиль нагрузки, стойкость `ffmpeg` к вредоносному
входу, поведение настоящей Authelia, поведение браузера с куками. Сознательно перестали проверять:
разбор вывода настоящего `ffprobe`; работа с настоящими SpeechKit и Object Storage; вход через живого
провайдера OIDC.
**Четыре строки, которые в конвейере не закрывает никто:**
1. Решения проекта не сверялись — `docs/adr/` процессный, расхождение ловит `av-dev:doc-healthcheck`.
2. Записанные наблюдения (`docs/research/`) не использовались; всякое число снято на этом прогоне.
3. Поимённая сверка с руководствами по стилю Go не задавалась ни одним проходом.
4. Альтернативной реализации, с которой можно сдиффить решения, у конвейера нет.
**Чем работал триаж.** Две пробы на копии дерева в скретчпаде, базы — временные каталоги. Боевых
данных, боевых ключей и выкладки не касался.
Формулировки «критичных проблем не обнаружено» в отчёте нет: одна `critical` подтверждена падающим
тестом и живёт в сервисе прямо сейчас.
@@ -0,0 +1,57 @@
## MODIFIED Requirements
### Requirement: У записи есть владелец, и чужую ей не отдают
Сервис SHALL заводить у каждой принятой записи владельца — учётную запись, от
имени которой запись принята, — и MUST отдавать данные такой записи только её
владельцу. Запись без владельца MUST не заводиться ничем — ни приёмом, ни конвейером, ни
рукой в панели: колонка владельца пустого значения не принимает, и норму эту
держит capability `storage`.
Владелец назначается один раз, при приёме, и MUST не меняться: совместного
доступа, ролей и передачи записи другому сервис не знает.
Владелец MUST браться из предъявленной сессии и ниоткуда больше. Владелец,
пришедший полем запроса, дал бы всякому вошедшему право завести запись на чужое
имя.
Обращение к чужой записи MUST быть неотличимо от обращения к несуществующей.
Отдельный отказ «доступ запрещён» превращает опрос в перебор — по разнице
ответов считывается, какие записи заведены, а идентификатор записи и есть то,
что разграничение прячет. Каким именно ответом это выражено, нормирует
capability `intake`: там живёт адрес опроса, и держатель нормы обязан быть один.
Пустой владелец MUST не совпадать ни с одной записью. Правило записано со стороны
**спрашивающего** и остаётся в силе, хотя записей без владельца в хранилище
больше нет: спрашивающий с пустым владельцем — это вызов, у которого нет учётной
записи, и отвечать ему надо отказом, а не выборкой. Держится оно отдельно от
схемы намеренно: схема запрещает **заводить** ничью запись, а это правило
запрещает **спрашивать** ничьим именем, и одно другое не заменяет.
#### Scenario: Своя запись доступна
- **GIVEN** человек вошёл и принял запись
- **WHEN** он спрашивает состояние этой записи своей сессией
- **THEN** ответ несёт состояние записи
#### Scenario: Чужая запись неотличима от несуществующей
- **GIVEN** запись принята одним вошедшим
- **WHEN** её состояние спрашивает другой вошедший
- **THEN** ответ тот же, что и на неизвестный идентификатор, — и кодом, и телом
#### Scenario: Владельца не задают запросом
- **WHEN** запрос на приём записи несёт своё значение владельца
- **THEN** владельцем принятой записи становится предъявитель сессии
#### Scenario: Ничью запись завести нечем
- **WHEN** запись пытаются завести с пустым владельцем
- **THEN** хранилище её не сохраняет
#### Scenario: Пустой владелец не открывает ничего
- **GIVEN** заведены две записи: своя и чужая
- **WHEN** состояние каждой спрашивают с пустым владельцем
- **THEN** ответ на обе тот же, что и на неизвестный идентификатор
@@ -0,0 +1,245 @@
## MODIFIED Requirements
### Requirement: Приём записи по HTTP
Сервис SHALL принимать запись от внешней программы запросом `POST /api/audio` с
телом `multipart/form-data` и полем `audio` **только от узнанного отправителя**.
Запрос без сессии MUST получать код `401`, и по нему MUST не заводиться ни файл,
ни аудиозапись. Принятая запись от узнанного отправителя MUST быть сохранена и
получить заведённую под неё аудиозапись на рубеже `uploaded`; ответ MUST нести
идентификатор записи полем `job_id` и её рубеж полем `status`.
Значение рубежа в ответе изменилось: прежде приём отдавал `created`. Перечень
состояний назван проектом необратимым, и ломка объявлена прямо — состояние
теперь называет достигнутое, а не предстоящее, и `created` в новом перечне нет
вовсе.
Имена полей ответа нормативны и MUST остаться прежними: контракт HTTP API
объявлен проектом необратимым, и переименование поля ломает внешнюю программу
молча. Меняются значения поля рубежа, а не его имя.
Отказ по отсутствию сессии наступает **раньше** чтения тела: запись, за которую
не заплатит узнанный отправитель, не должна попасть даже в память.
Приём не судит о годности записи сам: расширение он берёт из имени файла, а
пригодность содержимого узнаёт у источника метаданных.
Куда именно ложится принятая запись, приёму не принадлежит: раскладку выбирает
хранилище, и нормирует её capability `storage`.
Владельцем принятой записи приём SHALL назначать предъявителя сессии. Обязательность
владельца при этом MUST держаться и схемой хранилища: колонка владельца пустого
значения не принимает вовсе, и норму эту держит capability `storage`. Проверка в
приёме от этого не лишняя — она отвечает отправителю понятным отказом до того, как
запись попадёт в память, а схема отвечала бы отказом сохранения после укладки
файла.
Предъявитель, чья сессия не даёт учётной записи пользователя, MUST получать
отказ `403` и MUST получать его **до чтения тела** — там же, где стоит отказ по
отсутствию сессии. Сессия владельца панели — именно такой случай: узнан он всё
же узнан, а записи в коллекции пользователей у него нет, и владельцем записи он
стать не может.
Код здесь другой, чем у запроса без сессии, и это не оплошность: `401` значит
«предъяви себя», а предъявитель себя предъявил. Утечки по разнице кодов нет —
оба ответа говорят о самом спрашивающем, а не о том, какие записи заведены.
Отказ **после** укладки записи потребовал бы убрать уже сохранённый файл, а
уборки файлов сервис не умеет вовсе: норма, обязывающая к недостижимому, не
пишется.
#### Scenario: Запись принята
- **GIVEN** источник метаданных читает запись и отдаёт её длительность
- **AND** отправитель предъявил сессию
- **WHEN** программа шлёт `POST /api/audio` с полем `audio`
- **THEN** ответ имеет код `201`, а в теле лежат непустой `job_id` и `status`
со значением `uploaded`
- **AND** содержимое записи целиком лежит в хранилище одним файлом
- **AND** владельцем заведённой аудиозаписи стоит предъявитель сессии
#### Scenario: Сессия не даёт учётной записи пользователя
- **GIVEN** предъявлена сессия владельца панели
- **WHEN** он шлёт `POST /api/audio` с полем `audio`
- **THEN** ответ имеет код `403`
- **AND** ни файла, ни аудиозаписи не заводится
#### Scenario: Сессии нет
- **WHEN** программа шлёт `POST /api/audio` с полем `audio` без сессии
- **THEN** ответ имеет код `401`
- **AND** ни файла, ни аудиозаписи не заводится
- **AND** тело ответа не несёт данных записи
#### Scenario: Поля с записью нет
- **GIVEN** отправитель предъявил сессию
- **WHEN** программа шлёт `POST /api/audio` без поля `audio`
- **THEN** ответ имеет код `400` и сообщение об отсутствии записи
- **AND** ни файла, ни аудиозаписи не заводится
#### Scenario: Размеру записи приём не судья
- **GIVEN** источник метаданных читает запись и отдаёт её длительность
- **AND** отправитель предъявил сессию
- **WHEN** программа шлёт запись нулевой длины
- **THEN** ответ имеет код `201`: собственного порога по размеру у приёма нет
### Requirement: Опрос готовности задачи
Сервис SHALL отдавать рубеж аудиозаписи по запросу `GET /api/status/:id`
**только её владельцу**. Запрос без сессии MUST получать код `401`, и тело
такого ответа MUST не нести ни рубежа записи, ни текста расшифровки. Ответ
владельцу MUST нести идентификатор полем `job_id`, рубеж полем `status` и время
заведения полем `created_at`, а текст расшифровки полем `transcription_text`, и
это поле MUST отсутствовать в ответе, пока текста нет: пустая строка на месте
отсутствующего текста читается как «расшифровка пуста».
Видов текста у записи больше одного, поэтому ответ MUST называть вид, который
отдаёт: в поле `transcription_text` уходит **сырая расшифровка**, и только она.
Вычитанный текст этим полем MUST не подменяться — иначе значение поля менялось бы
у одной и той же записи от того, успел ли отработать необязательный шаг, а
контракт объявлен необратимым. Отдача «последнего записанного» текста MUST не
применяться: она делает ответ функцией порядка записи, а не состояния записи.
Перечень значений поля `status` MUST совпадать с перечнем рубежей конвейера:
`uploaded`, `normalized`, `submitted`, `transcribed`, `done`. Прежних значений
`created`, `converted`, `transcribe`, `failed` и `dead` в ответе MUST не быть.
Это объявленная ломка публичного контракта: рубеж называет достигнутое, а отказ
перестал быть состоянием.
Остановленная запись MUST отдавать рубеж, на котором она остановлена, и MUST
нести признак остановки отдельным полем `halted` со значением истины. Машинный
текст отказа MUST в ответ не попадать: он принадлежит журналу владельца сервиса,
а не отправителю. Этот адрес — **единственное** место, где отправитель узнаёт о
неудаче: доставки ответа отправителю у сервиса больше нет, и признак остановки
здесь несёт всю обязанность целиком.
Отказ без сессии MUST не зависеть от того, есть такая запись или нет: иначе по
кодам ответа перебирается список заведённых записей.
Запись, принадлежащая другому, MUST отвечать тем же, чем отвечает неизвестный
идентификатор, — кодом `404` и тем же телом.
#### Scenario: Запись найдена
- **GIVEN** отправитель предъявил сессию
- **WHEN** он спрашивает рубеж своей записи
- **THEN** ответ имеет код `200` и несёт `job_id`, `status` и `created_at`
- **AND** значение `status` принадлежит перечню рубежей конвейера
#### Scenario: Запись остановлена
- **GIVEN** запись остановлена признаком на рубеже приведения
- **WHEN** владелец спрашивает её рубеж
- **THEN** поле `status` несёт рубеж приведения
- **AND** поле `halted` несёт истину
- **AND** машинного текста отказа в ответе нет
#### Scenario: Сессии нет
- **WHEN** программа спрашивает рубеж заведённой записи без сессии
- **THEN** ответ имеет код `401`
- **AND** тело ответа не несёт ни рубежа записи, ни текста расшифровки
#### Scenario: Без сессии неизвестная запись неотличима от заведённой
- **WHEN** программа без сессии спрашивает рубеж заведённой записи, а затем
рубеж по неизвестному идентификатору
- **THEN** оба ответа имеют код `401`
#### Scenario: Чужая запись неотличима от неизвестной
- **GIVEN** запись заведена одним вошедшим
- **WHEN** её рубеж спрашивает другой вошедший
- **THEN** ответ имеет код `404` и то же тело, что и ответ по неизвестному
идентификатору
- **AND** тело ответа не несёт ни рубежа записи, ни текста расшифровки
#### Scenario: Расшифровки ещё нет
- **GIVEN** отправитель предъявил сессию
- **WHEN** он спрашивает рубеж своей записи, которая ещё не дошла до текста
- **THEN** поля `transcription_text` в ответе нет вовсе
#### Scenario: Записи с таким идентификатором нет
- **GIVEN** отправитель предъявил сессию
- **WHEN** программа спрашивает рубеж по неизвестному идентификатору
- **THEN** ответ имеет код `404` и сообщение о ненайденной записи
### Requirement: Имя файла, данное отправителем, не попадает в журнал
Приём SHALL не писать имя файла, данное отправителем, ни в одну свою журнальную
запись — ни на успешном пути, ни на пути отказа, где имя могло бы приехать
текстом ошибки. Имя приходит извне вместе с записью и принадлежит содержимому
личной переписки наравне с текстом расшифровки; журнал уезжает в собранные логи,
откуда строку не убрать.
Расширение, взятое из этого имени, в журнале остаётся собственным полем: по нему
прослеживается путь записи. Что именно попадает в журнал ради прослеживаемости,
нормирует требование ниже; наружу расширение выходит только приведённым к
известному виду — этому отдано отдельное требование.
Оговорка про второй вход из требования ушла вместе с ним: имя, данное
отправителем, доходит до сервиса единственным путём — приёмом по HTTP, — и
сценарии судят именно его.
#### Scenario: Имя записи не видно в журнале принятой записи
- **GIVEN** источник метаданных читает запись и отдаёт её длительность
- **WHEN** программа шлёт `POST /api/audio` с записью, чья основа имени несёт
опознаваемую строку при обычном расширении `.mp3`
- **THEN** ни одна журнальная запись приёма этой строки не содержит
- **AND** расширение `.mp3` в журнале допустимо
#### Scenario: Имя записи не видно в журнале при отказе приёма
- **GIVEN** источник метаданных не может прочитать запись
- **WHEN** программа шлёт `POST /api/audio` с записью, чья основа имени несёт
опознаваемую строку
- **THEN** ни одна журнальная запись приёма, включая запись об ошибке, этой
строки не содержит
### Requirement: Поднятые входы видны наблюдателю
Сервис SHALL отдавать признак поднятости по каждому входу приёма отдельной
метрикой и MUST выставлять метку только тому входу, который у сервиса есть.
Метки убранного входа в метриках MUST не быть вовсе: признак со значением нуля
читался бы как «вход есть, но не поднялся», то есть как поломка, а вечная
единица рядом с ним — как исправность того, чего нет.
Проверяемое здесь одно — **набор меток**, и это честнее прежнего. Вход остался
один, страница метрик отдаётся тем же сервером, что и приём, и значение нуля у
единственной метки недостижимо: чтобы прочитать признак, надо дотянуться до
входа, о котором он сообщает. Прежнее обоснование — «иначе потерянный вход не
виден ничем» — было верно, пока входов было два; сегодня неподнятый вход виден
неудачей чтения самих метрик.
Различать поднятый и неподнятый вход признак MUST снова, как только входов у
сервиса станет больше одного.
#### Scenario: В метриках только оставшийся вход
- **GIVEN** сервис поднялся
- **WHEN** наблюдатель читает метрики
- **THEN** признак поднятости несёт метку входа HTTP со значением единицы
- **AND** метки убранного входа Telegram в метриках нет вовсе
## REMOVED Requirements
### Requirement: Признак включения решает, поднимается ли вход Telegram
**Reason**: Вход Telegram убран из сервиса целиком, и решать о его подъёме стало
нечего. Настройки входа — признак включения, ключ доступа и срок ожидания
обновлений — уходят из конфигурации вместе с ним.
**Migration**: Секцию `[telegram]` и ключ `server.users_while_list` — имя в коде
именно такое, с опечаткой, и в боевом файле искать надо его — из файла настроек
убрать руками. Оставленные ключи сервис пропускает молча — незнакомые
ключи разбор настроек не судит, — и потому файл, забытый как есть, поднимет
сервис без бота и без единого слова о том, что секция больше ничего не значит.
Записи с источником Telegram, если они в базе есть, остаются нетронутыми и
достаются своему владельцу; ответ в чат по ним не уходит. Возврат входа заводится
новым изменением вместе со связью чата и учётной записи.
@@ -0,0 +1,258 @@
## ADDED Requirements
### Requirement: Конвейер ответа отправителю не шлёт
Шаг конвейера SHALL доводить запись до достигнутого рубежа и MUST не обращаться
к отправителю вовсе — ни с готовым текстом, ни с сообщением о неудаче. Исход
своей записи отправитель узнаёт опросом готовности и в панели владельца; адрес
опроса и содержимое ответа нормирует capability `intake`.
Требование заведено взамен доставки в чат, убранной вместе с входом Telegram.
Без него молчание конвейера читалось бы как недоделка: прежде ответ уходил, и
всякий, кто помнит это, ищет в шаге отправку, а её отсутствие принимает за
потерянную ветку.
Инвариант проекта «Принятая запись не теряется молча» держится теперь опросом
готовности — там остановка видна признаком — и журналом владельца, где у неё
стоит причина. Обязанность при этом сменила направление: прежде об отказе
сообщали, теперь отказ доступен спросившему. Отправитель, который не
спрашивает, об остановке не узнаёт.
Записи, которой этот канал недоступен, не бывает: у каждой записи есть владелец,
и опрос отдаёт ему её исход. Держится это обязательностью владельца в схеме
хранилища — норму держит capability `storage`.
#### Scenario: Готовый текст отправителю не уходит
- **GIVEN** запись дошла до конечного рубежа
- **WHEN** шаг конвейера её завершает
- **THEN** ни одного обращения наружу с текстом расшифровки не уходит
- **AND** текст достаётся опросом готовности
#### Scenario: Остановка видна опросом, а не сообщением
- **GIVEN** запись остановлена по исчерпании отказов
- **WHEN** владелец записи спрашивает её рубеж
- **THEN** ответ несёт достигнутый рубеж и признак остановки
- **AND** в журнале владельца сервиса есть запись об остановке с причиной
## MODIFIED Requirements
### Requirement: Захват задачи неделим
Захват записи воркером SHALL быть одним неделимым шагом хранилища: выбор
подходящей записи и пометка её захваченной MUST происходить вместе.
Захват MUST возвращать **идентификатор записи и признак этого захвата**, а не
перечень её колонок. Колонки записи шаг читает сам, обычным чтением. Иначе
всякая новая колонка аудиозаписи попадала бы под инвариант проекта о колонках
очереди, и забытая в захвате колонка приезжала бы нулевой, а первое же
сохранение писало бы этот ноль поверх сохранённого значения.
**Признак захвата MUST быть значением, уникальным для каждого захвата**, а не
признаком занятости. Условие записи результата сверяет именно это значение:
захват, перевыданный другому — по протуханию срока или после того, как человек
снял признак остановки в панели, — обязан обращать запись первого в отказ.
Условие, проверяющее лишь непустоту признака или срок, пропустило бы обоих, и
два шага записали бы в одну запись по очереди, испортив её результат.
Одна и та же запись MUST доставаться ровно одному захватившему. Двум вызывающим,
пришедшим за работой одновременно, запись MUST достаться одному, а второй MUST
получить признак «работы сейчас нет».
Срок протухания захвата MUST ехать с рубежом записи, а не с воркером: воркер не
привязан к шагу и не знает заранее, что вытянет. Срок MUST записываться числом
при самом захвате.
Порядок выборки MUST быть определён однозначно: сравнения по неуникальному
значению для этого мало, и к нему MUST добавляться ключ записи. Иначе порядок
обработки невоспроизводим, а проверка, опирающаяся на «следующую» запись, зелена
через раз.
Требование стоит на инварианте проекта «Принятая запись не теряется молча»:
захват, разделённый на два шага, отдаёт одну запись двум воркерам, и работа
одного из них теряется без следа.
Признак «работы нет» этим требованием не переопределяется — его нормирует
требование «Пустой прогон воркера — не отказ».
#### Scenario: За работой пришли трое разом
- **GIVEN** к работе пригодна ровно одна запись
- **WHEN** три захвата идут одновременно
- **THEN** запись получает ровно один из них
- **AND** двое остальных получают признак «работы сейчас нет»
#### Scenario: Захваченная запись не выдаётся второй раз
- **GIVEN** запись захвачена и срок захвата не истёк
- **WHEN** приходит следующий захват
- **THEN** эта запись ему не выдаётся
#### Scenario: Захват отдаёт идентификатор и свой признак
- **GIVEN** к работе пригодна запись
- **WHEN** воркер её захватывает
- **THEN** захват возвращает идентификатор записи и признак этого захвата
- **AND** колонки записи шаг читает отдельным чтением
#### Scenario: Признак перевыданного захвата отличается от прежнего
- **GIVEN** запись захвачена, и признак первого захвата известен
- **WHEN** человек снимает признак остановки, и запись захватывает другой воркер
- **THEN** признак нового захвата отличается от признака первого
### Requirement: Результат пишет только держатель захвата
Шаг конвейера SHALL записывать свой результат только тогда, когда захват записи
всё ещё принадлежит ему. Запись MUST быть условна по **признаку этого захвата**
значению, уникальному для каждого захвата, — а не по занятости записи вообще.
Шаг, чей захват за время работы достался другому, MUST завершиться без записи
результата.
Требование закрывает то, чего неделимость захвата не закрывает: захват протухает
не только у мёртвого воркера, но и у живого — шаг, идущий дольше своего срока,
теряет запись, продолжая работать. Снять захват может и человек, вернувший
остановленную запись в работу. Без условия по уникальному признаку два воркера
пишут в одну запись по очереди, а счётчик отказов сбрасывает тот, кто уже не
владелец.
Довод про два ответа отправителю из требования ушёл вместе с доставкой: обращений
наружу шаг не делает. Требование от этого не ослабло — порча записи двумя
пишущими остаётся его предметом целиком.
Шаг MUST записывать только те поля, которыми распоряжается сам. Запись он держит
снимком с момента захвата и до записи — это часы, — и безусловная запись снимка
стёрла бы всё, что владелец правил в панели за это время: молча, без строки в
журнале и без отказа в панели. Владелец увидел бы успешное сохранение и был бы
уверен, что правка на месте. Владелец записи, заголовок, краткое описание и темы
конвейер MUST не трогать.
#### Scenario: Правка владельца пережила сохранение шага
- **GIVEN** шаг держит захваченную запись
- **AND** владелец за это время изменил в панели поле, которого шаг не касается
- **WHEN** шаг записывает свой результат
- **THEN** результат шага записан
- **AND** правка владельца на месте
#### Scenario: Захват ушёл под работающим шагом
- **GIVEN** шаг работает над захваченной записью
- **AND** за это время та же запись досталась другому захвату
- **WHEN** первый шаг доходит до записи результата
- **THEN** результат не записывается
#### Scenario: Человек снял остановку под работающим шагом
- **GIVEN** шаг работает над захваченной записью
- **AND** человек за это время снял с неё признак остановки, освободив захват
- **AND** запись досталась другому воркеру
- **WHEN** первый шаг доходит до записи результата
- **THEN** результат не записывается
### Requirement: Число отказов ограничивает повторы шага
У аудиозаписи SHALL быть число отказов. Оно MUST расти при каждом захвате и MUST
возвращаться к нулю, когда шаг завершился без отказа либо отложил работу. Рост
при захвате, а не при отказе, засчитывает попытку и записи, брошенной на
середине: шаг, уносящий с собой процесс, до объявления отказа не доходит
никогда.
**Остановка сервиса отказом не считается.** Шаг, прерванный отменой по
собственной остановке сервиса, MUST возвращать число отказов назад и MUST не
выносить записи приговора: запись не виновата в том, что нас перезапустили, и
несколько выкладок подряд иначе останавливают здоровую многочасовую запись с
приговором «отказы исчерпаны». Всякая другая причина, по которой шаг не дошёл до
объявления исхода, отказ тратит.
Запись, захваченная с числом отказов сверх заданного предела, MUST
останавливаться признаком тем, кто её захватил, и MUST не отдаваться шагу в
работу. Остановка эта видна отправителю опросом готовности наравне с прочими —
норму держит capability `intake`.
Этот сторож MUST отвечать только за повторы внутри шага. Время, проведённое
записью в рубеже, MUST мериться отдельным сторожем: одно число не справляется ни
с одной из двух обязанностей — опрос, вернувший «ещё в работе», обнуляет его, и
зависшая чужая операция опрашивается вечно, а не обнулял бы — убивал бы здоровую
запись.
#### Scenario: Запись отказывает на каждой попытке
- **GIVEN** шаг конвейера отказывает на каждой попытке
- **WHEN** запись проходит заданное число отказов
- **THEN** у неё появляется признак остановки
- **AND** следующий захват её не выдаёт
- **AND** опрос готовности отдаёт владельцу записи признак остановки
#### Scenario: Шаг уносит процесс, не объявив отказа
- **GIVEN** шаг конвейера обрывается вместе с процессом на каждой попытке
- **WHEN** запись захватывается снова заданное число раз
- **THEN** у неё появляется признак остановки
#### Scenario: Остановка сервиса отказа не тратит
- **GIVEN** шаг работает над записью
- **WHEN** сервис останавливают, и шаг прерывается отменой
- **THEN** число отказов записи прежнее
- **AND** признака остановки у записи не появляется
#### Scenario: Прошедшая запись отказов не копит
- **GIVEN** запись прошла подряд несколько рубежей без единого отказа
- **WHEN** смотрят её число отказов
- **THEN** оно не приблизилось к пределу
### Requirement: Выборка воркера владельцем не сужается
Воркер SHALL брать записи всех владельцев подряд и MUST не учитывать владельца
при выборе очередной записи.
Владелец решает, кому запись показывать, а не кому её считать. Сужение выборки
владельцем поставило бы записи одних людей в зависимость от того, кто первым
завёл учётную запись.
Оговорка про записи без владельца из требования ушла: заводить их стало нечем —
колонка владельца пустого значения не принимает, и норму держит capability
`storage`.
Владелец записи MUST переживать работу конвейера: шаг, сохраняющий свой
результат, владельца не трогает и не затирает.
#### Scenario: Записи двух владельцев проходят одним воркером
- **GIVEN** заведены записи двух разных владельцев на одном рубеже
- **WHEN** воркер забирает работу
- **THEN** ему достаются обе, в порядке заведения
#### Scenario: Шаг конвейера владельца не затирает
- **GIVEN** запись с владельцем прошла шаг конвейера
- **WHEN** шаг сохраняет свой результат
- **THEN** владелец записи остаётся прежним
## REMOVED Requirements
### Requirement: Недоставленный ответ не роняет шаг
**Reason**: Доставка ответа отправителю убрана вместе с входом Telegram, и
недоставке взяться неоткуда: обращения наружу шаг больше не делает. Обе прежние
причины недоставки — неподнятый вход отправителя и неназванный адресат записи —
описывали именно этот вход.
**Migration**: Счётчик недоставленных ответов и записи журнала о недоставке
уходят вместе с требованием; наблюдателю, построившему на них отбор, ждать от
них значений больше нечего. Исход записи виден опросом готовности и журналом
событий записи.
### Requirement: Всякая остановка сообщает отправителю
**Reason**: Обязанность сообщить требовала адресата, а адресатом был чат
Telegram. С убранным входом сообщать стало нечем и некуда, и обязанность
переходит к опросу готовности — её держит требование «Конвейер ответа
отправителю не шлёт» вместе с capability `intake`.
**Migration**: Отправитель узнаёт об остановке признаком в ответе опроса
готовности. Владелец сервиса видит остановку записью журнала и полем причины у
самой записи — как и прежде.
@@ -0,0 +1,181 @@
## ADDED Requirements
### Requirement: Пустой результат не кладётся поверх сохранённого
Хранилище SHALL не заменять сохранённое содержимое приложения записи — текст и
структуру реплик — пустым. Замена пустым MUST оставлять прежнее значение и
считаться сделанной работой, а не отказом.
Требование стоит на повторном опросе одной и той же операции распознавания.
Повтор — обычное дело: держатель захвата умер, сохранение рубежа отказало,
человек снял признак остановки в панели. Провайдер при этом вправе ответить
пустым потоком, отказом это не считается, и безусловная замена стирала бы
расшифровку живого человека — без следа и без возврата, потому что сервис
объявлен архивом и удаления по требованию не знает.
Та же защита MUST стоять у сырого ответа провайдера: разное правило у двух
хранителей одного результата читается как недосмотр, и один из них молча теряет
то, ради чего второй заведён.
Норма записана со стороны **хранилища**, а не шага: шагов, кладущих текст,
больше одного, и правило, записанное у одного из них, у остальных читалось бы
как снятое.
#### Scenario: Пустой второй ответ не стирает расшифровку
- **GIVEN** расшифровка записи сохранена
- **WHEN** ту же операцию опрашивают снова, и провайдер отвечает пустым
- **THEN** сохранённая расшифровка остаётся прежней
- **AND** шаг завершается без отказа
## MODIFIED Requirements
### Requirement: Сервис поднимается на чистом каталоге данных
Сервис SHALL приводить хранилище в рабочий вид сам: на пустом каталоге данных он
MUST завести свою схему и принимать записи своим входом — приёмом по HTTP — без
единого ручного шага до первого запуска.
Прежние данные не переносятся. Каталог, оставшийся от прежней раскладки, MUST не
читаться и не считаться источником: сервис начинает с чистого листа, и это
решение задачи, а не следствие отказа.
Схема MUST заводиться версионированными шагами, а применённый шаг MUST не
переписываться — только новым шагом. Иначе повторный запуск на уже заведённом
каталоге разошёлся бы с первым молча.
Каталог данных у сервиса MUST быть один: база и файлы записей лежат под ним
вместе, и второго пути к ним не заводится.
#### Scenario: Первый запуск на пустом каталоге
- **GIVEN** каталог данных пуст
- **WHEN** сервис запускается
- **THEN** он заводит своё хранилище и продолжает работу
- **AND** принятая следом запись доходит до состояния `done`
#### Scenario: Повторный запуск на заведённом каталоге
- **GIVEN** сервис уже запускался на этом каталоге и завёл хранилище
- **WHEN** он запускается снова
- **THEN** он не заводит схему второй раз и не теряет прежние записи
### Requirement: Пароль владельца от панели не лежит в конфигурации
Сервис SHALL не заводить в конфигурации ключа под пароль владельца от панели.
Пароль MUST задаваться самим владельцем, а хранилище MUST держать только его
отпечаток.
Требование стоит на инварианте проекта «Секрет не покидает конфиг» с другой
стороны: секрет, которого в конфигурации нет, не утекает вместе с ней и не
уезжает в выкладку третьим путём. Пароль от панели открывает все записи и все
файлы разом — это самое чувствительное, что есть у сервиса.
Приглашение завести владельца сервис MUST печатать только пока владельца нет, и
оно MUST истекать по времени. Приглашение равносильно паролю от панели, а
печатается оно в журнал контейнера, откуда строку не убрать: бессрочное отдало бы
панель всякому читателю логов навсегда.
Пока владелец пароля не задал, сервис MUST принимать записи: панель без владельца
приёму не мешает.
#### Scenario: Владелец пароля ещё не задал
- **GIVEN** каталог данных пуст и владелец панели не заведён
- **WHEN** сервис запускается
- **THEN** он принимает записи
- **AND** ни один ключ конфигурации не несёт пароля от панели
#### Scenario: Владелец заведён, приглашение больше не печатается
- **GIVEN** владелец панели заведён
- **WHEN** сервис запускается снова
- **THEN** приглашения завести владельца в журнале нет
### Requirement: Владелец задачи лежит связью с учётной записью
Хранилище SHALL держать владельца аудиозаписи отдельной колонкой — связью с
учётной записью, — и эта колонка MUST не иметь умолчания: запись, чей владелец
не назван, не достаётся никому по недосмотру схемы.
Колонка MUST не допускать пустого значения. Прежде допускала, и цену платили за
записи, принятые ботом: связи чата с учётной записью сервис не вёл. С убранным
входом заводить ничью запись стало некому, и обязательность переезжает из одного
лишь приёма в схему — туда, где её держит хранилище, а не договорённость. Разница
не косметическая: пока обязательность жила в приёме, ничью запись заводили руками
в панели, и она уходила в конвейер, стоила денег на распознавание и не доставалась
потом никому.
Владелец MUST не назначаться и не меняться конвейером.
#### Scenario: Колонка появляется на пустой базе
- **WHEN** сервис поднимается на чистом каталоге данных
- **THEN** у аудиозаписи есть колонка владельца
- **AND** умолчания у неё нет
- **AND** пустого значения она не принимает
#### Scenario: Запись без владельца не сохраняется
- **GIVEN** сервис поднят
- **WHEN** аудиозапись пытаются сохранить с пустым владельцем — приёмом,
конвейером или руками в панели
- **THEN** хранилище её не сохраняет
#### Scenario: Конвейер владельца не назначает
- **GIVEN** запись с владельцем прошла шаг конвейера
- **WHEN** смотрят её владельца
- **THEN** он прежний
### Requirement: Файл записи сужается владельцем наравне с задачей
Хранилище SHALL держать владельца и у файла записи — той же связью с учётной
записью, — и правило просмотра файлов MUST пускать к файлу только его владельца.
Владелец файла MUST назначаться при приёме, из предъявленной сессии, а колонка
файла MUST не допускать пустого значения наравне с колонкой записи. Прежде пустое
значение оставалось у файлов, заведённых конвейером для записи без владельца;
таких записей больше не заводится, и разное правило у записи и у её файла
читалось бы как недосмотр.
Файл, заведённый шагом конвейера, — приведённую копию заводит именно он —
MUST получать владельца своей записи. Иного источника владельца у файла нет, и
шаг, оставивший его пустым, упрётся в отказ сохранения: запись накопит отказы и
остановится признаком на первом же приведении.
Ссылки на файлы у записи две — на принятую копию и на приведённую, — и обе живут
до конца, но владелец файла MUST по-прежнему лежать своей колонкой, а не
выводиться через запись: файл переживает свою запись, и заведённый шагом до
сохранения записи он остаётся с владельцем и без ссылки.
Отказ наступает **на переходе по ссылке**, а не на выдаче токена файла: токен
хранилище выдаёт на предъявителя, а не на файл, и о файле при выдаче не
спрашивает вовсе. Требовать отказа при выдаче значит требовать механизма,
которого нет, — а проверка, написанная под такое требование, зеленела бы, не
касаясь пути, по которому аудио и уходит.
#### Scenario: Чужой файл не отдаётся
- **GIVEN** запись принята одним вошедшим
- **WHEN** другой вошедший идёт по ссылке на файл этой записи со своим токеном
- **THEN** содержимого он не получает
#### Scenario: Свой файл отдаётся
- **GIVEN** человек принял запись
- **WHEN** он идёт по ссылке на файл своей записи со своим токеном
- **THEN** содержимое отдаётся
#### Scenario: Файл без владельца не сохраняется
- **GIVEN** сервис поднят
- **WHEN** файл записи пытаются сохранить с пустым владельцем
- **THEN** хранилище его не сохраняет
#### Scenario: Приведённая копия получает владельца записи
- **GIVEN** запись с владельцем дошла до приведения
- **WHEN** шаг заводит приведённую копию файла
- **THEN** владельцем копии стоит владелец записи
- **AND** шаг завершается без отказа
@@ -0,0 +1,131 @@
## 1. Модель записи и отображение в хранилище
- [x] 1.1 Убрать из `entity.AudioRecord` поля адресата ответа `TgChatId` и
`TgReplyMessageId`
- [x] 1.2 Оставить константу `entity.SourceTelegram` и объявить её комментарием
историческим значением: на неё ссылается применённый шаг схемы `202608140002`,
переписывать который запрещено инвариантом проекта
- [x] 1.3 Сделать владельца записи обычной строкой вместо ссылки, допускающей
отсутствие, и провести это через `applyToRecord` и `recordToAudioRecord`
- [x] 1.4 Убрать колонки адресата из `record_mapping.go` в обоих направлениях;
заведёнными в схеме они при этом остаются
- [x] 1.5 Завести **новый** шаг схемы: колонка владельца у аудиозаписи и у файла
перестаёт принимать пустое значение. Откат шага возвращает необязательность
- [x] 1.6 Убедиться, что ни один **применённый** шаг схемы не изменён: `task
migrations` отвечает нулём
- [x] 1.7 Проверить, что конвейер заводит приведённую копию файла с владельцем
записи: без этого первый же шаг приведения упрётся в обязательность колонки
## 2. Служба расшифровки
- [x] 2.1 Убрать метод приёма записи от бота `CreateJobFromTelegram`
- [x] 2.2 Убрать из службы отправителя сообщений: поле, параметр конструктора,
отправку текста, сообщение о неудаче и запись о недоставке
- [x] 2.3 Убрать метрику недоставленных ответов вместе с её причинами
- [x] 2.4 Убрать человеческие тексты отказа, которые уходили отправителю: их
единственным читателем была отправка. Шаг, доводящий запись до конечного
рубежа, остаётся — он двигает рубеж, — и обращений наружу не делает ни одного
- [x] 2.5 Убрать из `internal/archrules` транспорт `internal/controller/tg` из
перечня транспортов: правило требует существования названных пакетов, и без
этой правки гейт краснеет удалением каталога
## 3. Вход и сборка сервиса
- [x] 3.1 Удалить пакет `internal/adapter/telegram` целиком
- [x] 3.2 Удалить транспорт `internal/controller/tg` целиком
- [x] 3.3 Удалить `telegram_build.go` и `telegram_build_test.go`
- [x] 3.4 Убрать из `main.go` сборку входа, запуск транспорта в отдельной
горутине и остановку бота при завершении
- [x] 3.5 Убрать из `internal/contract` договор об отправителе сообщений
- [x] 3.6 Убрать выставление метки `telegram` у признака поднятого входа; метка
`http` остаётся
## 4. Настройки и зависимости
- [x] 4.1 Убрать `TelegramConfig`, её умолчания и проверку обязательности ключа
`telegram.enabled`
- [x] 4.2 Убрать ключ `server.users_while_list` из структуры настроек и
умолчаний. Имя написано с опечаткой — `while` вместо `white`, — и она стоит
«Расхождением» в `docs/conventions/config.md`: удаление ключа закрывает и его
- [x] 4.3 Убрать секцию `[telegram]` из `config.example.toml` вместе с
пояснениями. Ключа списка допущенных в образце нет — это второе записанное
«Расхождение», и оно закрывается тем же удалением
- [x] 4.4 Прогнать `go mod tidy` и убедиться, что `go-telegram-bot-api` ушёл из
`go.mod` и `go.sum`
## 5. Проверки
- [x] 5.1 Поправить тесты, опирающиеся на убранный вход: приём, владение,
конвейер, метрики, недоставка, завершение работы
- [x] 5.2 Оставить проверку того, что запись без владельца не заводится: приём
без учётной записи отвечает `403` и не заводит ни файла, ни записи
- [x] 5.3 Оставить проверку того, что запись без владельца не достаётся опросом:
ответ тот же, что и на неизвестный идентификатор
- [x] 5.4 `task gate` зелёный целиком
- [x] 5.5 Поведенческая проверка на живом сервисе: подъём с файлом настроек без
секции `[telegram]`, приём записи по HTTP, опрос готовности, признак поднятого
входа в метриках
## 6. Документы канона
- [x] 6.1 Паспорт: убрать потребителя «Пользователь Telegram» и сценарии 3 и 4;
поправить строку об основном входе и сценарий 6 — сообщения о неудаче
отправитель больше не получает, а видит исход опросом. Убранный вход записать
событием с датой, как записаны прочие сдвиги границы
- [x] 6.2 `CLAUDE.md`: убрать инвариант «Бот отвечает только тем, кто в белом
списке» (**critical**) целиком; переписать инвариант «Остановленная запись
сообщает отправителю, какой бы ни была причина» (**major**) в терминах опроса
готовности; переписать инвариант «Принятая запись не теряется молча»
(**major**) — он требует сообщить пользователю и называет состояние `failed`,
которого нет с прошлой задачи; снять «и без ответа отправителю» из инварианта о
держателе захвата;
поправить раздел «Что это» (входов больше не два), «Стек» (зависимость ушла) и
запрет «Боевым токеном бота не запускаться» вместе с рецептом локального
подъёма через `telegram.enabled = false`
- [x] 6.3 `docs/architecture.md`: поправить обзор capability поимённо — буллеты
`intake` (признак включения входа), `pipeline` (недоставленный ответ), `access`
(запись из Telegram без владельца), строку «поведение прочих узлов, включая
приём из Telegram, живёт только в коде», принцип «бот, HTTP-сервер и воркеры в
одном бинарнике» и строку «через него идут оба входа» в «Единых точках
проекта»; `docs/security.md`
убрать вход из периметра; `docs/database.md` — сказать про колонки, оставшиеся
без кода; `docs/conventions/` — снять ключи бота и закрыть оба «Расхождения»
про `users_while_list`
- [x] 6.4 `docs/review.md`: снять род узла «транспорт `internal/controller/tg`» и
«клиент внешнего сервиса `adapter/telegram`» из типовых узлов, ложноположительное
про запись без владельца, вопрос «через него идут оба входа», триггер метки
«трогает оба входа сразу» и рецепт живого прогона через `telegram.enabled = false`
- [x] 6.5 `README.md`: убрать вход из описания сервиса и из настроек
- [x] 6.6 Проза актуальных спек, которую дельты не правят: разделы Purpose у
`intake`, `pipeline` и `access` — дельты правят только требования, и проза
иначе уедет в архив с обещаниями про бота
## Критерии приёмки
Постановка пришла текстом и критериев не назвала. Ниже — **предложенные**;
приняты они после ответа человека на чекпоинте.
1. Ни одного упоминания входа Telegram нигде в дереве, кроме мест, где оно
законно. Оракул: `grep -ri telegram` по всему репозиторию; законны ровно
четыре места — применённые шаги схемы и константа источника, архив изменений
`openspec/changes/archive/`, записи решений `docs/adr/` и историческая часть
журнала ревью. Всё прочее — находка. Оракул нарочно шире кода: прошлые
удаления теряли документы именно потому, что их сверяли грепом по `internal/`.
2. Применённые шаги схемы не переписаны, колонки `tg_chat_id`,
`tg_reply_message_id` и значение `telegram` перечня источников на месте.
Изменение добавляет ровно один новый шаг — обязательность владельца. Оракул:
`task migrations` отвечает нулём, `git diff` по каталогу шагов показывает
только добавленный файл.
3. Запись без владельца завести нечем ни одним путём. Оракулы: тест приёма, где
сессия не даёт учётной записи, — ответ `403`, ни файла, ни аудиозаписи не
заведено; тест хранилища — сохранение записи и файла с пустым владельцем
отклоняется схемой.
4. Отправитель узнаёт об остановке опросом готовности. Оракул: тест опроса на
остановленной записи — поле рубежа несёт достигнутый рубеж, поле остановки
несёт истину, машинного текста отказа в ответе нет.
5. Сервис поднимается с файлом настроек без секции `[telegram]` и принимает
запись по HTTP. Оракул: живой запуск по разделу команд `CLAUDE.md`, `POST
/api/audio` отвечает `201`, `GET /api/status/:id` отвечает `200`.
6. Признак поднятого входа несёт метку `http` и не несёт метки `telegram`.
Оракул: чтение адреса метрик у поднятого сервиса.
7. `task gate` зелёный целиком.
+23 -30
View File
@@ -10,12 +10,10 @@ OIDC, чем предъявляется сессия, что её прекращ
только на вопрос «узнан ли пришедший», но и на «чьё он смотрит». Запись из веба
принадлежит тому, кто её принёс, и чужая неотличима от несуществующей.
Записи, принятые ботом, владельца не имеют вовсе и по API не достаются никому:
связи чата Telegram с учётной записью приложения сервис не ведёт, её заводит
отдельная задача.
Вход из Telegram эта capability не нормирует: бот проверяет отправителя своим
белым списком, и с учётной записью приложения тот список не связан.
Записи без владельца у сервиса не бывает: колонка владельца пустого значения не
принимает, и норму эту держит capability `storage`. Прежде такие записи заводил
вход Telegram — связи чата с учётной записью сервис не вёл, — и 2026-08-14 вход
убран вместе с этим исключением.
## Requirements
### Requirement: Вход через внешнего провайдера
@@ -350,13 +348,14 @@ MUST не делать. Кто допущен, определяет правил
### Requirement: У записи есть владелец, и чужую ей не отдают
Сервис SHALL заводить у каждой записи, принятой **по HTTP**, — владельца, то
есть учётную запись, от имени которой запись принята, — и MUST отдавать данные
такой записи только её владельцу. Владелец назначается один раз, при приёме, и
MUST не меняться у записи, у которой владелец есть: совместного доступа, ролей и
передачи записи другому сервис не знает. Оговорка не случайна — назначить
владельца записи, у которой его нет, вправе задача, заводящая связь чата
Telegram с учётной записью.
Сервис SHALL заводить у каждой принятой записи владельца — учётную запись, от
имени которой запись принята, — и MUST отдавать данные такой записи только её
владельцу. Запись без владельца MUST не заводиться ничем — ни приёмом, ни конвейером, ни
рукой в панели: колонка владельца пустого значения не принимает, и норму эту
держит capability `storage`.
Владелец назначается один раз, при приёме, и MUST не меняться: совместного
доступа, ролей и передачи записи другому сервис не знает.
Владелец MUST браться из предъявленной сессии и ниоткуда больше. Владелец,
пришедший полем запроса, дал бы всякому вошедшему право завести запись на чужое
@@ -368,16 +367,12 @@ Telegram с учётной записью.
что разграничение прячет. Каким именно ответом это выражено, нормирует
capability `intake`: там живёт адрес опроса, и держатель нормы обязан быть один.
Пустой владелец MUST не совпадать ни с одной записью — ни со своей, ни с чужой,
ни с ничьей. Правило записано со стороны **спрашивающего**, а не со стороны
записи: обязательность владельца, которую держит одна лишь подпись метода, пустую
строку пропускает, и первый же вызывающий без учётной записи получил бы ровно
множество записей без владельца, то есть все записи бота.
Записи, принятые из Telegram, владельца не имеют: связи чата с учётной записью
приложения сервис не ведёт. Такая запись MUST не доставаться по API никому —
ответ на неё тот же, что и на несуществующую, — а её расшифровка уезжает
отправителю в чат, как и прежде.
Пустой владелец MUST не совпадать ни с одной записью. Правило записано со стороны
**спрашивающего** и остаётся в силе, хотя записей без владельца в хранилище
больше нет: спрашивающий с пустым владельцем — это вызов, у которого нет учётной
записи, и отвечать ему надо отказом, а не выборкой. Держится оно отдельно от
схемы намеренно: схема запрещает **заводить** ничью запись, а это правило
запрещает **спрашивать** ничьим именем, и одно другое не заменяет.
#### Scenario: Своя запись доступна
@@ -396,15 +391,13 @@ capability `intake`: там живёт адрес опроса, и держат
- **WHEN** запрос на приём записи несёт своё значение владельца
- **THEN** владельцем принятой записи становится предъявитель сессии
#### Scenario: Запись из Telegram не достаётся по API
#### Scenario: Ничью запись завести нечем
- **GIVEN** запись принята ботом
- **WHEN** её состояние спрашивает вошедший человек
- **THEN** ответ тот же, что и на неизвестный идентификатор
- **WHEN** запись пытаются завести с пустым владельцем
- **THEN** хранилище её не сохраняет
#### Scenario: Пустой владелец не открывает ничего
- **GIVEN** заведены три задачи: своя, чужая и принятая ботом
- **GIVEN** заведены две записи: своя и чужая
- **WHEN** состояние каждой спрашивают с пустым владельцем
- **THEN** ответ на все три тот же, что и на неизвестный идентификатор
- **THEN** ответ на обе тот же, что и на неизвестный идентификатор
+33 -128
View File
@@ -6,12 +6,9 @@
записью, что уезжает в ответ и что происходит, когда запись не удалось
прочитать. Плюс наличие входов: с каким из них сервис вправе подняться.
Приём по существу описан пока **только для HTTP** — того, что нормируют
проверки. Про вход Telegram нормировано одно: настроен он или нет и что из этого
следует для подъёма. Кто допущен к боту и как забирается присланная им запись,
требованиями по-прежнему не описано — требование, написанное без проверки, это
предположение, а не норма. Первая задача, которая трогает поведение приёма из
Telegram, дописывает его сюда.
Вход у сервиса один — приём по HTTP, — и описан он тем, что нормируют проверки.
Второй вход, Telegram, убран 2026-08-14 вместе со своими требованиями; его
возвращение заводит их заново, вместе со связью чата и учётной записи.
## Requirements
### Requirement: Приём записи по HTTP
@@ -40,10 +37,12 @@ Telegram, дописывает его сюда.
Куда именно ложится принятая запись, приёму не принадлежит: раскладку выбирает
хранилище, и нормирует её capability `storage`.
Владельцем принятой записи приём SHALL назначать предъявителя сессии. Проверка
стоит здесь, а не только в схеме хранилища: колонка владельца допускает пустое
значение ради записей из Telegram, и приём по HTTP — то место, где
обязательность держится.
Владельцем принятой записи приём SHALL назначать предъявителя сессии. Обязательность
владельца при этом MUST держаться и схемой хранилища: колонка владельца пустого
значения не принимает вовсе, и норму эту держит capability `storage`. Проверка в
приёме от этого не лишняя — она отвечает отправителю понятным отказом до того, как
запись попадёт в память, а схема отвечала бы отказом сохранения после укладки
файла.
Предъявитель, чья сессия не даёт учётной записи пользователя, MUST получать
отказ `403` и MUST получать его **до чтения тела** — там же, где стоит отказ по
@@ -154,11 +153,9 @@ Telegram, дописывает его сюда.
нормирует требование ниже; наружу расширение выходит только приведённым к
известному виду — этому отдано отдельное требование.
Сценарии судят приём по HTTP, потому что имя, данное отправителем, доходит до
сервиса только оттуда: из Telegram приходит путь, выданный самим Telegram, а не
имя человека. Правка при этом ложится на общий шаг заведения задачи, через
который идут оба входа, поэтому своей нормы приём из Telegram здесь не получает —
её напишет задача, которая тронет его поведение.
Оговорка про второй вход из требования ушла вместе с ним: имя, данное
отправителем, доходит до сервиса единственным путём — приёмом по HTTP, — и
сценарии судят именно его.
#### Scenario: Имя записи не видно в журнале принятой записи
@@ -265,15 +262,15 @@ Telegram, дописывает его сюда.
Остановленная запись MUST отдавать рубеж, на котором она остановлена, и MUST
нести признак остановки отдельным полем `halted` со значением истины. Машинный
текст отказа MUST в ответ не попадать: он принадлежит журналу владельца сервиса,
а не отправителю. Отправитель узнаёт о неудаче ответом там, откуда пришла
запись, — это нормирует capability `pipeline`.
а не отправителю. Этот адрес — **единственное** место, где отправитель узнаёт о
неудаче: доставки ответа отправителю у сервиса больше нет, и признак остановки
здесь несёт всю обязанность целиком.
Отказ без сессии MUST не зависеть от того, есть такая запись или нет: иначе по
кодам ответа перебирается список заведённых записей.
Запись, принадлежащая другому, MUST отвечать тем же, чем отвечает неизвестный
идентификатор, — кодом `404` и тем же телом. То же MUST относиться к записи без
владельца: запись, принятая ботом, по этому адресу не достаётся никому.
идентификатор, — кодом `404` и тем же телом.
#### Scenario: Запись найдена
@@ -325,117 +322,25 @@ Telegram, дописывает его сюда.
### Requirement: Поднятые входы видны наблюдателю
Сервис SHALL отдавать признак поднятости по каждому входу приёма отдельной
метрикой. Признак MUST выставляться при сборке входа и MUST различать поднятый
вход и неподнятый.
метрикой и MUST выставлять метку только тому входу, который у сервиса есть.
Метки убранного входа в метриках MUST не быть вовсе: признак со значением нуля
читался бы как «вход есть, но не поднялся», то есть как поломка, а вечная
единица рядом с ним — как исправность того, чего нет.
Требование стоит на том, что иначе потерянный вход не виден ничем: проба
здоровья отвечает «сервис работает» и при неподнятом боте, а запись журнала
живёт до ротации и вопрос «работает ли вход сейчас» не отвечает. Метрика —
единственный канал наблюдения, который у владельца автоматизирован.
Проверяемое здесь одно — **набор меток**, и это честнее прежнего. Вход остался
один, страница метрик отдаётся тем же сервером, что и приём, и значение нуля у
единственной метки недостижимо: чтобы прочитать признак, надо дотянуться до
входа, о котором он сообщает. Прежнее обоснование — «иначе потерянный вход не
виден ничем» — было верно, пока входов было два; сегодня неподнятый вход виден
неудачей чтения самих метрик.
#### Scenario: Вход Telegram не поднят
Различать поднятый и неподнятый вход признак MUST снова, как только входов у
сервиса станет больше одного.
- **GIVEN** сервис поднялся без Telegram
#### Scenario: В метриках только оставшийся вход
- **GIVEN** сервис поднялся
- **WHEN** наблюдатель читает метрики
- **THEN** признак поднятости входа Telegram равен нулю
- **AND** признак поднятости входа HTTP равен единице
### Requirement: Признак включения решает, поднимается ли вход Telegram
Намерение владельца SHALL объявляться отдельным признаком включения входа
Telegram, а ключ доступа MUST означать только доступ. При выключенном входе
сервис MUST подниматься без Telegram и MUST не смотреть на ключ доступа вовсе.
При включённом входе пустой ключ MUST быть отказом старта: сообщение называет имя
незаполненного ключа и MUST не нести его значения.
Признак включения MUST быть в настройках задан. Умолчания у него нет: файл, где
признака нет вовсе, негоден, и сервис MUST выходить с ошибкой настройки, назвав
недостающий ключ. Умолчание здесь было бы угаданным намерением, а признак заведён
затем, чтобы намерение объявляли: любое умолчание делает одну из двух ошибок
тихой — либо бот молча пропадает, либо файл без признака молча работает.
Выключенный вход MUST быть назван в журнале **ровно одной** записью уровня `INFO`
при старте. Это выбор владельца, а не отклонение, и предупреждать о нём не о чем;
предупреждение остаётся за тем, чего владелец не выбирал.
При включённом входе сервис SHALL подниматься, когда вход поднять не удалось, и
MUST продолжать работу оставшимся входом: приём по HTTP, опрос готовности и
конвейер расшифровки работают в полном объёме. Неподнятый вход MUST быть назван в
журнале **ровно одной** записью уровня `WARN` при старте — с причиной и без
значения ключа.
Исключение одно, и оно проходит по тому, **ответил ли Telegram**. Ответ «такого
бота нет» — ошибка настройки: бот по этому ключу не появится ни от ожидания, ни
от повтора, и старт MUST кончаться отказом. Сервис, молча потерявший бота после
опечатки в ключе, перестаёт отвечать своим отправителям, и узнать об этом было бы
неоткуда.
Всё прочее — недоступность: сеть, DNS, авария Bot API, истёкший срок ожидания.
Она MUST не влиять на подъём. Основной вход сервиса — не Telegram, и ронять его
целиком из-за чужой аварии нельзя: перезапуск в такую минуту оставил бы без
работы и приём по HTTP, и панель, и конвейер, которому Telegram не нужен вовсе.
Ожидание при сборке MUST быть ограничено сроком. Без него недоступность
неотличима от подъёма: обращение к Telegram стоит на пути старта, и молчащий
собеседник останавливал бы его бессрочно — без записи, без порта и без пробы
здоровья.
Требование нормирует **наличие входа**, а не приём из него.
#### Scenario: Вход выключен
- **GIVEN** в настройках сервиса вход Telegram выключен
- **WHEN** сервис запускается
- **THEN** он поднимается и принимает записи по HTTP
- **AND** конвейер расшифровки работает
- **AND** бот не заведён, а в журнале ровно одна запись уровня `INFO` о том, что
вход выключен настройкой
#### Scenario: Вход выключен, а ключ доступа задан
- **GIVEN** в настройках сервиса вход Telegram выключен
- **AND** ключ доступа при этом заполнен
- **WHEN** сервис запускается
- **THEN** он поднимается без Telegram, и бот не заводится
- **AND** к Telegram не уходит ни одного обращения
#### Scenario: Вход включён, а ключа доступа нет
- **GIVEN** в настройках сервиса вход Telegram включён
- **AND** ключ доступа пуст
- **WHEN** сервис запускается
- **THEN** старт кончается отказом
- **AND** сообщение об отказе называет имя незаполненного ключа
#### Scenario: Признака включения в настройках нет
- **GIVEN** в настройках сервиса нет признака включения входа Telegram
- **AND** ключ доступа заполнен и Telegram признаёт по нему бота
- **WHEN** сервис запускается
- **THEN** старт кончается отказом настройки
- **AND** сообщение об отказе называет недостающий ключ
#### Scenario: Вход включён и ключ годен
- **GIVEN** в настройках сервиса вход Telegram включён
- **AND** стоит ключ, по которому Telegram признаёт бота
- **WHEN** сервис запускается
- **THEN** он поднимается и работает обоими входами
#### Scenario: Telegram не отвечает
- **GIVEN** в настройках сервиса вход Telegram включён и ключ непуст
- **AND** Telegram недоступен либо не отвечает дольше отведённого срока
- **WHEN** сервис запускается
- **THEN** он поднимается и принимает записи по HTTP
- **AND** бот не заведён, а в журнале запись уровня `WARN` с причиной
- **AND** запись не несёт значения ключа
#### Scenario: Telegram ответил, что такого бота нет
- **GIVEN** в настройках сервиса вход Telegram включён и ключ непуст
- **AND** Telegram отвечает отказом на этот ключ
- **WHEN** сервис запускается
- **THEN** старт кончается отказом
- **AND** ни журнал, ни текст отказа не несут значения ключа
- **THEN** признак поднятости несёт метку входа HTTP со значением единицы
- **AND** метки убранного входа Telegram в метриках нет вовсе
+57 -127
View File
@@ -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** в журнале владельца сервиса есть запись об остановке с причиной
+68 -16
View File
@@ -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** шаг завершается без отказа
-100
View File
@@ -1,100 +0,0 @@
package main
import (
"errors"
"log/slog"
"git.vakhrushev.me/av/transcriber/internal/adapter/telegram"
"git.vakhrushev.me/av/transcriber/internal/config"
"git.vakhrushev.me/av/transcriber/internal/contract"
"git.vakhrushev.me/av/transcriber/internal/metrics"
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
)
// buildTelegram заводит вход Telegram при старте. Клиент собирается **один
// раз** и достаётся обоим — отправителю ответов и транспорту бота. Пока его
// строили порознь, два пути одного старта разошлись: один ронял процесс на
// негодном токене, другой терпел, и согласовывать их приходилось руками.
func buildTelegram(cfg config.TelegramConfig, logger *slog.Logger) (*tgbotapi.BotAPI, contract.TelegramMessageSender, error) {
return telegramFromConfig(cfg, telegram.NewBot, logger)
}
// telegramFromConfig решает, поднимать ли вход вообще, и делает это **до**
// всякого обращения к Telegram. Намерение объявляет признак включения; ключ
// доступа при выключенном входе не смотрится вовсе.
//
// Сборка клиента приходит параметром, и это не украшение. Главное утверждение
// выключенного входа — «обращения не уходит ни одного», — иначе непроверяемо:
// адрес Bot API живёт внутри `telegram.NewBot`, и проверка, судящая по исходу,
// осталась бы зелёной и тогда, когда ветка выключенного входа встала **после**
// обращения. Прогон с заполненным ключом ушёл бы в живой Telegram боевым
// токеном, а заметить это было бы нечем.
//
// Шов — подпись, а не интерфейс: реализация у него одна, и заводить тип ради
// неё значит заводить понятие там, где хватает функции.
func telegramFromConfig(
cfg config.TelegramConfig,
newBot func(string, *slog.Logger) (*tgbotapi.BotAPI, error),
logger *slog.Logger,
) (*tgbotapi.BotAPI, contract.TelegramMessageSender, error) {
if !cfg.Enabled {
metrics.IntakeUpGauge.WithLabelValues("telegram").Set(0)
// Уровень «к сведению», а не «может стать проблемой»: это выбор
// владельца, а не отклонение. Предупреждение остаётся за тем, чего
// владелец не выбирал, — недоступностью Telegram.
logger.Info("Telegram bot is not started", "reason", "telegram intake is disabled in configuration")
return nil, telegram.NewAbsentMessageSender(), nil
}
bot, err := newBot(cfg.BotToken, logger)
return telegramFromBot(bot, err, logger)
}
// telegramFromBot решает, чем обернулась сборка клиента, и это решение —
// единственное содержательное здесь. Оно отделено от самого обращения к
// Telegram намеренно: обращение ходит в сеть и в проверке недоступно, а
// разрез проверять надо, иначе его молча вернут к прежнему виду.
//
// Разрез проходит по тому, **ответил ли Telegram**, и это решение владельца
// от 2026-08-13: недоступность Telegram на старт сервиса не влияет. Сюда
// доходит только включённый вход: выключенный отсеян выше, до обращения.
//
// - Telegram ответил отказом либо ключ доступа пуст — ошибка настройки: бота
// по такому ключу не существует, ждать нечего, и старт роняется. Молча
// потерянный бот перестаёт отвечать отправителям, а узнать об этом было бы
// неоткуда;
// - до Telegram не дошли — недоступность: сеть, DNS, авария Bot API,
// истёкший срок ожидания. Сервис поднимается без Telegram, потому что
// основной вход у него другой, и класть его из-за чужой аварии нельзя.
//
// Ветка пустого ключа по построению недостижима — его ловит проверка настроек
// раньше, — но исход у неё **тот же**, что у проверки, и потому она оставлена.
// Убрав её, мы отправили бы пустой ключ в общий случай `err != nil`, то есть в
// «недоступность»: обход проверки настроек дал бы тихий подъём без бота — ровно
// то, против чего написано это изменение.
//
// Ядро в обоих мягких случаях получает непустого отправителя: необязательная
// зависимость, доехавшая до него нулём, уронила бы первую же задачу из
// Telegram.
func telegramFromBot(
bot *tgbotapi.BotAPI,
err error,
logger *slog.Logger,
) (*tgbotapi.BotAPI, contract.TelegramMessageSender, error) {
var apiErr *tgbotapi.Error
switch {
case errors.Is(err, telegram.ErrEmptyToken), errors.As(err, &apiErr):
// Отказ токена не несёт: его чистит единая точка `telegram.NewBot`.
return nil, nil, err
case err != nil:
metrics.IntakeUpGauge.WithLabelValues("telegram").Set(0)
logger.Warn("Telegram bot is not started", "reason", "telegram is unreachable", "error", err)
return nil, telegram.NewAbsentMessageSender(), nil
default:
metrics.IntakeUpGauge.WithLabelValues("telegram").Set(1)
return bot, telegram.NewTelegramMessageSender(bot, logger), nil
}
}
-208
View File
@@ -1,208 +0,0 @@
package main
import (
"bytes"
"errors"
"log/slog"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
"github.com/prometheus/client_golang/prometheus/testutil"
"git.vakhrushev.me/av/transcriber/internal/adapter/telegram"
"git.vakhrushev.me/av/transcriber/internal/config"
"git.vakhrushev.me/av/transcriber/internal/contract"
"git.vakhrushev.me/av/transcriber/internal/metrics"
)
// Разрез сборки — то, ради чего написано изменение, — до этих проверок не
// держался ничем: инвертируй его, и весь набор оставался зелёным.
//
// Судится решение, а не обращение к Telegram: обращение ходит в сеть, а
// боевым токеном запускаться запрещено.
func journalLogger() (*slog.Logger, *bytes.Buffer) {
journal := &bytes.Buffer{}
return slog.New(slog.NewTextHandler(journal, &slog.HandlerOptions{Level: slog.LevelDebug})), journal
}
// Пустой ключ доступа при включённом входе — ошибка настройки, а не режим.
// Прежде он давал мягкий подъём без бота, и это был тот самый второй смысл,
// который изменение разводит с первым: «вход выключен» объявляет признак.
//
// Ветка по построению недостижима — пустой ключ ловит проверка настроек, — но
// исход у неё тот же, и потому она проверяется: убрав её, мы отправили бы
// пустой ключ в общий случай, то есть в «недоступность», и обход проверки дал
// бы тихий подъём без бота.
func TestTelegramFromBotOnEmptyTokenReturnsError(t *testing.T) {
logger, journal := journalLogger()
bot, sender, err := telegramFromBot(nil, telegram.ErrEmptyToken, logger)
require.ErrorIs(t, err, telegram.ErrEmptyToken, "отказ поднят вызывающему нетронутым")
assert.Nil(t, bot)
assert.Nil(t, sender, "заглушка тут не подставляется: это ошибка, а не режим")
assert.NotContains(t, journal.String(), "Telegram bot is not started",
"о законном отсутствии входа речи нет: вход заявлен и не обеспечен ключом")
}
// Выключенный вход — решение владельца: сервис встаёт одним входом, ядро
// получает заглушку, а владелец узнаёт об этом одной записью «к сведению».
func TestTelegramFromConfigDisabledKeepsStarting(t *testing.T) {
logger, journal := journalLogger()
cfg := config.TelegramConfig{Enabled: false, BotToken: ""}
bot, sender, err := telegramFromConfig(cfg, failingBuild(t), logger)
require.NoError(t, err, "выключенный вход старт не роняет")
assert.Nil(t, bot, "клиента нет — транспорт не поднимется")
require.NotNil(t, sender, "ядро получает отправителя всегда, а не ноль")
require.ErrorIs(t, sender.Send("любой ответ", 1, nil), contract.ErrDeliveryChannelDown,
"заглушка говорит, что канал не поднят")
written := journal.String()
assert.Contains(t, written, "Telegram bot is not started", "о неподнятом входе сказано")
assert.Contains(t, written, "disabled in configuration", "причина названа")
assert.Contains(t, written, "level=INFO",
"уровень «к сведению»: это выбор владельца, а не отклонение")
assert.Equal(t, 1, strings.Count(written, "Telegram bot is not started"),
"запись ровно одна: вторая была бы записью о том же факте")
// Ряд метрики заводится первым обращением к нему. Пропади эта строка из
// ветки — на `/metrics` не появится ряда вовсе, и правило наблюдения вида
// «равен нулю» не сработает на отсутствующем ряде: потерянный вход снова
// станет невидимым.
assert.Zero(t, testutil.ToFloat64(metrics.IntakeUpGauge.WithLabelValues("telegram")),
"признак поднятости входа выставлен в ноль")
}
// Главное утверждение выключенного входа судится **счётчиком обращений**, а не
// исходом. «Бот не заведён, отказа нет» остаётся верным и тогда, когда ветка
// встала после обращения, а обращение ушло в живой Telegram боевым токеном.
func TestTelegramFromConfigDisabledNeverBuildsClient(t *testing.T) {
logger, _ := journalLogger()
// Ключ заполнен — то самое состояние, ради которого два значения и
// разводятся. Значение заведомо ненастоящее.
cfg := config.TelegramConfig{Enabled: false, BotToken: "123456:AA-fake"}
calls := 0
build := func(string, *slog.Logger) (*tgbotapi.BotAPI, error) {
calls++
return nil, nil
}
_, _, err := telegramFromConfig(cfg, build, logger)
require.NoError(t, err)
assert.Zero(t, calls, "к Telegram не уходит ни одного обращения")
}
// failingBuild роняет проверку, если сборку клиента всё-таки позвали.
func failingBuild(t *testing.T) func(string, *slog.Logger) (*tgbotapi.BotAPI, error) {
t.Helper()
return func(string, *slog.Logger) (*tgbotapi.BotAPI, error) {
t.Fatal("сборка клиента позвана при выключенном входе")
return nil, nil
}
}
// Telegram ответил, что такого бота нет, — ошибка настройки, а не режим: бот по
// этому токену не появится ни от ожидания, ни от повтора, и старт роняется.
func TestTelegramFromBotOnRejectedTokenReturnsError(t *testing.T) {
logger, journal := journalLogger()
rejected := &tgbotapi.Error{Code: 401, Message: "Unauthorized"}
bot, sender, err := telegramFromBot(nil, rejected, logger)
require.ErrorIs(t, err, rejected, "отказ поднят вызывающему нетронутым")
assert.Nil(t, bot)
assert.Nil(t, sender, "заглушка тут не подставляется: это ошибка, а не режим")
assert.NotContains(t, journal.String(), "Telegram bot is not started",
"о законном отсутствии входа речи нет: вход заявлен и отвергнут")
}
// До Telegram не дошли — недоступность: на подъём сервиса она не влияет.
// Решение владельца 2026-08-13; иначе чужая авария кладёт и основной вход, и
// панель, и конвейер, которому Telegram не нужен вовсе.
func TestTelegramFromBotOnUnreachableTelegramKeepsStarting(t *testing.T) {
logger, journal := journalLogger()
unreachable := errors.New("dial tcp: connection refused")
bot, sender, err := telegramFromBot(nil, unreachable, logger)
require.NoError(t, err, "недоступность Telegram старт не роняет")
assert.Nil(t, bot, "клиента нет — транспорт не поднимется")
require.NotNil(t, sender, "ядро получает отправителя всегда, а не ноль")
require.ErrorIs(t, sender.Send("любой ответ", 1, nil), contract.ErrDeliveryChannelDown)
written := journal.String()
assert.Contains(t, written, "Telegram bot is not started", "о неподнятом входе сказано")
assert.Contains(t, written, "unreachable", "причина названа")
assert.Contains(t, written, "level=WARN")
}
// Годный токен даёт настоящего отправителя и клиента для транспорта: прежний
// путь сохранён, и о неподнятом боте не говорится ничего.
func TestTelegramFromBotOnLiveBotGivesRealSender(t *testing.T) {
logger, journal := journalLogger()
live := &tgbotapi.BotAPI{}
bot, sender, err := telegramFromBot(live, nil, logger)
require.NoError(t, err)
assert.Same(t, live, bot, "транспорт получит того же клиента, что и отправитель")
require.NotNil(t, sender)
_, stub := sender.(*telegram.AbsentMessageSender)
assert.False(t, stub, "это настоящий отправитель, а не заглушка")
assert.NotContains(t, journal.String(), "Telegram bot is not started")
}
// Включённый вход — основной путь нового кода, и связка «собрать клиента →
// разобрать исход» покрывается только целиком: обе её половины по отдельности
// проверены, а переданное не то поле или потерянный результат видны лишь здесь.
func TestTelegramFromConfigEnabledPassesTokenAndOutcome(t *testing.T) {
logger, _ := journalLogger()
cfg := config.TelegramConfig{Enabled: true, BotToken: "123456:AA-fake"}
live := &tgbotapi.BotAPI{}
var seen string
build := func(token string, _ *slog.Logger) (*tgbotapi.BotAPI, error) {
seen = token
return live, nil
}
bot, sender, err := telegramFromConfig(cfg, build, logger)
require.NoError(t, err)
assert.Equal(t, cfg.BotToken, seen, "в сборку уходит ключ доступа из настроек")
assert.Same(t, live, bot, "собранный клиент доезжает до транспорта")
require.NotNil(t, sender)
_, stub := sender.(*telegram.AbsentMessageSender)
assert.False(t, stub, "это настоящий отправитель, а не заглушка")
}
// Обратная сторона той же связки: отказ сборки доезжает до разбора исхода, а не
// теряется по дороге.
func TestTelegramFromConfigEnabledCarriesBuildFailure(t *testing.T) {
logger, _ := journalLogger()
cfg := config.TelegramConfig{Enabled: true, BotToken: "123456:AA-fake"}
rejected := &tgbotapi.Error{Code: 401, Message: "Unauthorized"}
build := func(string, *slog.Logger) (*tgbotapi.BotAPI, error) {
return nil, rejected
}
bot, sender, err := telegramFromConfig(cfg, build, logger)
require.ErrorIs(t, err, rejected, "отказ сборки доехал до вызывающего")
assert.Nil(t, bot)
assert.Nil(t, sender, "это ошибка настройки, а не режим")
}