удалён вход Telegram, владелец записи стал обязателен в схеме
- убраны клиент бота, транспорт обновлений, отправитель сообщений, сборка входа при старте, секция настроек и зависимость go-telegram-bot-api; из конвейера ушла доставка ответа отправителю — исход виден опросом готовности. Колонки адресата и значение источника остались в схеме: применённые шаги не переписываются - шаг 202608140003 запрещает пустого владельца у аудиозаписи и у файла; существующие строки он не проверяет, и это принято сознательно — искать их надо запросом до выкладки - ревью нашло два пред-существующих дефекта, оба закрыты: пустой второй ответ распознавателя стирал сохранённую расшифровку, а пустая расшифровка перестала быть заметной вместе с убранной доставкой. Попутно поднят golang.org/x/image до v0.45.0 — красный шаг vulns, воспроизводился и на чистом master
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
|
||||
- **Дата:** 2026-08-13
|
||||
- **Источник:** openspec/changes/archive/2026-08-13-telegram-enabled-flag/design.md
|
||||
- **Статус:** устарело — вход Telegram убран решением [ADR-2026-08-15-telegram-intake-removed-temporarily](ADR-2026-08-15-telegram-intake-removed-temporarily.md); довод устоял и понадобится возврату входа
|
||||
|
||||
## Решение
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
- **Дата:** 2026-08-13
|
||||
- **Источник:** openspec/changes/archive/2026-08-13-start-without-telegram-token/design.md
|
||||
- **Статус:** устарело — вход Telegram убран решением [ADR-2026-08-15-telegram-intake-removed-temporarily](ADR-2026-08-15-telegram-intake-removed-temporarily.md); довод устоял и понадобится возврату входа
|
||||
|
||||
## Решение
|
||||
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
# Обязательность владельца держит схема, а не приём
|
||||
|
||||
- **Дата:** 2026-08-15
|
||||
- **Источник:** openspec/changes/archive/2026-08-15-remove-telegram-intake/design.md,
|
||||
разделы «Схема теряет только необязательность владельца» и «Владелец записи
|
||||
перестаёт быть необязательным и в модели»
|
||||
|
||||
## Решение
|
||||
|
||||
Колонка владельца у аудиозаписи и у файла перестала принимать пустое значение —
|
||||
шагом схемы `202608140003`. Ничья запись не заводится ничем: ни приёмом, ни
|
||||
конвейером, ни рукой в панели. Поле владельца в модели стало обычной строкой
|
||||
вместо ссылки, которой позволено отсутствовать.
|
||||
|
||||
Существующие строки шаг **не проверяет**, и это принято сознательно: искать ничьи
|
||||
строки надо запросом до выкладки.
|
||||
|
||||
## Почему
|
||||
|
||||
Цитата источника:
|
||||
|
||||
> **Держать обязательность одним приёмом, схему не трогать.** Так было задумано
|
||||
> сперва, и это оставляло дыру: ничью запись заводили руками в панели, она
|
||||
> уходила в конвейер, стоила денег на распознавание и не доставалась потом
|
||||
> никому. Решение владельца от 2026-08-14 — обязательность держит схема.
|
||||
|
||||
Прежнее решение было обратным и записано спекой `storage`: «Колонка MUST
|
||||
допускать пустое значение… Обязательность для приёма по HTTP держит сама
|
||||
capability `intake`, а не схема». Цену за него платили записи входа Telegram — у
|
||||
них владельца не было по построению. Вход убран
|
||||
([ADR-2026-08-15-telegram-intake-removed-temporarily](ADR-2026-08-15-telegram-intake-removed-temporarily.md)),
|
||||
исключение исчезло вместе с ним, и владелец сервиса подтвердил, что записей без
|
||||
владельца в боевой базе нет.
|
||||
|
||||
Про непроверку существующих строк цитата источника:
|
||||
|
||||
> **Проверяется это запросом, а не прогоном шага**, и разница выяснилась ревью с
|
||||
> оракулом: хранилище держит обязательность связи проверкой записи при
|
||||
> сохранении, а не ограничением таблицы. Смена признака на базе с ничьей записью
|
||||
> проходит зелёным и такую запись оставляет… Заставить шаг считать строки самому
|
||||
> владелец решил не делать: безопасность держится ручной проверкой, и она названа
|
||||
> первым шагом плана перехода.
|
||||
|
||||
Правило «пустой владелец не совпадает ни с одной записью» при этом осталось и
|
||||
избыточным не стало: схема запрещает **заводить** ничью запись, а правило —
|
||||
**спрашивать** ничьим именем.
|
||||
|
||||
## Последствия
|
||||
|
||||
- `+` значения «владельца нет» не существует ни на одном уровне: ни в схеме, ни в
|
||||
модели, ни в отборе.
|
||||
- `+` дыра «ничью запись заводят руками в панели» закрыта тем же механизмом, что
|
||||
и приём, — одним, а не двумя.
|
||||
- `−` откат шага возвращает необязательность, но операционно недостижим: команд
|
||||
библиотеки сервис не подключает, и это верно для всех шагов схемы проекта.
|
||||
- `−` ничья запись, если её проглядят перед выкладкой, становится незакрываемой:
|
||||
захват выдаёт её воркеру, а всякое сохранение — включая то, которым ставится
|
||||
признак остановки, — отказывает. Следа не остаётся ни в метрике, ни в журнале
|
||||
событий, только строка в логе контейнера.
|
||||
@@ -0,0 +1,40 @@
|
||||
# Метка убранного входа не выставляется вовсе, а не обнуляется
|
||||
|
||||
- **Дата:** 2026-08-15
|
||||
- **Источник:** openspec/changes/archive/2026-08-15-remove-telegram-intake/design.md,
|
||||
раздел «Метка убранного входа не выставляется вовсе»
|
||||
|
||||
## Решение
|
||||
|
||||
Признак поднятого входа остался, а метки убранного входа в метриках нет вовсе —
|
||||
ни со значением единицы, ни со значением нуля. Ряд `transcriber_intake_up` с
|
||||
меткой `telegram` не появляется после выкладки.
|
||||
|
||||
## Почему
|
||||
|
||||
Цитата источника:
|
||||
|
||||
> Признак поднятого входа остаётся, метка `telegram` у него больше не появляется.
|
||||
> Ноль вместо неё читается как «вход есть, но не поднялся», то есть как поломка;
|
||||
> владелец, у которого на этот признак стоит отбор, увидел бы аварию на ровном
|
||||
> месте.
|
||||
|
||||
Отвергнут очевидный подход — оставить ряд со значением нуля. Он выглядит
|
||||
бережнее (отбор не ломается), но говорит неправду: значение нуля у этого признака
|
||||
означает именно неподнятый вход, а не отсутствующий.
|
||||
|
||||
С единственным оставшимся входом проверяемым осталось только **множество меток**:
|
||||
значение нуля у него недостижимо, потому что страница метрик отдаётся тем же
|
||||
сервером, что и приём, — чтобы прочитать признак, надо дотянуться до входа, о
|
||||
котором он сообщает. Различать поднятый и неподнятый вход признак станет снова,
|
||||
когда входов у сервиса станет больше одного.
|
||||
|
||||
## Последствия
|
||||
|
||||
- `+` наблюдатель не видит вечного нуля, который читался бы как незакрытая
|
||||
авария.
|
||||
- `−` отбор вида `transcriber_intake_up == 0` по убранному входу перестаёт
|
||||
срабатывать молча: исчезновение ряда ловится `absent()`, а не сравнением.
|
||||
Владельцу, если такой отбор был заведён, править его руками.
|
||||
- `−` требование «различать поднятый и неподнятый» стало непроверяемым до
|
||||
возвращения второго входа, и это сказано в самом требовании прямо.
|
||||
@@ -0,0 +1,63 @@
|
||||
# Вход Telegram убран целиком, а не выключен признаком
|
||||
|
||||
- **Дата:** 2026-08-15
|
||||
- **Источник:** openspec/changes/archive/2026-08-15-remove-telegram-intake/design.md,
|
||||
разделы «Context» и «Формы решения, между которыми выбирали»
|
||||
|
||||
## Решение
|
||||
|
||||
Вход Telegram убран из сервиса целиком: клиент, транспорт обновлений, отправитель
|
||||
сообщений, сборка входа при старте, список допущенных людей, секция настроек и
|
||||
зависимость. Убран **временно** — возврат заводится новым изменением вместе со
|
||||
связью чата с учётной записью.
|
||||
|
||||
Хранилище при этом не тронуто: колонки `tg_chat_id`, `tg_reply_message_id` и
|
||||
значение `telegram` перечня источников остаются в схеме вместе с записями,
|
||||
которые их заполнили.
|
||||
|
||||
## Почему
|
||||
|
||||
Цитата источника:
|
||||
|
||||
> Сервис принимает записи двумя входами, и входы расходятся в главном: у записи,
|
||||
> пришедшей из приложения, есть владелец, а у записи, пришедшей от бота, владельца
|
||||
> нет и быть не может — связи чата с учётной записью сервис не ведёт. Пока такие
|
||||
> записи заводятся, правило «каждая запись принадлежит человеку» действует
|
||||
> наполовину.
|
||||
|
||||
Отвергнуты две формы решения, обе с названной ценой:
|
||||
|
||||
> **Выключить вход признаком, код оставить.** Признак `telegram.enabled` заведён
|
||||
> 2026-08-13 и обязателен, а приём по HTTP владельца уже требует: одна правка
|
||||
> ключа в боевом файле даёт «новых записей без владельца не заводится» ценой ноля
|
||||
> строк кода и мгновенным возвратом. Отвергнуто по причине из раздела «Why»:
|
||||
> двойная модель остаётся в коде, и оговорку про бота продолжает платить каждая
|
||||
> следующая задача.
|
||||
>
|
||||
> **Сузить бота до исходящего канала.** Приём убрать, отправку оставить с одним
|
||||
> адресатом — чатом владельца строкой настроек. Отвергнуто потому, что заводит
|
||||
> понятие «канал уведомления владельца», которое тут же переделает задача
|
||||
> `ntfy-delivery`.
|
||||
|
||||
Решениями, которые это изменение отменяет, были
|
||||
[ADR-2026-08-13-telegram-intent-declared-not-inferred](ADR-2026-08-13-telegram-intent-declared-not-inferred.md)
|
||||
и
|
||||
[ADR-2026-08-13-telegram-outage-does-not-block-startup](ADR-2026-08-13-telegram-outage-does-not-block-startup.md):
|
||||
оба нормировали подъём входа, которого больше нет. Доводы их при этом устояли и
|
||||
понадобятся возврату — оба продолжают отвечать на вопрос «что делать с входом,
|
||||
чей внешний собеседник недоступен».
|
||||
|
||||
## Последствия
|
||||
|
||||
- `+` модель одна: оговорка про запись без владельца ушла из спек приёма,
|
||||
доступа, конвейера и хранилища.
|
||||
- `+` зависимость `go-telegram-bot-api` ушла из манифеста вместе с двумя путями
|
||||
утечки токена, которые проект закрывал двумя задачами.
|
||||
- `−` у сервиса не осталось входа, которым человек может воспользоваться:
|
||||
приложения нет, личных ключей для программ нет, и до этих задач запись кладут
|
||||
собранным руками запросом с сессией из браузера. Владелец окно принял.
|
||||
- `−` записи, застрявшие в конвейере на минуту выкладки, доходят до текста, и
|
||||
ответа в чат по ним не уходит. Смягчения нет: чат и есть убираемый вход.
|
||||
- `−` бот у Telegram остаётся зарегистрированным и на вид живым, а ключ доступа —
|
||||
в настройках выкладки под возврат входа (решение владельца от 2026-08-14).
|
||||
Отправитель голосового не получит ни ответа, ни отказа.
|
||||
+5
-2
@@ -35,12 +35,15 @@
|
||||
|
||||
| Дата | Запись | Статус |
|
||||
| --- | --- | --- |
|
||||
| 2026-08-15 | [Вход Telegram убран целиком, а не выключен признаком](ADR-2026-08-15-telegram-intake-removed-temporarily.md) | |
|
||||
| 2026-08-15 | [Обязательность владельца держит схема, а не приём](ADR-2026-08-15-owner-required-by-schema.md) | |
|
||||
| 2026-08-15 | [Метка убранного входа не выставляется вовсе, а не обнуляется](ADR-2026-08-15-removed-intake-has-no-metric-label.md) | |
|
||||
| 2026-08-14 | [Предел простоя остаётся часом, хотя он короче самой работы](ADR-2026-08-14-stuck-limit-stays-an-hour.md) | |
|
||||
| 2026-08-14 | [Ответ распознавателя хранится дословно, двоичной формой и вложением](ADR-2026-08-14-provider-payload-stored-verbatim.md) | |
|
||||
| 2026-08-14 | [Остановка записи — признак, а не рубеж](ADR-2026-08-14-halt-is-a-flag-not-a-stage.md) | |
|
||||
| 2026-08-14 | [Учётная запись с записями не удаляется, и это осознанный тупик](ADR-2026-08-14-account-with-records-is-not-deleted.md) | |
|
||||
| 2026-08-13 | [Намерение объявляется признаком, а не выводится из ключа доступа](ADR-2026-08-13-telegram-intent-declared-not-inferred.md) | |
|
||||
| 2026-08-13 | [Недоступность Telegram подъёму сервиса не мешает](ADR-2026-08-13-telegram-outage-does-not-block-startup.md) | |
|
||||
| 2026-08-13 | [Намерение объявляется признаком, а не выводится из ключа доступа](ADR-2026-08-13-telegram-intent-declared-not-inferred.md) | устарело: вход убран [ADR-2026-08-15-telegram-intake-removed-temporarily](ADR-2026-08-15-telegram-intake-removed-temporarily.md) |
|
||||
| 2026-08-13 | [Недоступность Telegram подъёму сервиса не мешает](ADR-2026-08-13-telegram-outage-does-not-block-startup.md) | устарело: вход убран [ADR-2026-08-15-telegram-intake-removed-temporarily](ADR-2026-08-15-telegram-intake-removed-temporarily.md) |
|
||||
| 2026-08-12 | [Файл записи закрыт защищённым полем и отдаётся вошедшему по токену файла](ADR-2026-08-12-protected-file-behind-session.md) | |
|
||||
| 2026-08-12 | [Сессия живёт семь суток и не продлевает саму себя](ADR-2026-08-12-session-without-refresh.md) | |
|
||||
| 2026-08-12 | [Кого пускать в сервис, решает правило провайдера, а не сервис](ADR-2026-08-12-access-delegated-to-provider.md) | |
|
||||
|
||||
+37
-57
@@ -17,16 +17,17 @@
|
||||
- [intake](../openspec/specs/intake/spec.md) — **приём по HTTP плюс наличие
|
||||
входов**: приём и опрос за сессией, имя отправителя не доходит ни до
|
||||
хранилища, ни до журнала, метка метрики несёт только известное расширение, а
|
||||
выключенный вход Telegram не мешает подъёму. Задачи
|
||||
наблюдатель видит единственный поднятый вход. Задачи
|
||||
`http-handler-tests-never-green` и `no-user-filename-in-log` 2026-08-11,
|
||||
`pocketbase-storage` и `oidc-login` 2026-08-12,
|
||||
`local-run-without-telegram-token` 2026-08-13. Приём из Telegram по существу —
|
||||
кто допущен и как забирается запись — здесь по-прежнему не описан;
|
||||
`local-run-without-telegram-token` 2026-08-13, `remove-telegram-intake`
|
||||
2026-08-14;
|
||||
- [pipeline](../openspec/specs/pipeline/spec.md) — пустой прогон воркера, захват
|
||||
задачи и срок его протухания, число попыток, состояние «мертва», пауза перед
|
||||
повтором и недоставленный ответ отправителю: задачи
|
||||
`errors-as-instead-of-typecast` 2026-08-11, `pocketbase-storage` 2026-08-12 и
|
||||
`local-run-without-telegram-token` 2026-08-13. Переходы состояний и отмена
|
||||
задачи и срок его протухания, число попыток, остановка признаком, пауза перед
|
||||
повтором и молчание конвейера наружу: задачи
|
||||
`errors-as-instead-of-typecast` 2026-08-11, `pocketbase-storage` 2026-08-12,
|
||||
`local-run-without-telegram-token` 2026-08-13 и `remove-telegram-intake`
|
||||
2026-08-14. Переходы состояний и отмена
|
||||
контекста посреди шага остаются
|
||||
долгом; что именно не описано, перечисляет раздел `Purpose` самой спеки;
|
||||
- [storage](../openspec/specs/storage/spec.md) — где живут запись, её метаданные
|
||||
@@ -40,17 +41,17 @@
|
||||
- [access](../openspec/specs/access/spec.md) — кто пришёл в сервис и пускают ли
|
||||
его дальше: вход через внешнего провайдера OIDC, чем предъявляется сессия, что
|
||||
её прекращает и какие адреса остаются открытыми. Задача `oidc-login`
|
||||
2026-08-12. Здесь же разграничение записей по владельцу: запись из веба
|
||||
принадлежит тому, кто её принёс, чужая неотличима от несуществующей, а запись
|
||||
из Telegram владельца не имеет и по API не достаётся никому. Задача
|
||||
`record-ownership` 2026-08-14.
|
||||
2026-08-12. Здесь же разграничение записей по владельцу: принятая запись
|
||||
принадлежит тому, кто её принёс, чужая неотличима от несуществующей, а ничьей
|
||||
записи не бывает вовсе — колонка владельца пустого значения не принимает.
|
||||
Задачи `record-ownership` и `remove-telegram-intake` 2026-08-14.
|
||||
|
||||
Поведение прочих узлов, включая приём из Telegram, по-прежнему живёт только в
|
||||
коде. Задача, которая его трогает, дописывает спеку своей capability.
|
||||
Поведение узла, которого нет в перечне выше, по-прежнему живёт только в коде.
|
||||
Задача, которая его трогает, дописывает спеку своей capability.
|
||||
|
||||
## Принципы
|
||||
|
||||
- **Один процесс.** Бот, HTTP-сервер и фоновые воркеры живут в одном бинарнике и
|
||||
- **Один процесс.** HTTP-сервер и фоновые воркеры живут в одном бинарнике и
|
||||
делят одну базу. Отдельного воркер-процесса нет намеренно.
|
||||
- **Очередь таблицей.** Состояние задачи лежит коллекцией хранилища; неделимость
|
||||
захвата и порядок выборки нормирует
|
||||
@@ -64,7 +65,7 @@
|
||||
[pipeline](../openspec/specs/pipeline/spec.md), «Брошенная задача возвращается
|
||||
в работу»; здесь это принцип письма шага, а не описание поведения.
|
||||
- **Ядро зависит от интерфейсов.** `internal/service` знает только
|
||||
`internal/contract`; ffmpeg, Yandex, Telegram и хранилище подставляются в
|
||||
`internal/contract`; ffmpeg, Yandex и хранилище подставляются в
|
||||
`main.go`. Правило механизировано тестами-сканерами `internal/archrules`, и
|
||||
они же держат обратные направления: транспорты не знают друг о друге, адаптер
|
||||
не знает ни ядра, ни транспортов.
|
||||
@@ -77,17 +78,15 @@
|
||||
|
||||
Каждый — строкой со ссылкой на capability, а не пересказом её требований.
|
||||
|
||||
<!-- канон: поведение → openspec/specs/intake, pipeline, storage; ещё НЕ переехало: приём из Telegram, деление длинного текста по словам -->
|
||||
<!-- канон: поведение → openspec/specs/intake, pipeline, storage; ещё НЕ переехало: приведение записи к рабочему формату -->
|
||||
|
||||
| Компонент | Где | Что делает |
|
||||
| --- | --- | --- |
|
||||
| Telegram-бот | `internal/controller/tg` | Принимает голосовые, аудиофайлы и документы с аудио, скачивает их, заводит задачу |
|
||||
| HTTP API | `internal/controller/http` | Приём файла и опрос статуса задачи |
|
||||
| Воркеры | `internal/controller/worker` | Пул одинаковых потоков: каждый берёт любую пригодную запись и опрашивает базу. Число — настройкой, ноль законен |
|
||||
| Сервис расшифровки | `internal/service` | Конвейер: приём, приведение, отправка, опрос, завершение. Шаг выбирается по рубежу записи |
|
||||
| Конвертер и метаданные | `internal/adapter/{converter,metaviewer}/ffmpeg` | `ffmpeg` в ogg/vorbis, `ffprobe` для длительности |
|
||||
| Распознаватель | `internal/adapter/recognizer/yandex` | Заливка в Object Storage и отложенное распознавание SpeechKit; разбор ответа в реплики со временем |
|
||||
| Отправитель Telegram | `internal/adapter/telegram` | Отправка текста, деление длинного по словам |
|
||||
| Репозитории | `internal/adapter/repo/pocketbase` | Записи, файлы, тексты, структура, попытки распознавания и журнал событий — коллекциями хранилища; захват — сырым запросом |
|
||||
| Шаги схемы | `internal/adapter/repo/pocketbase/migrations` | Файл на шаг, имя файла — имя шага; там же имена коллекций |
|
||||
| Панель владельца | `internal/adapter/repo/pocketbase`, `panel.go` | Панель хранилища; правила правки записи нормирует [storage](../openspec/specs/storage/spec.md), «Владелец видит записи в панели» |
|
||||
@@ -102,12 +101,6 @@
|
||||
|
||||
## Внешние границы и форматы
|
||||
|
||||
- **Telegram Bot API.** Вход — обновления длинным опросом, выход — сообщения.
|
||||
Файл скачивается по ссылке `file.Link(token)` запросом с контекстом, клиентом
|
||||
самого бота. Клиента заводит единая точка `internal/adapter/telegram`: токен
|
||||
стоит в пути каждого обращения, и снятие адреса с отказа живёт там —
|
||||
[conventions/logging.md](conventions/logging.md), «Безопасность: что не
|
||||
логируем». Telegram не отдаёт файлы больше 20 МиБ — это потолок приёма из бота.
|
||||
- **Yandex Object Storage.** S3-совместимый, клиент `aws-sdk-go-v2` с
|
||||
`UsePathStyle`. Ключ объекта — имя файла, то есть UUID с расширением.
|
||||
- **Yandex SpeechKit v3.** gRPC, `stt.api.cloud.yandex.net:443`, модель
|
||||
@@ -121,26 +114,12 @@
|
||||
- **Где работает, что рядом, кто перезапускает:** один контейнер на личном
|
||||
сервере, разворачивает и перезапускает Ansible из `pet-project-server`. Рядом —
|
||||
обратный прокси, который публикует HTTP-порт наружу.
|
||||
- **Порядок выкладки задаётся по ключу, а не по файлу целиком.** Общего правила
|
||||
«сперва образ» или «сперва конфиг» нет: два ключа секции Telegram требуют
|
||||
противоположного, и оба правила действуют одновременно.
|
||||
- **Признак включения `telegram.enabled` едет в конфиг раньше образа.** Он
|
||||
обязателен с 2026-08-13, умолчания у него нет, и образ, который его ждёт,
|
||||
без него выходит с кодом 1 **до** открытия порта — вместе с HTTP, панелью и
|
||||
конвейером. Прежний образ лишний ключ TOML просто не читает, поэтому ранняя
|
||||
правка конфига безопасна, а поздняя роняет сервис.
|
||||
- **Пустой ключ доступа `telegram.bot_token` едет позже образа.** Образы
|
||||
старше 2026-08-13 роняли старт на пустом ключе, тоже до открытия порта.
|
||||
- **Откат при выключенном входе** допустим только на образ от 2026-08-13 и
|
||||
новее. На более старом состояния «сервис поднят, бот опущен» не существует
|
||||
вовсе: пустой ключ роняет старт, негодный роняет старт, годный поднимает
|
||||
бота. Откат туда делают с непустым годным ключом, приняв, что бот поднимется.
|
||||
- **Откат образа при `enabled = false` и заполненном ключе** отменяет решение
|
||||
владельца молча: прежний образ признака не видит и поднимает бота. Если вход
|
||||
был выключен потому, что бот с этим токеном поднят где-то ещё, два процесса
|
||||
поделят один длинный опрос и часть ответов до людей не дойдёт.
|
||||
|
||||
Ревью кода воспроизвело порядок на прежней версии, живой прогон — на нынешней.
|
||||
- **Порядок выкладки: конфиг после образа.** Прежде здесь стояло правило,
|
||||
разное для двух ключей секции Telegram; с убранным входом оно потеряло предмет
|
||||
целиком. Оставшиеся ключи, которых новый образ ждёт, в конфиге уже есть.
|
||||
Секцию `[telegram]` и ключ `server.users_while_list` человек убирает из боевого
|
||||
файла после выкладки: незнакомые ключи разбор настроек не судит, и файл с ними
|
||||
сервис поднимает молча.
|
||||
- **Откат образа через шаг схемы `202608140002` не работает и не говорит об
|
||||
этом.** Шаг удаляет прежнюю коллекцию задач, а библиотека накатывает только
|
||||
те шаги, которые знает сам бинарь: прежний образ шагов новее не видит,
|
||||
@@ -160,16 +139,15 @@
|
||||
|
||||
| Зависимость | Падает | Отвечает медленно | Молчит | Отдаёт мусор |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| Telegram Bot API | Сервис поднимается без Telegram и работает по HTTP; старт роняют только ошибки настройки — ответ «такого бота нет» и включённый вход с пустым ключом доступа. Норму держит [intake](../openspec/specs/intake/spec.md), «Признак включения решает, поднимается ли вход Telegram» | На старте — ждём не дольше срока, дальше поднимаемся без Telegram. У поднятого сервиса скачивание файла висит бесконечно: там срока нет | То же, что «отвечает медленно»: на старте — подъём без Telegram по истечении срока, у поднятого — длинный опрос пуст и новые задачи не заводятся | Файл скачался битым, отказ вылезет на конвертации |
|
||||
| Yandex SpeechKit | Шаг возвращает ошибку, запись остаётся на повтор | Захват держится час, запись не двигается; по истечении предела простоя она останавливается с причиной «застряла», не теряя идентификатора операции | Операция вечно `in progress`, повтор каждые 5 секунд — до предела простоя в сутки | Пустой текст — запись завершается заглушкой «на записи нет текста» |
|
||||
| Yandex SpeechKit | Шаг возвращает ошибку, запись остаётся на повтор | Захват держится час, запись не двигается; по истечении предела простоя она останавливается с причиной «застряла», не теряя идентификатора операции | Операция вечно `in progress`, повтор каждые 5 секунд — до предела простоя в сутки | Пустой текст — запись доходит до конечного рубежа без расшифровки, и в журнале стоит запись «может стать проблемой» с идентификатором записи; опрос готовности отдаёт рубеж `done` без поля текста |
|
||||
| ↳ *остановка сервиса* | Принятие операции от отмены защищено своим пределом в 10 секунд: операцию там могли принять и начать считать деньги, а потерянный идентификатор заставил бы повтор оплатить ту же запись второй раз. Заливка в Object Storage отменяется штатно — её повтор бесплатен, объект ложится под тем же ключом | — | — | — |
|
||||
| Yandex Object Storage | Заливка падает, запись остаётся на рубеже `normalized` | То же, что падение: висит до конца захвата | — | SpeechKit не прочитает объект и вернёт отказ операции |
|
||||
| ffmpeg, ffprobe | Запись останавливается признаком с текстом «сбой конвертации файла» — рубеж при этом сохраняется, и снятие признака продолжает с него. Остановка сервиса — исход другой: процесс убивают контекстом, запись остаётся на повтор и отказа не тратит | Конвейер стоит: вызов синхронный | — | Выходной файл пуст, отказ вылезет на распознавании |
|
||||
| Хранилище (файл на диске) | Приложение не стартует либо шаг падает на каждом запросе | Блокировка записи держит воркеры | — | — |
|
||||
| Диск | Запись файла падает, задача не заводится | — | — | — |
|
||||
|
||||
- **Кто заметит отказ и когда:** пользователь Telegram — сразу, по молчанию бота
|
||||
или по сообщению об ошибке. Владелец — по метрике
|
||||
- **Кто заметит отказ и когда:** тот, кто загрузил запись, — опросом готовности:
|
||||
остановленная запись отдаёт признак остановки. Владелец — по метрике
|
||||
`transcriber_worker_job_count` с меткой `error="true"`, и метка `stage`
|
||||
называет рубеж, с которого запись взята: с появлением пула одинаковых воркеров
|
||||
имя потока перестало что-либо значить, а разрез по шагу — единственное, чем
|
||||
@@ -178,15 +156,15 @@
|
||||
- **Журнал событий записи** — второй канал наблюдения, `record_events`. Пишется
|
||||
на смену рубежа, на остановку и на снятие остановки; читает его человек в
|
||||
панели, ни один шаг конвейера на него не смотрит. Экрана у него пока нет.
|
||||
- **Характер потока:** непрерывный, но разреженный. Бот держит длинный опрос,
|
||||
воркеры опрашивают базу вхолостую с паузой из
|
||||
- **Характер потока:** непрерывный, но разреженный. Воркеры опрашивают базу
|
||||
вхолостую с паузой из
|
||||
[database.md](database.md), «Настройки с числовым значением».
|
||||
|
||||
## Единые точки проекта
|
||||
|
||||
| Что | Где |
|
||||
| --- | --- |
|
||||
| Приём аудио и заведение записи | `TranscribeService.createRecord` — через него идут оба входа |
|
||||
| Приём аудио и заведение записи | `TranscribeService.createRecord` — единственный путь, которым запись появляется в хранилище |
|
||||
| Правка записи владельцем | панель хранилища; правка запросом проходит правила перехода (`pocketbase.BindPanelRules`), а шаг конвейера пишет только свои поля и правку владельца не стирает |
|
||||
| Захват записи воркером | `AudioRecordRepository.FindAndAcquire` — один запрос с `RETURNING`, отдаёт идентификатор и признак захвата |
|
||||
| Объявление рубежа | `internal/entity/stage.go` — выбор шага, отбор захвата, срок протухания и предел простоя выводятся отсюда |
|
||||
@@ -194,7 +172,7 @@
|
||||
| Рабочая копия файла на диске | `FileRepository.Localize`, `Stage`, `StageEmpty` — они же дают единственный способ её убрать (`WorkFile.Close`); зовёт его шаг |
|
||||
| Переход записи на рубеж | `entity.AudioRecord.MoveToState` — чистит служебные поля прошлого рубежа и ставит время входа |
|
||||
| Откладывание работы | `entity.AudioRecord.Postpone` — ставит паузу и снимает захват, рубежа не трогая |
|
||||
| Остановка и перезапуск | `entity.AudioRecord.Halt` и `Resume`; ответ отправителю — `TranscribeService.halt`, одно место на все причины |
|
||||
| Остановка и перезапуск | `entity.AudioRecord.Halt` и `Resume`; запись причины и события — `TranscribeService.halt`, одно место на все причины |
|
||||
| Разбор конфигурации | `internal/config.LoadConfig` |
|
||||
| Чтение времени | `internal/clock` — `Now` даёт метку в UTC, `Start` — начало измерения длительности; `time.Now` вне пакета запрещён правилом линтера |
|
||||
| Метрики | `internal/metrics`, префикс имени `transcriber_` |
|
||||
@@ -224,7 +202,8 @@
|
||||
[access](../openspec/specs/access/spec.md), решения —
|
||||
[ADR-2026-08-12-session-without-refresh](adr/ADR-2026-08-12-session-without-refresh.md)
|
||||
и [ADR-2026-08-12-oidc-exchange-via-own-route](adr/ADR-2026-08-12-oidc-exchange-via-own-route.md).
|
||||
**Не решено одно:** как связываются пользователь Telegram и пользователь веба.
|
||||
**Не решено одно:** как связать чат Telegram с учётной записью — от этого
|
||||
зависит возвращение убранного входа.
|
||||
Панель администратора при этом Authelia не закрывает: у неё свой пароль
|
||||
суперпользователя.
|
||||
- **Приложение.** Экранов нет вовсе, есть только API. Решено делать SPA,
|
||||
@@ -240,8 +219,8 @@
|
||||
Push с VAPID отвергнут. Появляется внешняя зависимость, которой сегодня нет, и
|
||||
текст расшифровки начинает уходить на сторону — сдвиг периметра
|
||||
[security.md](security.md).
|
||||
- **Долгие записи.** Потолок сегодня неизвестен и не замерялся: 20 МиБ на приём
|
||||
из Telegram — точно, ограничения `deferred-general` по длине — нет. Расчётные
|
||||
- **Долгие записи.** Потолок сегодня неизвестен и не замерялся: ограничения
|
||||
`deferred-general` по длине не выяснены. Расчётные
|
||||
шесть часов нормирует [storage](../openspec/specs/storage/spec.md), «Файл
|
||||
записи живёт в хранилище»; откуда взято число —
|
||||
[research/pocketbase-defaults.md](research/pocketbase-defaults.md), «Чего эта
|
||||
@@ -265,8 +244,9 @@
|
||||
- **Формат для распознавания.** Конвертер отдаёт ogg/vorbis (`libvorbis`), а
|
||||
SpeechKit получает `ContainerAudio_OGG_OPUS`. Расхождение не разобрано: то ли
|
||||
сервис определяет содержимое сам, то ли часть записей теряется на этом.
|
||||
- **Видео.** Дорожку из видеофайла бот принимает по MIME-типу `video/`, но
|
||||
конвертер этот случай не проверялся.
|
||||
- **Видео.** Дорожка из видеофайла к приёму допускается — расширение он берёт из
|
||||
имени и о годности содержимого спрашивает источник метаданных, — но конвертер
|
||||
на этом случае не проверялся.
|
||||
- **Очередь.** Модель очереди сделана задачей `pocketbase-storage` 2026-08-12
|
||||
([ADR](adr/ADR-2026-08-11-queue-as-pocketbase-collection.md)), перестроена
|
||||
вокруг аудиозаписи задачей `record-centric-model` 2026-08-14 и нормирована
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
**Взято из проекта jellybit.** Расхождения с сегодняшним кодом названы по месту.
|
||||
Главные: комментариями снабжена половина полей; единого места проверки на старте
|
||||
нет: у секций `[auth]` и `[telegram]` свой `Validate()` в `main.go`, а пустые
|
||||
нет: у секций `[auth]` и `[pipeline]` свой `Validate()` в `main.go`, а пустые
|
||||
ключи `[yandex]` ловит конструктор распознавателя.
|
||||
|
||||
**Механизировано:** запрет `os.Getenv` — `forbidigo` в `.golangci.yml`
|
||||
@@ -46,7 +46,6 @@
|
||||
port = <N> # порт HTTP-сервера
|
||||
shutdown_timeout = <N> # ждать мягкой остановки сервера, секунды
|
||||
force_shutdown_timeout = <N> # ждать остановки воркеров, секунды
|
||||
users_while_list = ["<@name>"] # кому отвечает бот; строка автора Telegram
|
||||
```
|
||||
|
||||
Значения намеренно заменены плейсхолдерами: предмет конвенции — форма
|
||||
@@ -58,9 +57,6 @@ users_while_list = ["<@name>"] # кому отвечает бот; стр
|
||||
|
||||
Секретные поля оставляем пустыми — значение приходит из выкладки (см. «Секреты»).
|
||||
|
||||
*Расхождение:* секции `[server]` в `config.example.toml` не хватает поля
|
||||
`users_while_list`, из-за чего бот на свежем конфиге отвечает отказом всем.
|
||||
|
||||
*Расхождение:* адреса провайдера в секции `[auth]` образца заполнены примерами
|
||||
вида `https://auth.example.com/...`, а не оставлены пустыми: пустой адрес не
|
||||
говорит, какой формы значение здесь ждут. Пустым оставлен только
|
||||
@@ -91,7 +87,7 @@ Ansible из `pet-project-server`). Приложение просто читае
|
||||
секретов в коде нет. Источник истины секрета — внешнее хранилище выкладки, не
|
||||
репозиторий и не окружение.
|
||||
|
||||
- Секретные поля transcriber: `telegram.bot_token`, `yandex.speech_kit_api_key`,
|
||||
- Секретные поля transcriber: `yandex.speech_kit_api_key`,
|
||||
`yandex.object_storage_access_key_id`,
|
||||
`yandex.object_storage_secret_access_key`, `auth.client_secret`.
|
||||
- Отрендеренный `config.toml` (с секретами) **не коммитится**; права `0600`,
|
||||
@@ -130,16 +126,8 @@ Ansible из `pet-project-server`). Приложение просто читае
|
||||
TOML. Пустые ключи Yandex ловятся в конструкторе распознавателя, и там процесс
|
||||
выходит с кодом 1. Единого места проверки нет.
|
||||
|
||||
Под это расхождение больше не подпадают два ключа секции `[telegram]` — признак
|
||||
включения и ключ доступа, — и проверок у них две. Третий ключ секции,
|
||||
`update_timeout`, границ по-прежнему не проверяет никто, и ноль в нём обращает
|
||||
длинный опрос в непрерывный. Обязательность признака включения судит загрузчик — только разбор отличает
|
||||
«ключ не задан» от «ключ задан ложным», потому что нулевое значение `bool` у
|
||||
обоих одинаковое. Заполненность ключа доступа судит `TelegramConfig.Validate()` из
|
||||
`main.go`, рядом с проверкой `[auth]`: пустой `bot_token` при `enabled = true` —
|
||||
ошибка настройки и отказ старта. Непустой негодный по-прежнему судится при сборке
|
||||
клиента, до подъёма сервера. Нормирует это `openspec/specs/intake`, «Признак
|
||||
включения решает, поднимается ли вход Telegram».
|
||||
Два ключа секции `[telegram]`, стоявшие здесь исключением, ушли вместе с самим
|
||||
входом 2026-08-14: секции больше нет, и своей проверки у неё тоже.
|
||||
|
||||
Секция `[auth]` — первая, у которой проверка своя и стоит на старте:
|
||||
`AuthConfig.Validate()` зовётся из `main.go` сразу после загрузки и роняет
|
||||
@@ -165,5 +153,6 @@ TOML. Пустые ключи Yandex ловятся в конструкторе
|
||||
комментария у самого поля), ни по нулевому значению типа; присутствие ключа
|
||||
судит **разбор** — `MetaData.IsDefined` из `toml.DecodeFile`, — потому что
|
||||
значение отличить «не задано» от «задано нулём» не позволяет. В
|
||||
`config.example.toml` у поля стоит значение свежей установки. Первое такое
|
||||
поле — `telegram.enabled`.
|
||||
`config.example.toml` у поля стоит значение свежей установки. Первым таким
|
||||
полем был `telegram.enabled`; секция убрана 2026-08-14, и живого примера у
|
||||
правила сейчас нет.
|
||||
|
||||
+12
-14
@@ -69,11 +69,10 @@ transcriber — **приложение, а не библиотека**: внеш
|
||||
`contract.JobNotFoundError` (состояние и сообщение), `contract.NoopJobError`
|
||||
(состояние), `contract.LostAcquisitionError` (идентификатор задачи).
|
||||
|
||||
`tg.EmptyBotTokenError` был ровно тем случаем, против которого написано правило —
|
||||
тип без полей, — и снят задачей `local-run-without-telegram-token` 2026-08-13;
|
||||
его место занял sentinel `telegram.ErrEmptyToken`. Рядом живёт
|
||||
`contract.ErrDeliveryChannelDown` — тоже sentinel и по той же причине: заглушка
|
||||
отправителя не знает ни задачи, ни чата, и нести ей нечего.
|
||||
Правило это однажды нарушал `tg.EmptyBotTokenError` — тип без полей, — и был
|
||||
снят задачей `local-run-without-telegram-token` 2026-08-13 в пользу sentinel'а.
|
||||
Оба ушли из проекта 2026-08-14 вместе с входом Telegram; пример остаётся здесь
|
||||
как случай, а не как живой код.
|
||||
|
||||
## Граница и трансляция: приватный и публичный канал
|
||||
|
||||
@@ -83,8 +82,8 @@ transcriber — **приложение, а не библиотека**: внеш
|
||||
- **Приватный канал — логи** (владелец сервиса). Полная ошибка со всей цепочкой
|
||||
`%w` и контекстом. Пишется один раз на доменной границе — см.
|
||||
[logging.md](logging.md).
|
||||
- **Публичный канал — пользовательские поверхности** (Telegram, веб-UI, HTTP
|
||||
API). Сюда отдаём:
|
||||
- **Публичный канал — пользовательские поверхности** (веб-UI, HTTP API). Сюда
|
||||
отдаём:
|
||||
- **человекочитаемое сообщение** по доменной ошибке — не сырой `err.Error()` и
|
||||
не детали реализации (`database/sql`, пути на диске, имена внешних сервисов);
|
||||
- **корреляционный ключ** для владельца — идентификатор задачи, чтобы по нему
|
||||
@@ -118,8 +117,7 @@ transcriber — **приложение, а не библиотека**: внеш
|
||||
|
||||
У публичной границы две поверхности, и правило сырого текста для них разное.
|
||||
|
||||
- **Разовый ответ на действие** (тело HTTP-ответа, сообщение бота по результату
|
||||
команды) — строго нейтральный: отображение выше, `err.Error()` наружу не идёт,
|
||||
- **Разовый ответ на действие** (тело HTTP-ответа) — строго нейтральный: отображение выше, `err.Error()` наружу не идёт,
|
||||
полная ошибка остаётся в логах по идентификатору задачи.
|
||||
- **Сохранённая диагностика состояния** — колонка `error_text` задачи. Это
|
||||
**поверхность владельца**, а не пользователя: сюда сырой текст ошибки допустим
|
||||
@@ -131,9 +129,9 @@ transcriber — **приложение, а не библиотека**: внеш
|
||||
- **внешнее значение в тексте усекается на границе, а его размер называется
|
||||
числом рядом**: без этого непонятно, насколько сокращать.
|
||||
|
||||
*Расхождение:* `error_text` пишется целиком, без вычистки и без усечения, а
|
||||
пользователь Telegram видит отдельный человекочитаемый текст — это часть
|
||||
правила соблюдена.
|
||||
*Расхождение:* `error_text` пишется целиком, без вычистки и без усечения.
|
||||
Наружу он при этом не выходит: опрос готовности отдаёт признак остановки без
|
||||
машинного текста — эту часть правила держит спека `intake`.
|
||||
|
||||
## panic
|
||||
|
||||
@@ -145,8 +143,8 @@ transcriber — **приложение, а не библиотека**: внеш
|
||||
ронял процесс. В transcriber его вешает роутер хранилища сам
|
||||
(`apis.panicRecover`, слой с идентификатором `DefaultPanicRecoverMiddlewareId`
|
||||
на каждом роутере PocketBase): паникующий обработчик отдаёт `500`, процесс
|
||||
живёт. Своего слоя мы не пишем. У воркеров и у бота такой границы **нет**:
|
||||
паника в шаге конвейера роняет процесс целиком.
|
||||
живёт. Своего слоя мы не пишем. У воркеров такой границы **нет**: паника в
|
||||
шаге конвейера роняет процесс целиком.
|
||||
|
||||
## Несколько ошибок
|
||||
|
||||
|
||||
@@ -78,7 +78,7 @@
|
||||
| --- | --- |
|
||||
| Сравнение ошибок через `errors.Is` и `errors.As`, не `==` и не приведением типа | `.golangci.yml` → `errorlint` |
|
||||
| Ошибка не узнаётся сравнением текста сообщения (`strings.Contains(err.Error(), …)`, `err.Error() == …`) | `internal/archrules` → `TestОшибкаНеУзнаётсяПоТексту` |
|
||||
| Непроверенное возвращаемое значение ошибки | `.golangci.yml` → `errcheck`, включая присваивание в `_` (`check-blank`). Отказ, который решено не проверять, объявляют в `exclude-functions` поимённо — там сегодня `defer Close`, `os.Remove` и `send` |
|
||||
| Непроверенное возвращаемое значение ошибки | `.golangci.yml` → `errcheck`, включая присваивание в `_` (`check-blank`). Отказ, который решено не проверять, объявляют в `exclude-functions` поимённо — там сегодня `defer Close` и `os.Remove` |
|
||||
| Непроверенное приведение типа (`v := x.(T)`) | `.golangci.yml` → `errcheck` с `check-type-assertions`. Отдельная настройка, потому что такое приведение паникует, а не возвращает ошибку, и `check-blank` его не видит |
|
||||
| Проверенный отказ не оборачивается в `return nil` | `.golangci.yml` → `nilerr`. Механизирует половину инварианта «принятая запись не теряется молча»: молчаливый успех после отказа |
|
||||
| Отказ выборки из хранилища не теряется (`rows.Err()`), а сама выборка закрывается | `.golangci.yml` → `rowserrcheck`, `sqlclosecheck`. **Профилактические: предмета в коде сегодня нет** — выборки идут через `dbx` хранилища, а из `database/sql` употребляются только `sql.NullString` и `sql.ErrNoRows`. Правила заведены на будущий сырой запрос; мутацией проверены на пробе, а не на своём коде |
|
||||
@@ -89,7 +89,7 @@
|
||||
| Правило | Где механизировано |
|
||||
| --- | --- |
|
||||
| Ядро (`internal/service`) не знает ни адаптеров, ни транспортов | `internal/archrules` → `TestЯдроНеЗнаетОбАдаптерах`, `TestЯдроНеЗнаетОТранспортах` |
|
||||
| Транспорты (`controller/http`, `controller/tg`, `controller/worker`) не знают друг о друге | `internal/archrules` → `TestТранспортыНеЗнаютДругОДруге` |
|
||||
| Транспорты (`controller/http`, `controller/worker`) не знают друг о друге | `internal/archrules` → `TestТранспортыНеЗнаютДругОДруге` |
|
||||
| Адаптер не знает ни ядра, ни транспортов | `internal/archrules` → `TestАдаптерыНеЗнаютНиЯдра_НиТранспортов` |
|
||||
| Колонки записи согласованы: что пишет отображение ↔ что читает обратное ↔ что заводит шаг схемы | `internal/archrules` → правила о колонках. Закрывает инвариант «колонки записи правятся в двух местах» (CLAUDE.md, major), которого компилятор не держит. Литерал колонки ищется в телах нужных функций, а не в файле целиком |
|
||||
| Рубежи согласованы: дескриптор ↔ таблица выбора шага, в обе стороны | `internal/archrules` → правила о рубежах. Закрывает инвариант «рубеж объявляется одним дескриптором» (CLAUDE.md, major). Рубеж без шага останавливает запись, не начав работы; шаг без рубежа недостижим — захват такую запись не выдаст никогда |
|
||||
@@ -117,7 +117,7 @@
|
||||
| --- | --- |
|
||||
| Проверка судит ответ по готовому ответу (`Result()`), а не по живой карте заголовков обработчика | `.golangci.yml` → `forbidigo` с `analyze-types`, находки только в `*_test.go`. Судит по типу приёмника (`httptest.ResponseRecorder`), поэтому ловит любую форму: цепочкой, через переменную, по индексу карты, обходом, полем `HeaderMap`. Остаётся ревью проверка, идущая мимо recorder — через свой `http.ResponseWriter` |
|
||||
| Форма утверждения в проверках: «ожидалось» и «получено» не перепутаны местами, отказ судится `NoError`, а не `Nil`, `require` не зовут из горутины | `.golangci.yml` → `testifylint` |
|
||||
| Одновременный доступ проверен детектором, а не чтением кода | `Taskfile.yml` → шаг `tests` (`go test -race ./...`). Общее у воркеров — счётчики метрик, логгер и клиент бота; захват задачи в гонку не входит, он по построению её не даёт (одно состояние на воркер) — см. «Типовые ложноположительные» в [../review.md](../review.md). Что делает шаг без компилятора C и каким кодом краснеет — [CLAUDE.md](../../CLAUDE.md), «Гейт» |
|
||||
| Одновременный доступ проверен детектором, а не чтением кода | `Taskfile.yml` → шаг `tests` (`go test -race ./...`). Общее у воркеров — счётчики метрик и логгер; захват задачи в гонку не входит, он по построению её не даёт (одно состояние на воркер) — см. «Типовые ложноположительные» в [../review.md](../review.md). Что делает шаг без компилятора C и каким кодом краснеет — [CLAUDE.md](../../CLAUDE.md), «Гейт» |
|
||||
| Строчное подавление называет линтер и причину, а протухшее краснеет | `.golangci.yml` → `nolintlint` (`require-explanation`, `require-specific`, `allow-unused: false`) |
|
||||
|
||||
### Форма кода и файлов вне Go
|
||||
@@ -152,7 +152,7 @@
|
||||
|
||||
| Подавлено | Где | Почему |
|
||||
| --- | --- | --- |
|
||||
| `errcheck` на `defer Close`, `os.Remove` и `send` | `.golangci.yml`, `exclude-functions` | Отказ, который решено не проверять, объявляют поимённо — так он заметен |
|
||||
| `errcheck` на `defer Close` и `os.Remove` | `.golangci.yml`, `exclude-functions` | Отказ, который решено не проверять, объявляют поимённо — так он заметен |
|
||||
| Правило о заголовках вне `*_test.go` | `.golangci.yml`, `exclusions` | В рабочем коде `Header()` и есть способ отдать заголовок |
|
||||
| `time.Now` внутри `internal/clock` | там же | Единой точке чтения времени нечем читать время иначе |
|
||||
| Чтение времени и окружения в `*_test.go` | там же | Проверка строит вход прогона — фикстуру времени, `PATH`, окружение дочернего процесса, — а не метку домена и не настройки приложения. Исключение объявлено по тексту сообщения: правило называет четыре имени, и исключение обязано покрывать те же четыре |
|
||||
|
||||
+16
-29
@@ -28,7 +28,7 @@ OpenSpec.
|
||||
`jq` без регулярных выражений.
|
||||
|
||||
```json
|
||||
{"time":"2026-08-10T11:23:45.123456Z","level":"INFO","msg":"record accepted","capability":"intake","record_id":"…","source":"telegram","duration_seconds":137}
|
||||
{"time":"2026-08-10T11:23:45.123456Z","level":"INFO","msg":"record accepted","capability":"intake","record_id":"…","source":"api","duration_seconds":137}
|
||||
```
|
||||
|
||||
*Расхождение:* `main.go` ставит `slog.NewTextHandler(os.Stdout, …)`.
|
||||
@@ -37,7 +37,7 @@ OpenSpec.
|
||||
|
||||
- `msg` — короткая константа в нижнем регистре: `record accepted`,
|
||||
`recognition done`, `conversion failed`. Данные — в атрибутах:
|
||||
`log.Info("record accepted", "record_id", id, "source", "telegram")`.
|
||||
`log.Info("record accepted", "record_id", id, "source", "api")`.
|
||||
- `msg` — чистая категория без префикса подсистемы: `recognition done`, а не
|
||||
`recognize: done`. Подсистему выносим в поле `capability`, не в текст.
|
||||
- **Смена состояния задачи — единая категория `state transition`** с полями
|
||||
@@ -60,7 +60,7 @@ OpenSpec.
|
||||
| `DEBUG` | разработчику при отладке; в продакшене выключен | `GET /health`, пустой прогон воркера, проверка готовности операции распознавания, тела запросов и ответов внешних сервисов |
|
||||
| `INFO` | владельцу, разбор постфактум | приём записи, переход задачи, конвертация выполнена, текст отправлен, старт и остановка процессов, **событийный вызов внешнего сервиса** |
|
||||
| `WARN` | владельцу, «может стать проблемой» | повтор внешнего вызова, задача досталась повторно по истечении захвата, пустой текст распознавания |
|
||||
| `ERROR` | владельцу, в разбор | внешний сервис недоступен, задача ушла в `failed`, необработанная ошибка |
|
||||
| `ERROR` | владельцу, в разбор | внешний сервис недоступен, запись остановлена признаком, необработанная ошибка |
|
||||
|
||||
Правила:
|
||||
|
||||
@@ -99,7 +99,7 @@ OpenSpec.
|
||||
|
||||
| Когда добавляем | Поля |
|
||||
| --- | --- |
|
||||
| на входящий HTTP-запрос | `transport` (`http`, `telegram`), `http.method`, `http.route`, `http.status_code`, `duration_ms` |
|
||||
| на входящий HTTP-запрос | `transport` (`http`), `http.method`, `http.route`, `http.status_code`, `duration_ms` |
|
||||
| на задачу | `capability` (значения — по именам заведённых capability в `openspec/specs/`), `record_id`, `file_id`, `source` |
|
||||
| на запись об ошибке | `error` |
|
||||
| на вызов внешнего сервиса | `ext.service`, `ext.operation`, `ext.status_code`, `duration_ms`, `retry` |
|
||||
@@ -137,7 +137,7 @@ log := log.With("record_id", record.Id, "capability", "conversion")
|
||||
- Логируем ошибку **один раз — на границе доменного слоя**, которая определяет
|
||||
исход операции. Логирует эта единая точка, а не каждый транспорт — так
|
||||
транспорты остаются тонкими. Границы в transcriber:
|
||||
- приём записи (`CreateJobFromTelegram`, `CreateJobFromApi`);
|
||||
- приём записи (`CreateJobFromApi`);
|
||||
- **шаг конвейера** (`FindAndRunConversionJob`, `FindAndRunTranscribeJob`,
|
||||
`FindAndRunTranscribeCheckJob`) — исход шага, вызванного циклом воркера;
|
||||
- завершение и отказ задачи (`completeJob`, `failJob`).
|
||||
@@ -169,9 +169,9 @@ log := log.With("record_id", record.Id, "capability", "conversion")
|
||||
|
||||
**Каждый** вызов внешнего сервиса логируется. Поля:
|
||||
|
||||
- `ext.service` — `telegram`, `speechkit`, `object-storage`, `ffmpeg`;
|
||||
- `ext.operation` — логическая операция (`getFile`, `sendMessage`,
|
||||
`RecognizeFile`, `GetOperation`, `PutObject`, `convert`);
|
||||
- `ext.service` — `speechkit`, `object-storage`, `ffmpeg`;
|
||||
- `ext.operation` — логическая операция (`RecognizeFile`, `GetOperation`,
|
||||
`PutObject`, `convert`);
|
||||
- `ext.status_code` — код ответа, если применим;
|
||||
- `duration_ms` — длительность вызова;
|
||||
- `retry` — номер попытки, если повторы были.
|
||||
@@ -191,8 +191,7 @@ log := log.With("record_id", record.Id, "capability", "conversion")
|
||||
|
||||
*Расхождение:* обёртки `ext.*` нет. Из внешних вызовов логируется только
|
||||
конвертация (через метрику длительности) и запуск распознавания; заливка в
|
||||
Object Storage, скачивание файла из Telegram и опрос операции не логируются
|
||||
никак.
|
||||
Object Storage и опрос операции не логируются никак.
|
||||
|
||||
## HTTP и проверка здоровья
|
||||
|
||||
@@ -218,7 +217,6 @@ Object Storage, скачивание файла из Telegram и опрос оп
|
||||
|
||||
Никаких секретов в полях и сообщениях. Под запретом:
|
||||
|
||||
- токен бота Telegram;
|
||||
- ключ SpeechKit и заголовок `Authorization`;
|
||||
- пара ключей Object Storage;
|
||||
- **сам текст расшифровки и имена файлов пользователя** — это содержимое личной
|
||||
@@ -234,28 +232,17 @@ Object Storage, скачивание файла из Telegram и опрос оп
|
||||
- При сомнении не логируем значение, логируем факт его наличия
|
||||
(`"has_api_key", true`).
|
||||
- **Ошибка HTTP-транспорта несёт URL — возможный носитель секрета.**
|
||||
`*url.Error` из `net/http` встраивает полный URL запроса, а токен Telegram
|
||||
живёт прямо в пути (`…/bot<TOKEN>/…`). Такую ошибку разворачивают в
|
||||
`*url.Error` из `net/http` встраивает полный URL запроса, а секрет иногда
|
||||
живёт прямо в пути. Такую ошибку разворачивают в
|
||||
первопричину на границе клиента **до** лога и до обёртки: URL отбрасывается,
|
||||
проверка `errors.Is` на причину сохраняется. Общее правило: **секрет не кладём
|
||||
в URL, если у сервиса есть заголовок** — тогда его нет и в ошибке транспорта.
|
||||
|
||||
Обращения к Telegram этому правилу следуют, и точка чистки одна на все вызовы —
|
||||
`internal/adapter/telegram`, `NewBot`. Токен стоит в пути **каждого** обращения к
|
||||
Bot API, поэтому чистка на месте употребления закрывала бы один вызов из всех:
|
||||
|
||||
- отказ транспорта разворачивает в первопричину клиент бота (`safeClient`), а
|
||||
библиотека отдаёт наш отказ вызывающему нетронутым — этим закрыты `getFile`,
|
||||
`sendMessage`, скачивание записи и `getMe` из конструктора;
|
||||
- длинный опрос печатает свои отказы **пакетным логгером самой библиотеки**,
|
||||
минуя наш `slog`; логгер подменён на вычищающий (`tgbotapi.SetLogger`), и
|
||||
замена точная — токен известен.
|
||||
|
||||
Прежде здесь стоял `http.Get(file.Link(token))`, отказ уезжал в журнал вместе с
|
||||
токеном, а конвенция числила это расхождением с оценкой «не логируется», которая
|
||||
была неверной. Запись — [../review.md](../review.md), 2026-08-13; оракулом
|
||||
служат проверки `internal/adapter/telegram/bot_test.go`, судящие по тексту
|
||||
отказа и строке журнала.
|
||||
Живого случая у этого правила сейчас нет: единственный секрет, стоявший в пути
|
||||
обращения, — токен бота, и он ушёл вместе с входом Telegram 2026-08-14. Разбор
|
||||
случая и цена промаха записаны в [../review.md](../review.md), 2026-08-13:
|
||||
конвенция числила утечку расхождением с оценкой «не логируется», и оценка была
|
||||
неверной.
|
||||
|
||||
*Изъятие, а не расхождение:* расширение берётся из имени отправителя дословно
|
||||
(`filepath.Ext`), поэтому имя `запись.тайное-слово` отдаёт приватный хвост
|
||||
|
||||
@@ -108,5 +108,5 @@
|
||||
узнала»).
|
||||
- **Устройство service worker и версионирование статики** — задача
|
||||
[installable-pwa](../../tasks/items/installable-pwa.md).
|
||||
- **Как связываются пользователь Telegram и пользователь веба** — открытый вопрос
|
||||
- **Как связать чат Telegram с учётной записью** — открытый вопрос; от него зависит возвращение убранного 2026-08-14 входа
|
||||
«Учётные записи» в [../architecture.md](../architecture.md).
|
||||
|
||||
+15
-13
@@ -47,7 +47,7 @@ CGO сборке не нужен.
|
||||
| --- | --- | --- |
|
||||
| `id` | TEXT PK | Идентификатор записи, выдаёт хранилище |
|
||||
| `file` | file | Сам файл |
|
||||
| `owner` | relation → `users` | Владелец файла; пусто у файлов записи, принятой ботом |
|
||||
| `owner` | relation → `users` | Владелец файла; пустого значения не принимает |
|
||||
| `location` | select | `local` или `s3` |
|
||||
| `object_key` | TEXT | Ключ объекта; заведён прежним шагом и новым путём не заполняется |
|
||||
| `size` | INTEGER | Размер в байтах |
|
||||
@@ -66,8 +66,8 @@ capability, и третий смысл развёл бы одно слово п
|
||||
| Поле | Тип | Что |
|
||||
| --- | --- | --- |
|
||||
| `id` | TEXT PK | Идентификатор записи, выдаёт хранилище |
|
||||
| `owner` | relation → `users` | Владелец записи; пусто у записей, принятых ботом |
|
||||
| `source` | select | `api`, `telegram`, `unknown` |
|
||||
| `owner` | relation → `users` | Владелец записи; пустого значения не принимает |
|
||||
| `source` | select | `api`, `unknown`; значение `telegram` осталось историческим — вход убран, новых записей с ним не появляется |
|
||||
| `title`, `brief` | TEXT | Заголовок и краткое описание: читаются вместе со списком |
|
||||
| `state` | select | Рубеж: `uploaded`, `normalized`, `submitted`, `transcribed`, `done`; перечень закрыт схемой |
|
||||
| `state_entered_at` | DATETIME | Время входа в рубеж — сторож застревания |
|
||||
@@ -84,8 +84,8 @@ capability, и третий смысл развёл бы одно слово п
|
||||
| `structure` | relation → `structures` | Структура реплик |
|
||||
| `recognition` | relation → `recognitions` | Попытка распознавания |
|
||||
| `topics` | relation → `topics`, до 5 | Темы записи |
|
||||
| `tg_chat_id` | INTEGER | Куда отправить результат |
|
||||
| `tg_reply_message_id` | INTEGER | С каким сообщением связать |
|
||||
| `tg_chat_id` | INTEGER | Адресат ответа у записи убранного входа; кодом не читается |
|
||||
| `tg_reply_message_id` | INTEGER | Ответное сообщение у неё же; кодом не читается |
|
||||
| `created`, `updated` | DATETIME | Проставляет хранилище |
|
||||
|
||||
Индекс один — по паре «рубеж и признак остановки»: выборка захвата идёт по ним,
|
||||
@@ -188,10 +188,15 @@ capability, и третий смысл развёл бы одно слово п
|
||||
висела бы в панели вторым домом для понятия, которого больше нет.
|
||||
|
||||
**Владелец записи** заведён шагом `202608140001` — связью с коллекцией `users` в
|
||||
обеих таблицах. Пустое значение допустимо, и это решение с ценой: записи,
|
||||
принятые ботом, владельца не имеют вовсе, потому что связи чата Telegram с
|
||||
учётной записью сервис не ведёт. Обязательность для приёма по HTTP держит поэтому
|
||||
сам приём, а не схема.
|
||||
обеих таблицах, — и шагом `202608140003` пустого значения больше не принимает.
|
||||
Прежде принимал, и цену за это платили записи входа Telegram: связи чата с
|
||||
учётной записью сервис не вёл. Вход убран 2026-08-14, ничью запись заводить стало
|
||||
некому, и обязательность переехала из приёма в схему — туда, где её держит
|
||||
хранилище, а не договорённость.
|
||||
|
||||
**Колонки `tg_chat_id` и `tg_reply_message_id`** остались от убранного входа и
|
||||
кодом больше не читаются. Из схемы они не убираются: заводили их применённые
|
||||
шаги `202608110001` и `202608140002`, а применённый шаг не переписывается.
|
||||
|
||||
Выборка по владельцу сужает **чтение записи**: чужая, ничья и несуществующая
|
||||
дают один и тот же отказ. Выборку воркера владелец не сужает — конвейер
|
||||
@@ -288,11 +293,8 @@ capability, и третий смысл развёл бы одно слово п
|
||||
| Задержка перед первой проверкой операции | 10 секунд | `service/transcribe.go` | как было |
|
||||
| Задержка между проверками операции | 5 секунд | там же | как было |
|
||||
| Пауза воркера между прогонами | 1 секунда | `controller/worker/worker.go` | как было |
|
||||
| Предел длины сообщения Telegram | 4000 символов | `adapter/telegram/sender.go` | предел Telegram |
|
||||
| Таймаут мягкой остановки | 5 секунд | конфиг, `[server] shutdown_timeout` | — |
|
||||
| Таймаут жёсткой остановки | 20 секунд | конфиг, `[server] force_shutdown_timeout` | — |
|
||||
| Таймаут обновлений Telegram | 10 секунд | конфиг, `[telegram] update_timeout` | — |
|
||||
| Срок ожидания Telegram при сборке клиента | 10 секунд | `adapter/telegram.ProbeTimeout` | решение, не замер: одно обращение за `getMe` укладывается в доли секунды, дольше Telegram считается недоступным и сервис поднимается без него. Длинный опрос этим сроком не ограничен — клиент подменяется сразу после сборки |
|
||||
| Качество кодирования vorbis | `-q:a 4` | `adapter/converter/ffmpeg/ffmpeg.go` | — |
|
||||
| Жизнь приглашения завести владельца панели | 30 минут | умолчание PocketBase | — |
|
||||
| Потолок размера одной записи | 8 ГиБ | `entity.MaxRecordSize` | расчётный потолок в шесть часов с запасом на видео |
|
||||
@@ -320,4 +322,4 @@ capability, и третий смысл развёл бы одно слово п
|
||||
Чего среди настроек **нет**: режим журналирования, таймаут занятости и размер
|
||||
пула соединений задаёт хранилище своими умолчаниями, а не мы; срока хранения
|
||||
файлов и объектов нет вовсе. Таймаутов у
|
||||
обращений к Telegram, S3 и SpeechKit тоже нет — ни одного.
|
||||
обращений к S3 и SpeechKit тоже нет — ни одного.
|
||||
|
||||
+13
-16
@@ -21,12 +21,14 @@
|
||||
| --- | --- |
|
||||
| Владелец сервиса | Загрузить диктофонную запись или видео из семейного архива с телефона и получить текст. Видеть, кто сколько загрузил и во что это обошлось |
|
||||
| Приглашённый пользователь | Войти в приложение через свою учётную запись, загрузить запись, забрать текст, вернуться к ней через месяц. Приложение ставится на телефон; каждый видит только свои записи |
|
||||
| Пользователь Telegram | Отправить боту голосовое сообщение и получить текст ответом. Работает сегодня |
|
||||
| Внешняя программа | Отдать файл по HTTP, представившись своим токеном, и опросить готовность. Сегодня почти не работает: приём и опрос закрыты сессией OIDC, а своего токена у программы нет — годится только чужая сессия, снятая из браузера и предъявленная кукой либо заголовком `Authorization`. Токен приносит `api-tokens` |
|
||||
|
||||
**Основной вход — приложение**, бот и HTTP API дополняют его. До 2026-08-11
|
||||
основным был бот, и порядок здесь перевёрнут сознательно: диктофонная запись на
|
||||
несколько часов через Telegram не проходит вовсе.
|
||||
**Вход у сервиса один — HTTP API**, и приложение строится поверх него. До
|
||||
2026-08-11 основным входом был Telegram-бот. 2026-08-11 основным объявили
|
||||
приложение: диктофонная запись на несколько часов через Telegram не проходит
|
||||
вовсе. 2026-08-14 бот убран целиком — временно, до задачи, которая свяжет чат с
|
||||
учётной записью. Вместе с ним из потребителей ушёл пользователь
|
||||
Telegram.
|
||||
|
||||
Цель достигнута, когда:
|
||||
|
||||
@@ -35,8 +37,8 @@
|
||||
- запись расчётного потолка — шести часов — доходит до текста, а не прерывается
|
||||
ошибкой при достижении предела (норма — `openspec/specs/storage`);
|
||||
- сервисом пользуются несколько человек, и записи одного не видны другому;
|
||||
- текст доступен там же, где загружали, — в приложении и в Telegram. Человек
|
||||
узнаёт о его готовности, не держа приложение открытым;
|
||||
- текст доступен там же, где загружали. Человек узнаёт о его готовности, не
|
||||
держа приложение открытым;
|
||||
- расшифровка не теряется: к записи возвращаются через месяц и находят её по
|
||||
заголовку и темам;
|
||||
- владелец видит расход по каждому пользователю и понимает, во что обходится
|
||||
@@ -90,21 +92,16 @@
|
||||
2. **Возвращение к записи.** Через месяц человек открывает список, находит
|
||||
запись по заголовку или теме и читает вычитанный текст, а при нужде — сырую
|
||||
расшифровку.
|
||||
3. **Голосовое из Telegram.** Пользователь шлёт боту голосовое сообщение, бот
|
||||
отвечает «обрабатываю», через минуту приходит текст ответом на то же
|
||||
сообщение. Записи, чей текст длиннее предела сообщения Telegram, приходят
|
||||
несколькими частями. Работает сегодня.
|
||||
4. **Файл через Telegram.** То же для аудиофайла или документа с аудио: бот
|
||||
отличает их по MIME-типу и расширению. Работает сегодня.
|
||||
5. **Загрузка по HTTP.** Программа шлёт `POST /api/audio` со своим токеном,
|
||||
3. **Загрузка по HTTP.** Программа шлёт `POST /api/audio` со своим токеном,
|
||||
получает идентификатор задачи и опрашивает `GET /api/status/:id`, пока не
|
||||
увидит `done` и текст. Сегодня доступно только предъявившему сессию OIDC:
|
||||
анонимный запрос обоими адресами отклоняется. Своего входа у программы нет —
|
||||
его заводит `api-tokens`. Записи при этом разграничены: программа с чужой
|
||||
сессией видит только записи того, чью сессию предъявила.
|
||||
6. **Отказ на середине.** Конвертация или распознавание не удались — задача
|
||||
переходит в `failed`, а пользователь получает сообщение о том, что именно не
|
||||
вышло, и предложение повторить.
|
||||
4. **Отказ на середине.** Конвертация или распознавание не удались — запись
|
||||
получает признак остановки с причиной, и опрос готовности отдаёт этот признак
|
||||
тому, кто её загрузил. Сообщения о неудаче сервис никому не шлёт: доставка
|
||||
ушла вместе с ботом, а уведомления заводит задача `ntfy-delivery`.
|
||||
|
||||
## Референсы
|
||||
|
||||
|
||||
@@ -4,14 +4,16 @@
|
||||
[записки разведки](pocketbase.md) отличаются предметом: та мерила, **что даёт
|
||||
панель**, эта — **что библиотека делает молча**, если её не переубедить.
|
||||
|
||||
Все четыре наблюдения нашлись ревью, а не чтением документации: три из них
|
||||
выглядят как «значение по умолчанию — нет ограничения», а значат обратное.
|
||||
Наблюдения нашлись ревью, а не чтением документации, и все об одном роде промаха:
|
||||
объявление библиотеки выглядит как «ограничения нет» либо «ограничение есть», а
|
||||
значит обратное.
|
||||
|
||||
## Как снималось
|
||||
|
||||
Версия **0.39.10**, та же, что у первой записки. Прогоны — на пустом каталоге
|
||||
данных во временном каталоге и на поднятом сервере `127.0.0.1:18099`; боевые
|
||||
данные и ключи не участвовали. Числа ниже сняты 2026-08-11 и 2026-08-12.
|
||||
данные и ключи не участвовали. Числа сняты 2026-08-11 и 2026-08-12, последнее
|
||||
наблюдение — 2026-08-15.
|
||||
|
||||
## Нулевой потолок у поля файла значит 5 МиБ, а не «без предела»
|
||||
|
||||
@@ -86,6 +88,34 @@ core/field_file.go:310 if f.MaxSize <= 0 { return DefaultFileFieldMaxSize }
|
||||
прогоном: при первом запуске строка со ссылкой в журнале есть, после заведения
|
||||
владельца при следующем запуске её нет.
|
||||
|
||||
## Обязательность связи проверяется у записи, а не у колонки
|
||||
|
||||
`Required` у поля связи — правило **проверки записи при сохранении**, а не
|
||||
ограничение таблицы. Шаг схемы, объявляющий колонку обязательной на базе, где уже
|
||||
лежат строки с пустым значением, проходит **зелёным** и такие строки оставляет:
|
||||
|
||||
```
|
||||
core/field_relation.go:156 ColumnType отдаёт TEXT DEFAULT '' NOT NULL — от Required не зависит
|
||||
core/collection_validate.go ни одной проверки, читающей существующие строки
|
||||
```
|
||||
|
||||
Проверено прогоном 2026-08-15 на копии хранилища во временном каталоге: строка с
|
||||
пустым владельцем заведена до шага, шаг применён тем же кодом, что и на подъёме,
|
||||
и вывод:
|
||||
|
||||
```
|
||||
STEP 003 (Required=true) поверх ничьей записи: err=<nil>
|
||||
ПОСЛЕ ШАГА: строка на месте, owner=""
|
||||
Save остановленной ничьей записи: err=failed to update audio record: owner: cannot be blank.
|
||||
```
|
||||
|
||||
Следствие для нас: оставленная строка становится **незакрываемой**. Захват идёт
|
||||
сырым запросом мимо проверки и выдаёт её воркеру, а всякое сохранение отказывает —
|
||||
включая то, которым ставится признак остановки. Искать такие строки надо запросом
|
||||
до выкладки, а не прогоном самого шага: прогон чистую базу от грязной не
|
||||
отличает. Цена решения записана в
|
||||
[adr/ADR-2026-08-15-owner-required-by-schema.md](../adr/ADR-2026-08-15-owner-required-by-schema.md).
|
||||
|
||||
## Чего эта записка не узнала
|
||||
|
||||
- **Во что обходится потолок в 8 ГиБ на диске.** Число выбрано расчётом из
|
||||
@@ -96,3 +126,6 @@ core/field_file.go:310 if f.MaxSize <= 0 { return DefaultFileFieldMaxSize }
|
||||
— нет.
|
||||
- **Поведение под одновременной правкой панели и конвейера в бою.** Проверено
|
||||
тестом на одной машине, не живой нагрузкой.
|
||||
- **Сколько строк с пустой связью выдерживает смена признака обязательности.**
|
||||
Проверено на одной строке: суть наблюдения — сам факт отсутствия проверки, а не
|
||||
её цена на объёме.
|
||||
|
||||
+91
-38
@@ -51,14 +51,14 @@
|
||||
- повтор шага на той же задаче не создаёт лишних файлов и записей;
|
||||
- отвечает пользователю ровно один раз.
|
||||
|
||||
**Транспорт** (`internal/controller/tg`, `internal/controller/http`):
|
||||
**Транспорт** (`internal/controller/http`):
|
||||
|
||||
- проверяет право отправителя до всякой работы;
|
||||
- не логирует ошибку, которую уже залогировал доменный слой;
|
||||
- переводит доменную ошибку в свой ответ, а не отдаёт сырой текст;
|
||||
- закрывает то, что открыл, на всех ветках выхода.
|
||||
|
||||
**Клиент внешнего сервиса** (`adapter/recognizer/yandex`, `adapter/telegram`):
|
||||
**Клиент внешнего сервиса** (`adapter/recognizer/yandex`):
|
||||
|
||||
- имеет таймаут и не виснет, когда внешний сервис не отвечает;
|
||||
- не кладёт секрет в URL и не даёт ему утечь через ошибку транспорта;
|
||||
@@ -121,17 +121,19 @@
|
||||
- **«Файлы и объекты не удаляются, диск растёт».** Факт верный и записан в
|
||||
[database.md](database.md); срок хранения не задан сознательно, задачи на него нет.
|
||||
Новой находкой это не считается, пока не измерен рост.
|
||||
- **«Запись без владельца не достаётся никому».** Не дефект: владельца не имеют
|
||||
записи, принятые ботом, — связи чата Telegram с учётной записью приложения
|
||||
сервис не ведёт, её заводит `telegram-account-link`. Ответ такой записи по API
|
||||
совпадает с ответом на несуществующую, и это норма — расшифровку отправитель
|
||||
получает в чат.
|
||||
- **«Запись без владельца не достаётся никому».** Строка отменена **дважды**, и
|
||||
обе отмены оставлены намеренно: прогон, помнящий любую из прежних редакций,
|
||||
иначе выбросил бы настоящую находку как известную.
|
||||
|
||||
**Прежняя редакция этой строки отменена 2026-08-14.** До задачи
|
||||
`record-ownership` здесь стояло «вошедший видит чужие записи — не дефект и не
|
||||
новость»: разграничения не было сознательно. Теперь оно есть, и такая находка
|
||||
настоящая. Строка оставлена вместо удаления намеренно: прогон, помнящий её
|
||||
прежний вид, выбросил бы регрессию не глядя.
|
||||
До задачи `record-ownership` здесь стояло «вошедший видит чужие записи — не
|
||||
дефект и не новость»: разграничения не было сознательно. Первая отмена
|
||||
2026-08-14 завела разграничение и объявила не дефектом уже другое — запись без
|
||||
владельца, принятую ботом.
|
||||
|
||||
Вторая отмена того же дня, задачей `remove-telegram-intake`, сняла и это:
|
||||
колонка владельца пустого значения больше не принимает, ничьих записей у
|
||||
сервиса не бывает вовсе. **Запись без владельца сегодня — настоящая находка**,
|
||||
а не известное исключение.
|
||||
|
||||
### Вопросы по темам
|
||||
|
||||
@@ -145,10 +147,10 @@
|
||||
делает с задачей, деньгами и ответом отправителю» (чтение `worker.go` и
|
||||
`transcribe.go`, 2026-08-13; прежняя запись от 2026-08-10 устарела вместе с
|
||||
дефектом «остановка хоронила запись»).
|
||||
- `operations`: появился ли таймаут у обращения к Telegram, S3 и SpeechKit — ни у
|
||||
одного из них таймаута нет, и проброс контекста на этот вопрос **не отвечает**:
|
||||
контекст здесь несёт жизнь процесса, а не дедлайн вызова (чтение `tg.go`,
|
||||
`s3.go`, `speechkit.go`, 2026-08-13).
|
||||
- `operations`: появился ли таймаут у обращения к S3 и SpeechKit — ни у одного
|
||||
из них таймаута нет, и проброс контекста на этот вопрос **не отвечает**:
|
||||
контекст здесь несёт жизнь процесса, а не дедлайн вызова (чтение `s3.go`,
|
||||
`speechkit.go`, 2026-08-13).
|
||||
- `operations`: не удвоилась ли запись об одном сбое — шаг логирует ошибку и
|
||||
возвращает её воркеру, который логирует снова (чтение `transcribe.go`,
|
||||
2026-08-10).
|
||||
@@ -161,14 +163,12 @@
|
||||
- `security`: не уходит ли значение, пришедшее снаружи, меткой метрики — страница
|
||||
метрик отдаётся без проверки отправителя, и метка это поверхность пошире
|
||||
журнала (журнал, запись 2026-08-11 про хвост имени).
|
||||
- `architecture`: не появился ли второй путь приёма мимо
|
||||
`createTranscribeJob` — сегодня через него идут оба входа
|
||||
- `architecture`: не появился ли второй путь приёма мимо `createRecord` — сегодня
|
||||
он единственный, которым запись попадает в хранилище
|
||||
([architecture.md](architecture.md), «Единые точки проекта»).
|
||||
- `architecture`: не поехало ли поведение в `architecture.md` вместо спеки —
|
||||
заведены четыре capability (`intake`, `pipeline`, `storage`, `access`), и
|
||||
первые две описаны частично. Поведение прочих узлов, включая
|
||||
приём из Telegram, живёт в обзоре под маркерами долга, а соблазн дописать туда
|
||||
ещё — самый большой.
|
||||
заведённые capability описывают поведение не целиком, и остаток живёт в обзоре
|
||||
под маркерами долга, а соблазн дописать туда ещё — самый большой.
|
||||
- `conventions`: новая колонка правится в обоих местах репозитория, а новый
|
||||
рубеж — одним дескриптором
|
||||
(CLAUDE.md, «Инварианты»).
|
||||
@@ -205,12 +205,12 @@
|
||||
- замена хранилища или переход на PocketBase — любой её кусок;
|
||||
- смена модели очереди: захват, повторы и воркеры разом;
|
||||
- каркас приложения: сборка фронтенда, раздача статики и шаг гейта разом;
|
||||
- изменение, трогающее оба входа сразу — Telegram и HTTP.
|
||||
- изменение, убирающее или возвращающее вход приёма целиком.
|
||||
|
||||
**Незнакомое здесь** (поднимает до `large`, ось формы решения):
|
||||
|
||||
- вход через OIDC и разграничение доступа: как связаны пользователь Telegram и
|
||||
пользователь приложения, до начала работы назвать нельзя;
|
||||
- вход через OIDC и разграничение доступа: как связать чат Telegram с учётной
|
||||
записью, до начала работы назвать нельзя;
|
||||
- всё, что делается на выбранном фреймворке впервые: правила
|
||||
[conventions/web-ui.md](conventions/web-ui.md) выведены из выбора и из замера
|
||||
на пробном экране, а не из написанного кода, и первая же задача проверяет их
|
||||
@@ -224,7 +224,6 @@
|
||||
|
||||
**Мелкое здесь** (опускает до `small`):
|
||||
|
||||
- правка текста, который видит пользователь Telegram;
|
||||
- новая метрика в `internal/metrics`;
|
||||
- правка `config.example.toml` и умолчаний `defaultConfig()` без нового поля;
|
||||
- правка документов канона.
|
||||
@@ -259,19 +258,19 @@ API и имя не откатываются обратной правкой по
|
||||
`adapter/metaviewer/ffmpeg` нет; решение и его цена — в
|
||||
[adr/ADR-2026-08-11-stub-adapters-in-tests.md](adr/ADR-2026-08-11-stub-adapters-in-tests.md);
|
||||
- **работа сервиса с настоящими внешними собеседниками.** Сам сервис поднять
|
||||
теперь можно: с `telegram.enabled = false` он встаёт и работает одним входом
|
||||
(`openspec/specs/intake`, «Признак включения решает, поднимается ли вход
|
||||
Telegram»). Живой прогон — осмотр HTTP, панели, журнала и остановки — доступен
|
||||
теперь любой задаче. Прежняя формулировка «всё, что требует поднять сервис целиком»
|
||||
снята задачей `local-run-without-telegram-token` 2026-08-13; рецепт прогона
|
||||
сменился с пустого ключа доступа на выключенный вход задачей
|
||||
`telegram-enabled-flag` того же дня.
|
||||
можно: он встаёт своим единственным входом на выдуманных непустых ключах
|
||||
секций `[auth]` и `[yandex]` — наружу они на старте не ходят. Живой прогон —
|
||||
осмотр HTTP, панели, журнала, метрик и остановки — доступен любой задаче.
|
||||
Прежняя формулировка «всё, что требует поднять сервис целиком» снята задачей
|
||||
`local-run-without-telegram-token` 2026-08-13; рецепт прогона менялся дважды —
|
||||
с пустого ключа доступа на выключенный вход (`telegram-enabled-flag` того же
|
||||
дня), а 2026-08-14 признак включения ушёл вместе с самим входом.
|
||||
|
||||
**Остаток**: за настоящий Telegram, SpeechKit и Object Storage живой прогон
|
||||
по-прежнему не отвечает — боевым токеном запускаться запрещено, ключи Yandex в
|
||||
прогоне выдуманные, а распознавание подменяют в коде. Проверить живьём можно
|
||||
подъём, отказ старта, маршруты и остановку; нельзя — приём из Telegram,
|
||||
расшифровку и заливку.
|
||||
**Остаток**: за настоящие SpeechKit и Object Storage живой прогон по-прежнему
|
||||
не отвечает — ключи Yandex в прогоне выдуманные, а распознавание подменяют в
|
||||
коде. Проверить живьём можно подъём, отказ старта, маршруты, метрики и
|
||||
остановку; нельзя — расшифровку и заливку. Вход через живого провайдера OIDC
|
||||
тоже недоступен: сессию в прогоне выдать нечем.
|
||||
|
||||
## Журнал дефектов
|
||||
|
||||
@@ -281,6 +280,60 @@ API и имя не откатываются обратной правкой по
|
||||
истории git 2026-08-10: поле «Чем воспроизведён» называет у них коммит, а не
|
||||
оракул, и выдумывать оракул задним числом нельзя.
|
||||
|
||||
## 2026-08-15 — пустой второй ответ распознавателя стирал сохранённую расшифровку [пойман ревью]
|
||||
|
||||
- **Где:** `internal/adapter/repo/pocketbase/text_repo.go`, `TextRepository.Put`
|
||||
и `StructureRepository.Put`; путь до них — `poll` → `storeOutcome` в
|
||||
`internal/service/transcribe.go`. Кода задачи `remove-telegram-intake` дефект не
|
||||
касался: она этот путь не трогала
|
||||
- **Симптом:** поток от SpeechKit, закрывшийся на первом же ответе, отказом не
|
||||
считается — наружу уходит пустой результат без отказа. Замена содержимого шла
|
||||
безусловно, и повторный опрос той же операции клал пустое поверх сохранённой
|
||||
расшифровки. Шаг при этом объявлял запись готовой: рубеж двигался, опрос
|
||||
готовности отдавал `done` без текста
|
||||
- **Причина:** соседний хранитель того же результата — сырой ответ провайдера —
|
||||
от пустого значения защищён условием `len(raw) > 0` с самого заведения, а текст
|
||||
и структура реплик такого условия не имели. Разное правило у двух хранителей
|
||||
одного результата
|
||||
- **Чем воспроизведён:** падающий тест враждебного прохода, переснятый триажем, —
|
||||
`expected: "Личный разговор." actual: ""`. Оракул закреплён в дереве:
|
||||
`internal/service/recognition_test.go`, `TestEmptySecondAnswerKeepsArchivedText`;
|
||||
он же проверяет, что до второго ответа дело действительно дошло
|
||||
- **Почему не поймали раньше:** повторный опрос одной операции — не редкость, но
|
||||
и не штатный путь: он наступает, когда держатель захвата умер, сохранение рубежа
|
||||
отказало либо человек снял остановку в панели. Ни один прогон до этого не строил
|
||||
такого входа, а от чтения кода защита у соседа выглядела общей
|
||||
- **Что меняем:** правило «пустое не кладётся поверх сохранённого» записано
|
||||
нормой в спеку `storage` и держится **хранилищем**, а не шагом: шагов, кладущих
|
||||
текст, больше одного, и правило у одного из них у остальных читалось бы как
|
||||
снятое. Дефект пред-существующий, чинился решением владельца от 2026-08-14 в
|
||||
задаче, которая его нашла
|
||||
|
||||
## 2026-08-15 — пустая расшифровка перестала быть заметной вместе с убранным входом [пойман ревью]
|
||||
|
||||
- **Где:** `internal/service/transcribe.go`, шаг завершения; документы
|
||||
`docs/conventions/logging.md` и `docs/architecture.md`
|
||||
- **Симптом:** запись с пустым распознаванием доходила до конечного рубежа и от
|
||||
успешной не отличалась ничем — ни строкой журнала, ни ответом опроса
|
||||
- **Причина:** единственным следом этого случая был текст, уходивший отправителю
|
||||
в чат («на записи нет текста»). Задача убрала доставку целиком, и след исчез
|
||||
вместе с ней — при том, что конвенция журнала называет пустой текст
|
||||
распознавания поимённым примером уровня «может стать проблемой», а обзор
|
||||
архитектуры обещал заглушку
|
||||
- **Чем воспроизведён:** `internal/service/recognition_test.go`,
|
||||
`TestEmptyRecognitionIsNamedInJournal` — подставной распознаватель отдаёт
|
||||
готовую операцию с пустым результатом, проверка судит уровень строки и
|
||||
идентификатор записи
|
||||
- **Почему не поймали раньше:** удаление сняло **последнего потребителя** видимого
|
||||
признака, а не сам признак; такое не видно ни компилятору, ни грепу по
|
||||
удаляемому имени. Нашёл проход конвенций, сверив таблицу уровней журнала с тем,
|
||||
что осталось в коде
|
||||
- **Что меняем:** шаг опроса пишет строку уровня «может стать проблемой» с
|
||||
идентификатором записи; строка обзора архитектуры переписана на фактическое
|
||||
поведение. Класс общий: **удаляя канал, проверь, не был ли он единственным
|
||||
потребителем сигнала** — сигнал переживает канал только там, где его переносят
|
||||
руками
|
||||
|
||||
## 2026-08-13 — сторож инварианта про секрет искал подстроку, которой не бывает [пойман ревью]
|
||||
|
||||
- **Где:** `internal/config/config_test.go`, проверка «значение ключа доступа не
|
||||
|
||||
+19
-44
@@ -15,11 +15,10 @@
|
||||
записи, а чужая отвечает «не найдено». Целевому периметру недостаёт теперь второго уровня
|
||||
доступа — страницы расхода для владельца сервиса.
|
||||
|
||||
Записи, принятые ботом, владельца не имеют и по API не достаются никому: связи
|
||||
чата с учётной записью приложения нет, её заводит `telegram-account-link`.
|
||||
|
||||
Разграничение доступа в Telegram осталось прежним — белым списком, и с учётной
|
||||
записью приложения он не связан.
|
||||
Ничьих записей у сервиса больше не бывает: колонка владельца пустого значения
|
||||
не принимает, и держит это схема хранилища. Прежде такие записи заводил вход
|
||||
Telegram — связи чата с учётной записью сервис не вёл, — и 2026-08-14 вход убран
|
||||
вместе с этим исключением.
|
||||
|
||||
**Целевой периметр шире сегодняшнего не только входом.** Содержимое записи
|
||||
начинает уходить на три новые стороны — языковой модели, в канал уведомлений и
|
||||
@@ -59,9 +58,7 @@
|
||||
| --- | --- | --- |
|
||||
| Аудиофайл и его имя | `POST /api/audio`, multipart-поле `audio` | Любой вошедший через OIDC; без сессии — `401` до чтения тела |
|
||||
| Идентификатор задачи | `GET /api/status/:id` | Любой вошедший через OIDC; без сессии — `401`, одинаковый для заведённой и незаведённой задачи |
|
||||
| Голосовое, аудио, документ | Telegram, длинный опрос | Любой пользователь Telegram; обрабатывается только из белого списка |
|
||||
| Имя файла в Telegram | Поле `file_path` ответа Bot API | Telegram, а через него — отправитель |
|
||||
| Содержимое аудио | Файл, скармливаемый `ffmpeg` и `ffprobe` | Отправитель по любому из каналов |
|
||||
| Содержимое аудио | Файл, скармливаемый `ffmpeg` и `ffprobe` | Отправитель |
|
||||
| Текст расшифровки | Поток gRPC от SpeechKit | Yandex, а через него — содержимое записи |
|
||||
|
||||
Что добавится вместе с целевым периметром — каждый вход появляется своей
|
||||
@@ -82,9 +79,10 @@
|
||||
|
||||
## Куда уходит содержимое записи
|
||||
|
||||
Сегодня запись и её текст покидают наш сервер тремя путями: файл уезжает в
|
||||
Yandex Object Storage, оттуда его читает SpeechKit, а текст возвращается в
|
||||
Telegram отправителю.
|
||||
Сегодня запись покидает наш сервер двумя путями: файл уезжает в Yandex Object
|
||||
Storage, оттуда его читает SpeechKit. Третий путь — ответ в Telegram — исчез
|
||||
2026-08-14 вместе с убранным входом: текст теперь достаётся только по опросу
|
||||
готовности и в панели владельца.
|
||||
|
||||
Целевой периметр добавляет три пути, каждый — своей задачей:
|
||||
|
||||
@@ -156,10 +154,6 @@ Telegram отправителю.
|
||||
|
||||
## Что разграничивает доступ
|
||||
|
||||
- **Telegram** — белый список `[server] users_while_list`. Сверяется со строкой
|
||||
автора сообщения (`update.Message.From.String()`, то есть `@username` либо имя
|
||||
с фамилией), а не с числовым идентификатором. Имя пользователя Telegram
|
||||
меняется владельцем в любой момент: список привязан к изменяемому значению.
|
||||
- **HTTP API** — сессия, заведённая входом через OIDC у Authelia. Предъявляется
|
||||
кукой `transcriber_session`, обесценивается выходом, срок жизни назначен числом
|
||||
([database.md](database.md), «Настройки с числовым значением»).
|
||||
@@ -203,9 +197,6 @@ Telegram отправителю.
|
||||
| Личный токен | Права своего владельца программе, без браузерной сессии | `api-tokens` |
|
||||
| Признак владельца сервиса | Страницу расхода и сводку по всем пользователям | `admin-stats-screen` |
|
||||
|
||||
Белый список Telegram при этом перестаёт быть отдельным механизмом: право
|
||||
писать боту выводится из учётной записи (`telegram-account-link`).
|
||||
|
||||
Признак владельца сервиса — **второй уровень доступа**, которого в сегодняшней
|
||||
модели нет вовсе: до него всё разграничение сводилось к «свой или чужой».
|
||||
Откуда он берётся — из группы OIDC или из конфигурации — не решено
|
||||
@@ -229,10 +220,10 @@ Telegram отправителю.
|
||||
`record_events` (журнал событий, содержимого не несёт) и `topics` (словарь
|
||||
тем человека). Всякая новая коллекция, куда содержимое переезжает, закрывается
|
||||
наравне с записью — норму держит спека `storage`.
|
||||
2. **Токен бота Telegram.** Даёт полный доступ к боту и к перепискам с ним.
|
||||
3. **Ключи Yandex Cloud** — `speech_kit_api_key` и пара ключей Object Storage.
|
||||
2. **Ключи Yandex Cloud** — `speech_kit_api_key` и пара ключей Object Storage.
|
||||
Утечка оплачивается деньгами и доступом к бакету.
|
||||
4. **Белый список пользователей** — сам по себе перечень имён.
|
||||
3. **Секрет клиента OIDC** — вместе с адресами провайдера открывает вход в
|
||||
приложение от чужого имени.
|
||||
|
||||
Всё перечисленное лежит в `config.toml`. Файл в `.gitignore`, на сервер его
|
||||
кладёт Ansible; `gitleaks` на pre-commit смотрит только индекс коммита.
|
||||
@@ -288,30 +279,14 @@ Telegram отправителю.
|
||||
приведения хвост читал бы кто угодно из интернета, а множеством значений метки
|
||||
распоряжался бы анонимный отправитель.
|
||||
|
||||
Приём из Telegram имени, данного человеком, до сервиса не доводит: оттуда
|
||||
приходит путь, выданный самим Telegram. Настоящее имя документа дальше проверки
|
||||
типа файла не идёт.
|
||||
Два пути утечки токена бота — адрес Bot API в отказе транспорта и отказ сборки
|
||||
клиента — закрыты задачами `no-user-filename-in-log` и
|
||||
`local-run-without-telegram-token` 2026-08-13 и потеряли предмет 2026-08-14
|
||||
вместе с убранным входом: ни клиента, ни токена у сервиса больше нет. Разбор
|
||||
случая остался в [review.md](review.md) — он про класс, а не про Telegram.
|
||||
|
||||
Токен бота стоит в пути **каждого** обращения к Bot API (`bot<TOKEN>/getFile`,
|
||||
`…/sendMessage`, `…/getMe`, `…/getUpdates`) и в ссылке на скачивание
|
||||
(`file.Link(token)`). Сами адреса нигде не логируются, но до 2026-08-13 их
|
||||
уносил **отказ транспорта**: `*url.Error` встраивает адрес целиком, а отказы
|
||||
скачивания и отправки пишутся в журнал. Теперь адрес на границе клиента снимает
|
||||
свой `Do` — `internal/adapter/telegram`, `NewBot`: он чистит отказ, а
|
||||
подменённый логгер библиотеки вычищает токен из строк длинного опроса, которые
|
||||
она печатает сама. Транспорт бота токена больше не получает вовсе: клиента ему
|
||||
отдают готовым. Правило — [conventions/logging.md](conventions/logging.md),
|
||||
случай — [review.md](review.md), оракул — `internal/adapter/telegram/bot_test.go`.
|
||||
|
||||
Ещё один путь закрыт задачей `local-run-without-telegram-token` 2026-08-13, и до
|
||||
неё он был открыт: токен, не разбирающийся как часть адреса (перенос строки из
|
||||
шаблона выкладки, невычищенная `%`-последовательность), роняет сборку клиента
|
||||
**раньше** обращения к нему — то есть мимо чистки на границе клиента. Отказ
|
||||
конструктора теперь чистится отдельно. Нашло это ревью кода тремя проходами
|
||||
независимо; оракул — там же, в `bot_test.go`.
|
||||
|
||||
Третий путь закрыт задачей `telegram-enabled-flag` 2026-08-13, и он **шире
|
||||
токена бота**: до неё утечь мог любой секрет конфига. Отказ разбора файла
|
||||
Путь, который остался, закрыт задачей `telegram-enabled-flag` 2026-08-13, и он
|
||||
**шире всякого одного ключа**: до неё утечь мог любой секрет конфига. Отказ разбора файла
|
||||
настроек пересказывался как есть, а библиотека разбора собирает текст отказа из
|
||||
разбираемого куска — `toml.ParseError` кладёт в сообщение само значение. Строка
|
||||
секретного ключа с оборванной кавычкой — типовая поломка криво собранного
|
||||
|
||||
Reference in New Issue
Block a user