Files
transcriber/openspec/changes/archive/2026-08-15-remove-telegram-intake/specs/intake/spec.md
T
av 8f7c3a057a удалён вход Telegram, владелец записи стал обязателен в схеме
- убраны клиент бота, транспорт обновлений, отправитель сообщений, сборка
  входа при старте, секция настроек и зависимость go-telegram-bot-api; из
  конвейера ушла доставка ответа отправителю — исход виден опросом готовности.
  Колонки адресата и значение источника остались в схеме: применённые шаги не
  переписываются
- шаг 202608140003 запрещает пустого владельца у аудиозаписи и у файла;
  существующие строки он не проверяет, и это принято сознательно — искать их
  надо запросом до выкладки
- ревью нашло два пред-существующих дефекта, оба закрыты: пустой второй ответ
  распознавателя стирал сохранённую расшифровку, а пустая расшифровка перестала
  быть заметной вместе с убранной доставкой. Попутно поднят golang.org/x/image
  до v0.45.0 — красный шаг vulns, воспроизводился и на чистом master
2026-08-15 07:24:35 +03:00

19 KiB
Raw Blame History

MODIFIED Requirements

Requirement: Приём записи по HTTP

Сервис SHALL принимать запись от внешней программы запросом POST /api/audio с телом multipart/form-data и полем audio только от узнанного отправителя. Запрос без сессии MUST получать код 401, и по нему MUST не заводиться ни файл, ни аудиозапись. Принятая запись от узнанного отправителя MUST быть сохранена и получить заведённую под неё аудиозапись на рубеже uploaded; ответ MUST нести идентификатор записи полем job_id и её рубеж полем status.

Значение рубежа в ответе изменилось: прежде приём отдавал created. Перечень состояний назван проектом необратимым, и ломка объявлена прямо — состояние теперь называет достигнутое, а не предстоящее, и created в новом перечне нет вовсе.

Имена полей ответа нормативны и MUST остаться прежними: контракт HTTP API объявлен проектом необратимым, и переименование поля ломает внешнюю программу молча. Меняются значения поля рубежа, а не его имя.

Отказ по отсутствию сессии наступает раньше чтения тела: запись, за которую не заплатит узнанный отправитель, не должна попасть даже в память.

Приём не судит о годности записи сам: расширение он берёт из имени файла, а пригодность содержимого узнаёт у источника метаданных.

Куда именно ложится принятая запись, приёму не принадлежит: раскладку выбирает хранилище, и нормирует её capability storage.

Владельцем принятой записи приём SHALL назначать предъявителя сессии. Обязательность владельца при этом MUST держаться и схемой хранилища: колонка владельца пустого значения не принимает вовсе, и норму эту держит capability storage. Проверка в приёме от этого не лишняя — она отвечает отправителю понятным отказом до того, как запись попадёт в память, а схема отвечала бы отказом сохранения после укладки файла.

Предъявитель, чья сессия не даёт учётной записи пользователя, MUST получать отказ 403 и MUST получать его до чтения тела — там же, где стоит отказ по отсутствию сессии. Сессия владельца панели — именно такой случай: узнан он всё же узнан, а записи в коллекции пользователей у него нет, и владельцем записи он стать не может.

Код здесь другой, чем у запроса без сессии, и это не оплошность: 401 значит «предъяви себя», а предъявитель себя предъявил. Утечки по разнице кодов нет — оба ответа говорят о самом спрашивающем, а не о том, какие записи заведены.

Отказ после укладки записи потребовал бы убрать уже сохранённый файл, а уборки файлов сервис не умеет вовсе: норма, обязывающая к недостижимому, не пишется.

Scenario: Запись принята

  • GIVEN источник метаданных читает запись и отдаёт её длительность
  • AND отправитель предъявил сессию
  • WHEN программа шлёт POST /api/audio с полем audio
  • THEN ответ имеет код 201, а в теле лежат непустой job_id и status со значением uploaded
  • AND содержимое записи целиком лежит в хранилище одним файлом
  • AND владельцем заведённой аудиозаписи стоит предъявитель сессии

Scenario: Сессия не даёт учётной записи пользователя

  • GIVEN предъявлена сессия владельца панели
  • WHEN он шлёт POST /api/audio с полем audio
  • THEN ответ имеет код 403
  • AND ни файла, ни аудиозаписи не заводится

