- в секции [telegram] заведён обязательный ключ enabled: умолчания у него нет, файл без него негоден; bot_token стал только ключом доступа и при enabled = false не читается вовсе, а пустой при enabled = true роняет старт - выключенный вход даёт подъём одним входом без единого обращения к Telegram и записью INFO вместо прежнего WARN: это выбор владельца, а не отклонение - отказ разбора файла настроек больше не пересказывает toml — её ParseError несёт в тексте разбираемое значение, и оборванная строка секретного ключа уносила его в журнал; теперь называются путь, строка, столбец и последний ключ
378 lines
28 KiB
Markdown
378 lines
28 KiB
Markdown
# intake Specification
|
||
|
||
## Purpose
|
||
|
||
Приём записи и опрос готовности задачи расшифровки: что считается принятой
|
||
записью, что уезжает в ответ и что происходит, когда запись не удалось
|
||
прочитать. Плюс наличие входов: с каким из них сервис вправе подняться.
|
||
|
||
Приём по существу описан пока **только для HTTP** — того, что нормируют
|
||
проверки. Про вход Telegram нормировано одно: настроен он или нет и что из этого
|
||
следует для подъёма. Кто допущен к боту и как забирается присланная им запись,
|
||
требованиями по-прежнему не описано — требование, написанное без проверки, это
|
||
предположение, а не норма. Первая задача, которая трогает поведение приёма из
|
||
Telegram, дописывает его сюда.
|
||
## Requirements
|
||
### Requirement: Приём записи по HTTP
|
||
|
||
Сервис SHALL принимать запись от внешней программы запросом `POST /api/audio` с
|
||
телом `multipart/form-data` и полем `audio` **только от узнанного отправителя**.
|
||
Запрос без сессии MUST получать код `401`, и по нему MUST не заводиться ни файл,
|
||
ни задача расшифровки. Принятая запись от узнанного отправителя MUST быть
|
||
сохранена и получить заведённую под неё задачу расшифровки в состоянии
|
||
`created`; ответ MUST нести идентификатор задачи полем `job_id` и её состояние
|
||
полем `status`.
|
||
|
||
Отказ по отсутствию сессии наступает **раньше** чтения тела: запись, за которую
|
||
не заплатит узнанный отправитель, не должна попасть даже в память.
|
||
|
||
Имена полей ответа нормативны: контракт HTTP API объявлен проектом необратимым,
|
||
и переименование поля ломает внешнюю программу молча. Появление отказа без
|
||
сессии — намеренная ломка этого контракта: до неё приём стоял открытым наружу.
|
||
|
||
Приём не судит о годности записи сам: расширение он берёт из имени файла, а
|
||
пригодность содержимого узнаёт у источника метаданных.
|
||
|
||
Куда именно ложится принятая запись, приёму не принадлежит: раскладку выбирает
|
||
хранилище, и нормирует её capability `storage`.
|
||
|
||
Владельца у принятой записи приём не заводит: после входа видно ровно то же, что
|
||
видно было анонимно.
|
||
|
||
#### Scenario: Запись принята
|
||
|
||
- **GIVEN** источник метаданных читает запись и отдаёт её длительность
|
||
- **AND** отправитель предъявил сессию
|
||
- **WHEN** программа шлёт `POST /api/audio` с полем `audio`
|
||
- **THEN** ответ имеет код `201`, а в теле лежат непустой `job_id` и `status`
|
||
со значением `created`
|
||
- **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 сохранять принятую запись под собственным именем — идентификатором,
|
||
к которому приписано расширение из имени файла отправителя. Имя, данное
|
||
отправителем, MUST не попадать в хранилище: оно приходит извне и содержимым
|
||
своим приёму не подконтрольно.
|
||
|
||
Расширения в присланном имени нет — сервис MUST подставить `.audio`, чтобы у
|
||
файла в хранилище расширение было всегда.
|
||
|
||
Требование переживает смену раскладки. Умолчание хранилища, строящее имя из
|
||
имени отправителя, MUST не применяться: имя отправителя в журнал не пишется по
|
||
инварианту приватности, а изъятие из него кончается расширением — хвостом после
|
||
последней точки.
|
||
|
||
#### Scenario: Расширение взято из имени отправителя
|
||
|
||
- **WHEN** программа шлёт запись с именем `test.mp3`
|
||
- **THEN** имя файла в хранилище оканчивается на `.mp3`
|
||
|
||
#### Scenario: Имени без расширения назначено своё
|
||
|
||
- **WHEN** программа шлёт запись с именем `test` без расширения
|
||
- **THEN** имя файла в хранилище оканчивается на `.audio`
|
||
|
||
#### Scenario: Имя отправителя в хранилище не попало
|
||
|
||
- **WHEN** программа шлёт запись с именем `секретное-слово.mp3`
|
||
- **THEN** имя файла в хранилище не содержит `секретное-слово`
|
||
- **AND** путь к этому файлу не содержит его тоже
|
||
|
||
### Requirement: Отказ чтения метаданных
|
||
|
||
Сервис SHALL отвечать отказом, когда источник метаданных не смог прочитать
|
||
принятую запись. Ответ MUST иметь код `500`, а причина отказа MUST не попадать в
|
||
тело ответа: она принадлежит журналу, а не отправителю.
|
||
|
||
#### Scenario: Источник метаданных вернул ошибку
|
||
|
||
- **GIVEN** источник метаданных не может прочитать запись
|
||
- **WHEN** программа шлёт `POST /api/audio` с этой записью
|
||
- **THEN** ответ имеет код `500`
|
||
- **AND** задача расшифровки не заводится
|
||
|
||
### Requirement: Имя файла, данное отправителем, не попадает в журнал
|
||
|
||
Приём SHALL не писать имя файла, данное отправителем, ни в одну свою журнальную
|
||
запись — ни на успешном пути, ни на пути отказа, где имя могло бы приехать
|
||
текстом ошибки. Имя приходит извне вместе с записью и принадлежит содержимому
|
||
личной переписки наравне с текстом расшифровки; журнал уезжает в собранные логи,
|
||
откуда строку не убрать.
|
||
|
||
Расширение, взятое из этого имени, в журнале остаётся собственным полем: по нему
|
||
прослеживается путь записи. Что именно попадает в журнал ради прослеживаемости,
|
||
нормирует требование ниже; наружу расширение выходит только приведённым к
|
||
известному виду — этому отдано отдельное требование.
|
||
|
||
Сценарии судят приём по HTTP, потому что имя, данное отправителем, доходит до
|
||
сервиса только оттуда: из Telegram приходит путь, выданный самим Telegram, а не
|
||
имя человека. Правка при этом ложится на общий шаг заведения задачи, через
|
||
который идут оба входа, поэтому своей нормы приём из Telegram здесь не получает —
|
||
её напишет задача, которая тронет его поведение.
|
||
|
||
#### Scenario: Имя записи не видно в журнале принятой записи
|
||
|
||
- **GIVEN** источник метаданных читает запись и отдаёт её длительность
|
||
- **WHEN** программа шлёт `POST /api/audio` с записью, чья основа имени несёт
|
||
опознаваемую строку при обычном расширении `.mp3`
|
||
- **THEN** ни одна журнальная запись приёма этой строки не содержит
|
||
- **AND** расширение `.mp3` в журнале допустимо
|
||
|
||
#### Scenario: Имя записи не видно в журнале при отказе приёма
|
||
|
||
- **GIVEN** источник метаданных не может прочитать запись
|
||
- **WHEN** программа шлёт `POST /api/audio` с записью, чья основа имени несёт
|
||
опознаваемую строку
|
||
- **THEN** ни одна журнальная запись приёма, включая запись об ошибке, этой
|
||
строки не содержит
|
||
|
||
### Requirement: Журнал приёма прослеживает запись
|
||
|
||
Приём SHALL писать в журнал идентификатор заведённого файла, расширение принятой
|
||
записи и её размер в байтах. По ним путь записи собирается отбором по журналу, и
|
||
удаление имени отправителя прослеживаемости не отнимает.
|
||
|
||
Расширение засчитывается собственным полем журнальной строки. Имя, под которым
|
||
файл лёг в хранилище, приём MUST в журнал не писать: это имя — последняя часть
|
||
ссылки на скачивание, и записанное вместе с идентификатором записи оно собирает
|
||
ссылку целиком. Норму держит capability `storage`.
|
||
|
||
#### Scenario: Идентификатор, расширение и размер на месте
|
||
|
||
- **GIVEN** источник метаданных читает запись и отдаёт её длительность
|
||
- **WHEN** программа шлёт `POST /api/audio` с записью
|
||
- **THEN** журнал приёма несёт идентификатор заведённого файла, расширение
|
||
принятой записи и её размер в байтах
|
||
|
||
#### Scenario: Имени файла в хранилище в журнале нет
|
||
|
||
- **GIVEN** источник метаданных читает запись и отдаёт её длительность
|
||
- **WHEN** программа шлёт `POST /api/audio` с записью
|
||
- **THEN** имени, под которым файл лёг в хранилище, в журнале приёма нет
|
||
|
||
### Requirement: Метка метрики несёт только известное расширение
|
||
|
||
Сервис SHALL приводить расширение принятой записи к известному виду прежде, чем
|
||
употребить его меткой метрики: расширение приводится к нижнему регистру и
|
||
сверяется с закрытым перечнем; совпавшее идёт приведённым, всякое другое MUST
|
||
заменяться единым значением `other`. Перечень — `mp3`, `wav`, `ogg`, `oga`,
|
||
`opus`, `flac`, `m4a`, `aac`, `wma`, `mp4`, `mkv`, `mov`, `avi`, `webm`, плюс
|
||
`audio`: последнее не формат, а собственное умолчание сервиса на случай имени
|
||
без расширения, и различать его от чужого хвоста метка обязана.
|
||
|
||
Страница метрик отдаётся без проверки отправителя, поэтому метка — поверхность
|
||
пошире журнала: её читает кто угодно. Тем же ограничением снимается и рост числа
|
||
временных рядов, которым иначе распоряжается анонимный отправитель.
|
||
|
||
Требование намеренно шире приёма: под него подпадает и метка шага конвертации.
|
||
Когда конвертацию нормируют своей capability, обязанность переезжает туда вместе
|
||
с ней.
|
||
|
||
Имя файла в хранилище это требование не трогает: там расширение остаётся тем,
|
||
каким пришло, — это уже нормировано требованием «Имя файла в хранилище».
|
||
|
||
Настоящий формат записи, попавшей в `other`, остаётся видимым в журнале: значение
|
||
`other` в метке означает «расширение не из перечня», а само оно стоит полем
|
||
журнальной строки приёма и полем формата строки конвертации.
|
||
|
||
#### Scenario: Незнакомое расширение наружу не выходит
|
||
|
||
- **GIVEN** источник метаданных читает запись и отдаёт её длительность
|
||
- **WHEN** программа шлёт запись с именем, чей хвост после последней точки не
|
||
принадлежит перечню известных форматов
|
||
- **THEN** метка метрики принимает значение `other`
|
||
- **AND** имя файла в хранилище сохраняет пришедшее расширение
|
||
|
||
#### Scenario: Известное расширение идёт как есть
|
||
|
||
- **GIVEN** источник метаданных читает запись и отдаёт её длительность
|
||
- **WHEN** программа шлёт запись с именем `sample.MP3`
|
||
- **THEN** метка метрики принимает значение `mp3`
|
||
|
||
### Requirement: Опрос готовности задачи
|
||
|
||
Сервис SHALL отдавать состояние задачи расшифровки по запросу
|
||
`GET /api/status/:id` **только узнанному отправителю**. Запрос без сессии MUST
|
||
получать код `401`, и тело такого ответа MUST не нести ни состояния задачи, ни
|
||
текста расшифровки. Ответ узнанному отправителю MUST нести идентификатор полем
|
||
`job_id`, состояние полем `status` и время заведения полем `created_at`, а текст
|
||
расшифровки полем `transcription_text`, и это поле MUST отсутствовать в ответе,
|
||
пока текста нет: пустая строка на месте отсутствующего текста читается как
|
||
«расшифровка пуста».
|
||
|
||
Отказ без сессии MUST не зависеть от того, есть такая задача или нет: иначе по
|
||
кодам ответа перебирается список заведённых задач.
|
||
|
||
Выборку по владельцу опрос не сужает: узнанный отправитель видит любую задачу по
|
||
её идентификатору ровно как прежде. Сужение придёт отдельной задачей.
|
||
|
||
#### Scenario: Задача найдена
|
||
|
||
- **GIVEN** отправитель предъявил сессию
|
||
- **WHEN** программа спрашивает состояние заведённой задачи
|
||
- **THEN** ответ имеет код `200` и несёт `job_id`, `status` и `created_at`
|
||
|
||
#### Scenario: Сессии нет
|
||
|
||
- **WHEN** программа спрашивает состояние заведённой задачи без сессии
|
||
- **THEN** ответ имеет код `401`
|
||
- **AND** тело ответа не несёт ни состояния задачи, ни текста расшифровки
|
||
|
||
#### Scenario: Без сессии неизвестная задача неотличима от заведённой
|
||
|
||
- **WHEN** программа без сессии спрашивает состояние заведённой задачи, а затем
|
||
состояние по неизвестному идентификатору
|
||
- **THEN** оба ответа имеют код `401`
|
||
|
||
#### Scenario: Расшифровки ещё нет
|
||
|
||
- **GIVEN** отправитель предъявил сессию
|
||
- **WHEN** программа спрашивает состояние задачи, которая ещё не дошла до текста
|
||
- **THEN** поля `transcription_text` в ответе нет вовсе
|
||
|
||
#### Scenario: Задачи с таким идентификатором нет
|
||
|
||
- **GIVEN** отправитель предъявил сессию
|
||
- **WHEN** программа спрашивает состояние по неизвестному идентификатору
|
||
- **THEN** ответ имеет код `404` и сообщение о ненайденной задаче
|
||
|
||
### Requirement: Поднятые входы видны наблюдателю
|
||
|
||
Сервис SHALL отдавать признак поднятости по каждому входу приёма отдельной
|
||
метрикой. Признак MUST выставляться при сборке входа и MUST различать поднятый
|
||
вход и неподнятый.
|
||
|
||
Требование стоит на том, что иначе потерянный вход не виден ничем: проба
|
||
здоровья отвечает «сервис работает» и при неподнятом боте, а запись журнала
|
||
живёт до ротации и вопрос «работает ли вход сейчас» не отвечает. Метрика —
|
||
единственный канал наблюдения, который у владельца автоматизирован.
|
||
|
||
#### Scenario: Вход Telegram не поднят
|
||
|
||
- **GIVEN** сервис поднялся без Telegram
|
||
- **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** ни журнал, ни текст отказа не несут значения ключа
|
||
|