# 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: Недоступный или незаданный вход Telegram не мешает подъёму Сервис SHALL подниматься, когда вход Telegram поднять не удалось, и MUST продолжать работу оставшимся входом: приём по HTTP, опрос готовности и конвейер расшифровки работают в полном объёме. Неподнятый вход MUST быть назван в журнале **ровно одной** записью уровня `WARN` при старте — с причиной и без значения токена. Исключение одно, и оно проходит по тому, **ответил ли Telegram**. Ответ «такого бота нет» — ошибка настройки: бот по этому токену не появится ни от ожидания, ни от повтора, и старт MUST кончаться отказом. Сервис, молча потерявший бота после опечатки в токене, перестаёт отвечать своим отправителям, и узнать об этом было бы неоткуда. Всё прочее — недоступность: сеть, DNS, авария Bot API, истёкший срок ожидания. Она MUST не влиять на подъём. Основной вход сервиса — не Telegram, и ронять его целиком из-за чужой аварии нельзя: перезапуск в такую минуту оставил бы без работы и приём по HTTP, и панель, и конвейер, которому Telegram не нужен вовсе. Ожидание при сборке MUST быть ограничено сроком. Без него недоступность неотличима от подъёма: обращение к Telegram стоит на пути старта, и молчащий собеседник останавливал бы его бессрочно — без записи, без порта и без пробы здоровья. Требование нормирует **наличие входа**, а не приём из него. #### Scenario: Токен не задан - **GIVEN** в настройках сервиса токен бота пуст - **WHEN** сервис запускается - **THEN** он поднимается и принимает записи по HTTP - **AND** конвейер расшифровки работает - **AND** бот не заведён, а в журнале ровно одна запись уровня `WARN` о том, что он не поднят и почему #### Scenario: Токен задан и годен - **GIVEN** в настройках сервиса стоит токен, по которому Telegram признаёт бота - **WHEN** сервис запускается - **THEN** он поднимается и работает обоими входами #### Scenario: Telegram не отвечает - **GIVEN** в настройках сервиса стоит непустой токен - **AND** Telegram недоступен либо не отвечает дольше отведённого срока - **WHEN** сервис запускается - **THEN** он поднимается и принимает записи по HTTP - **AND** бот не заведён, а в журнале запись уровня `WARN` с причиной - **AND** запись не несёт значения токена #### Scenario: Telegram ответил, что такого бота нет - **GIVEN** в настройках сервиса стоит непустой токен - **AND** Telegram отвечает отказом на этот токен - **WHEN** сервис запускается - **THEN** старт кончается отказом - **AND** ни журнал, ни текст отказа не несут значения токена ### Requirement: Поднятые входы видны наблюдателю Сервис SHALL отдавать признак поднятости по каждому входу приёма отдельной метрикой. Признак MUST выставляться при сборке входа и MUST различать поднятый вход и неподнятый. Требование стоит на том, что иначе потерянный вход не виден ничем: проба здоровья отвечает «сервис работает» и при неподнятом боте, а запись журнала живёт до ротации и вопрос «работает ли вход сейчас» не отвечает. Метрика — единственный канал наблюдения, который у владельца автоматизирован. #### Scenario: Вход Telegram не поднят - **GIVEN** сервис поднялся без Telegram - **WHEN** наблюдатель читает метрики - **THEN** признак поднятости входа Telegram равен нулю - **AND** признак поднятости входа HTTP равен единице