Scenario: Сессии нет

  • WHEN программа шлёт POST /api/audio с полем audio без сессии
  • THEN ответ имеет код 401
  • AND ни файла, ни аудиозаписи не заводится
  • AND тело ответа не несёт данных записи

Scenario: Поля с записью нет

  • GIVEN отправитель предъявил сессию
  • WHEN программа шлёт POST /api/audio без поля audio
  • THEN ответ имеет код 400 и сообщение об отсутствии записи
  • AND ни файла, ни аудиозаписи не заводится

Scenario: Размеру записи приём не судья

  • GIVEN источник метаданных читает запись и отдаёт её длительность
  • AND отправитель предъявил сессию
  • WHEN программа шлёт запись нулевой длины
  • THEN ответ имеет код 201: собственного порога по размеру у приёма нет

Requirement: Опрос готовности задачи

Сервис SHALL отдавать рубеж аудиозаписи по запросу GET /api/status/:id только её владельцу. Запрос без сессии MUST получать код 401, и тело такого ответа MUST не нести ни рубежа записи, ни текста расшифровки. Ответ владельцу MUST нести идентификатор полем job_id, рубеж полем status и время заведения полем created_at, а текст расшифровки полем transcription_text, и это поле MUST отсутствовать в ответе, пока текста нет: пустая строка на месте отсутствующего текста читается как «расшифровка пуста».

Видов текста у записи больше одного, поэтому ответ MUST называть вид, который отдаёт: в поле transcription_text уходит сырая расшифровка, и только она. Вычитанный текст этим полем MUST не подменяться — иначе значение поля менялось бы у одной и той же записи от того, успел ли отработать необязательный шаг, а контракт объявлен необратимым. Отдача «последнего записанного» текста MUST не применяться: она делает ответ функцией порядка записи, а не состояния записи.

Перечень значений поля status MUST совпадать с перечнем рубежей конвейера: uploaded, normalized, submitted, transcribed, done. Прежних значений created, converted, transcribe, failed и dead в ответе MUST не быть. Это объявленная ломка публичного контракта: рубеж называет достигнутое, а отказ перестал быть состоянием.

Остановленная запись MUST отдавать рубеж, на котором она остановлена, и MUST нести признак остановки отдельным полем halted со значением истины. Машинный текст отказа MUST в ответ не попадать: он принадлежит журналу владельца сервиса, а не отправителю. Этот адрес — единственное место, где отправитель узнаёт о неудаче: доставки ответа отправителю у сервиса больше нет, и признак остановки здесь несёт всю обязанность целиком.

Отказ без сессии MUST не зависеть от того, есть такая запись или нет: иначе по кодам ответа перебирается список заведённых записей.

Запись, принадлежащая другому, MUST отвечать тем же, чем отвечает неизвестный идентификатор, — кодом 404 и тем же телом.

Scenario: Запись найдена

  • GIVEN отправитель предъявил сессию
  • WHEN он спрашивает рубеж своей записи
  • THEN ответ имеет код 200 и несёт job_id, status и created_at
  • AND значение status принадлежит перечню рубежей конвейера

Scenario: Запись остановлена

  • GIVEN запись остановлена признаком на рубеже приведения
  • WHEN владелец спрашивает её рубеж
  • THEN поле status несёт рубеж приведения
  • AND поле halted несёт истину
  • AND машинного текста отказа в ответе нет

Scenario: Сессии нет

  • WHEN программа спрашивает рубеж заведённой записи без сессии
  • THEN ответ имеет код 401
  • AND тело ответа не несёт ни рубежа записи, ни текста расшифровки

Scenario: Без сессии неизвестная запись неотличима от заведённой

  • WHEN программа без сессии спрашивает рубеж заведённой записи, а затем рубеж по неизвестному идентификатору
  • THEN оба ответа имеют код 401

Scenario: Чужая запись неотличима от неизвестной

  • GIVEN запись заведена одним вошедшим
  • WHEN её рубеж спрашивает другой вошедший
  • THEN ответ имеет код 404 и то же тело, что и ответ по неизвестному идентификатору
  • AND тело ответа не несёт ни рубежа записи, ни текста расшифровки

