- Клиент бота собирается один раз и достаётся отправителю и транспорту; разрез прошёл по «ответил ли Telegram»: ответ «такого бота нет» роняет старт, недоступность даёт подъём без Telegram (ADR-2026-08-13). Ожидание при сборке ограничено сроком — иначе молчащий Telegram вешал подъём. - Недоставленный ответ не роняет шаг: пишется с job_id и считается метрикой, уровень по причине — WARN для неподнятого входа, ERROR для неназванного адресата. Заведены transcriber_intake_up и transcriber_undelivered_reply_count. - Закрыта утечка токена в журнал: отказ разбора адреса рождается раньше обращения к клиенту, то есть мимо чистки на его границе.
24 KiB
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 равен единице