Scenario: Расшифровки ещё нет

  • GIVEN отправитель предъявил сессию
  • WHEN он спрашивает рубеж своей записи, которая ещё не дошла до текста
  • THEN поля transcription_text в ответе нет вовсе

Scenario: Записи с таким идентификатором нет

  • GIVEN отправитель предъявил сессию
  • WHEN программа спрашивает рубеж по неизвестному идентификатору
  • THEN ответ имеет код 404 и сообщение о ненайденной записи

Requirement: Имя файла, данное отправителем, не попадает в журнал

Приём SHALL не писать имя файла, данное отправителем, ни в одну свою журнальную запись — ни на успешном пути, ни на пути отказа, где имя могло бы приехать текстом ошибки. Имя приходит извне вместе с записью и принадлежит содержимому личной переписки наравне с текстом расшифровки; журнал уезжает в собранные логи, откуда строку не убрать.

Расширение, взятое из этого имени, в журнале остаётся собственным полем: по нему прослеживается путь записи. Что именно попадает в журнал ради прослеживаемости, нормирует требование ниже; наружу расширение выходит только приведённым к известному виду — этому отдано отдельное требование.

Оговорка про второй вход из требования ушла вместе с ним: имя, данное отправителем, доходит до сервиса единственным путём — приёмом по HTTP, — и сценарии судят именно его.

Scenario: Имя записи не видно в журнале принятой записи

  • GIVEN источник метаданных читает запись и отдаёт её длительность
  • WHEN программа шлёт POST /api/audio с записью, чья основа имени несёт опознаваемую строку при обычном расширении .mp3
  • THEN ни одна журнальная запись приёма этой строки не содержит
  • AND расширение .mp3 в журнале допустимо

Scenario: Имя записи не видно в журнале при отказе приёма

  • GIVEN источник метаданных не может прочитать запись
  • WHEN программа шлёт POST /api/audio с записью, чья основа имени несёт опознаваемую строку
  • THEN ни одна журнальная запись приёма, включая запись об ошибке, этой строки не содержит

Requirement: Поднятые входы видны наблюдателю

Сервис SHALL отдавать признак поднятости по каждому входу приёма отдельной метрикой и MUST выставлять метку только тому входу, который у сервиса есть. Метки убранного входа в метриках MUST не быть вовсе: признак со значением нуля читался бы как «вход есть, но не поднялся», то есть как поломка, а вечная единица рядом с ним — как исправность того, чего нет.

Проверяемое здесь одно — набор меток, и это честнее прежнего. Вход остался один, страница метрик отдаётся тем же сервером, что и приём, и значение нуля у единственной метки недостижимо: чтобы прочитать признак, надо дотянуться до входа, о котором он сообщает. Прежнее обоснование — «иначе потерянный вход не виден ничем» — было верно, пока входов было два; сегодня неподнятый вход виден неудачей чтения самих метрик.

Различать поднятый и неподнятый вход признак MUST снова, как только входов у сервиса станет больше одного.

Scenario: В метриках только оставшийся вход

  • GIVEN сервис поднялся
  • WHEN наблюдатель читает метрики
  • THEN признак поднятости несёт метку входа HTTP со значением единицы
  • AND метки убранного входа Telegram в метриках нет вовсе

REMOVED Requirements

Requirement: Признак включения решает, поднимается ли вход Telegram

Reason: Вход Telegram убран из сервиса целиком, и решать о его подъёме стало нечего. Настройки входа — признак включения, ключ доступа и срок ожидания обновлений — уходят из конфигурации вместе с ним.

Migration: Секцию [telegram] и ключ server.users_while_list — имя в коде именно такое, с опечаткой, и в боевом файле искать надо его — из файла настроек убрать руками. Оставленные ключи сервис пропускает молча — незнакомые ключи разбор настроек не судит, — и потому файл, забытый как есть, поднимет сервис без бота и без единого слова о том, что секция больше ничего не значит. Записи с источником Telegram, если они в базе есть, остаются нетронутыми и достаются своему владельцу; ответ в чат по ним не уходит. Возврат входа заводится новым изменением вместе со связью чата и учётной записи.