Files
transcriber/openspec/changes/archive/2026-08-15-remove-telegram-intake/design.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

20 KiB

Context

Сервис принимает записи двумя входами. Приложение и внешняя программа приходят по HTTP с сессией, и у их записей есть владелец; бот приходит от чата, и у его записей владельца нет — связи чата с учётной записью сервис не ведёт. Отсюда оговорка про бота в каждом решении о владельце: в спеке приёма, в спеке доступа, в схеме хранилища и в отборе записей.

Вход Telegram при этом несёт собственный вес: клиент, транспорт обновлений, разбиение длинного текста на сообщения, заглушка отправителя, сборка входа при старте с разбором «недоступность или ошибка настройки», список допущенных людей, доставка ответа из конвейера и правило о недоставленном ответе.

Ограничение, которое правит решением: применённый шаг схемы не переписывается — это инвариант проекта, и он critical. Колонки tg_chat_id, tg_reply_message_id и значение telegram перечня source заведены шагами 202608110001 и 202608140002, оба применены. В боевой базе по этим колонкам лежат записи живых людей.

Goals / Non-Goals

Goals:

  • Убрать вход Telegram из кода, настроек и зависимостей целиком.
  • Сделать владельца записи безусловным: завести запись без владельца сервису нечем.
  • Сделать владельца обязательным в схеме, а не только в приёме: ни одной записи не потерять и ни одного применённого шага схемы не переписать.
  • Оставить возврат входа дешёвым: удалённое лежит в истории git одним изменением, и возвращается оно вместе со связью чата и учётной записи.

Non-Goals:

  • Замена доставки ответа. Уведомлений взамен чата не заводим — на это стоит задача ntfy-delivery.
  • Чистка хранилища от колонок и значений бота. Ни нового шага схемы, ни правки применённых.
  • Переделка приёма по HTTP, конвейера, распознавания и панели.
  • Связь чата Telegram с учётной записью. Она нужна возврату входа, и заводит её своя задача.

Decisions

Схема теряет только необязательность владельца

Колонки чата и ответного сообщения остаются, значение telegram в перечне источников остаётся, применённые шаги не переписываются. Новый шаг схемы у изменения один, и делает он ровно одно: колонка владельца у аудиозаписи и у файла перестаёт принимать пустое значение.

Шаг безопасен потому, что записей без владельца в боевой базе нет — это сказал владелец сервиса, отвечая на прямой вопрос, и на этом ответе решение и стоит. Будь такие записи, шаг пришлось бы либо отменить, либо оплатить необратимой правкой чужих данных: назначить им владельца выдумкой или удалить.

Проверяется это запросом, а не прогоном шага, и разница выяснилась ревью с оракулом: хранилище держит обязательность связи проверкой записи при сохранении, а не ограничением таблицы. Смена признака на базе с ничьей записью проходит зелёным и такую запись оставляет — то есть прогон шага на копии боевой базы чистую базу от грязной не отличает. Заставить шаг считать строки самому владелец решил не делать (решение от 2026-08-14): безопасность держится ручной проверкой, и она названа первым шагом плана перехода.

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

Колонка темы словаря обязательной была уже — разное правило у трёх колонок одного смысла читалось бы как недосмотр, и теперь их правило одно.

Рассмотрено и отвергнуто:

  • Новый шаг, убирающий колонки бота. Он законен — запрет стоит на правке применённого шага, а не на новом, — но необратим по данным: колонки заполнены у записей живых людей, и восстановить их после удаления неоткуда. Возврат входа завёл бы их заново пустыми.
  • Сужение перечня source до unknown и api. Строки со значением telegram в базе есть, и после сужения перечня они перестают проходить проверку схемы: панель откажется их сохранять, а поведение записи при чтении зависит от библиотеки. Цена — молчаливая порча уже принятых записей.

Поля адресата уходят при этом из модели — колонки остаются в схеме, а аудиозапись их больше не несёт. Это безопасно ровно потому, что колонки адресата пишет только заведение записи: снимок шага конвейера кладётся отображением, где их нет вовсе, и потому значение уже принятой записи он не затирает. Правила internal/archrules такую пару держат: колонка, которую никто не пишет, законна, незаконна колонка, которую пишут, но не читают.

Следствие: константа entity.SourceTelegram остаётся в модели. На неё ссылается применённый шаг 202608140002, и убрать её значило бы переписать применённый шаг. В модели она получает комментарий об историческом значении: новых записей с ним не появляется.

Владелец записи перестаёт быть необязательным и в модели

Поле владельца в аудиозаписи становится обычной строкой вместо ссылки, которой позволено отсутствовать. Модель здесь повторяет схему, а не расходится с ней: значения «владельца нет» больше не существует ни на одном уровне.

Рассмотрено и отвергнуто:

  • Оставить ссылку, которой позволено отсутствовать. Она заставляет каждого читателя решать, что делать с отсутствием, хотя отсутствия больше не бывает. Два способа выразить одно значение расходятся молча.
  • Держать обязательность одним приёмом, схему не трогать. Так было задумано сперва, и это оставляло дыру: ничью запись заводили руками в панели, она уходила в конвейер, стоила денег на распознавание и не доставалась потом никому. Решение владельца от 2026-08-14 — обязательность держит схема.

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

Отправителя о готовности и об остановке узнаёт опрос, и другого канала нет

Доставка из конвейера убирается вместе с договором об отправителе сообщений. Правило о недоставленном ответе уходит: недоставке взяться неоткуда.

Рассмотрено и отвергнуто:

  • Оставить заглушку отправителя. Договор без единой реализации, кроме пустой, — это мёртвый шов, который читается как незаконченная работа и переживает не одну задачу.
  • Завести уведомление взамен сразу. Это другой предмет со своей внешней зависимостью; задача на него уже стоит в беклоге, и слепить их значит собрать два изменения в одно.

Метка убранного входа не выставляется вовсе

Признак поднятого входа остаётся, метка telegram у него больше не появляется. Ноль вместо неё читается как «вход есть, но не поднялся», то есть как поломка; владелец, у которого на этот признак стоит отбор, увидел бы аварию на ровном месте.

Незнакомые ключи настроек по-прежнему не судятся

Секция [telegram] и ключ server.users_while_list убираются из структуры настроек и из образца. Имя ключа написано с опечаткой — while вместо white, — и это записано «Расхождением» в docs/conventions/config.md; удаление ключа закрывает и его. Файл, где их забыли, сервис поднимет молча: разбор настроек незнакомые ключи не проверяет.

Рассмотрено и отвергнуто: завести отказ старта по незнакомому ключу. Это новое поведение настроек, полезное само по себе, но чужое этой задаче: оно касается всех ключей, а не убранных, и разбирается своей задачей.

Risks / Trade-offs

  • У сервиса не остаётся входа, которым человек может воспользоваться. Это главный риск изменения, и он не смягчается ничем внутри задачи. Приложения нет, своего токена у программы нет, значит после выкладки положить запись можно единственным способом: собранным руками запросом с сессией, снятой из браузера после входа. Срок этого состояния задаётся чужими задачами — экраном приложения и личными токенами, — и внутри этой задачи не назначается. Прецедент в журнале ревью: 2026-08-12 поверхность закрыли так, что войти не мог никто, и спасением тогда был именно бот.
  • Боевой файл настроек переживёт выкладку с мёртвой секцией → сервис поднимется без бота, и это ровно то, чего мы хотим; секцию убирает человек при выкладке, и об этом сказано в плане перехода.
  • Записи, застрявшие в конвейере на минуту выкладки, дойдут до текста, и ответа в чат по ним не уйдёт → отправитель в Telegram ответа не получит вовсе. Смягчения нет и быть не может: чат — это и есть убираемый вход. Расшифровка не теряется, владелец сервиса видит её в панели.
  • Ничья запись, если она всё же найдётся, шагом не ловится → она остаётся в базе и становится незакрываемой: сохранить её нечем, остановить тоже, а следа не остаётся ни в метрике, ни в журнале событий. Смягчение одно и ручное — проверка запросом первым шагом плана перехода. Заставить шаг считать строки самому владелец решил не делать.
  • Возврат входа стоит восстановления кода → удаление уезжает одним изменением, и его архив вместе с историей git даёт точку возврата. Связь чата с учётной записью всё равно потребует нового кода: без неё возвращать вход значит возвращать записи без владельца.
  • Правила, потерявшие предмет, останутся в памятке и в модели угроз → инварианты про белый список бота и про ответ отправителю убираются синком документации тем же изменением, а не следующей задачей.

Migration Plan

  1. Проверить боевую базу запросом: SELECT count(*) FROM audio_records WHERE owner = '' и то же по files. Оба должны отдать ноль. Прогон самого шага на копии этого не показывает — он проходит зелёным и на грязной базе.
  2. Собрать образ (task image) — сборка образа в гейт не входит, и человек обязан прогнать её сам.
  3. Выложить (inv pl -- transcriber из pet-project-server) — запускает человек.
  4. Убрать из боевого файла настроек секцию [telegram] и ключ server.users_while_list. Порядок с шагом 3 безразличен: оставленные ключи сервис пропускает молча, и для подъёма этот шаг не нужен.
  5. Ключ доступа к боту остаётся в хранилище выкладки под возврат входа — решение владельца от 2026-08-14. Ротация не делается, бот у Telegram остаётся зарегистрированным. Цена решения названа прямо: отправитель голосового не получит ни ответа, ни отказа и не отличит выведенный вход от сломанного сервиса. Оповещать людей из списка допущенных владелец не стал.
  6. Откат — прежний образ. Он потребует вернуть настройки: сервис прошлой версии без ключа telegram.enabled не поднимается. Схему откат вернёт своим обратным шагом — колонка владельца снова примет пустое значение, — и на записях это не сказывается: пустых среди них нет.

Формы решения, между которыми выбирали

Форма выбрана не единственной рассмотренной, и остальные две названы здесь, а не подразумеваются:

  • Выключить вход признаком, код оставить. Признак telegram.enabled заведён 2026-08-13 и обязателен, а приём по HTTP владельца уже требует: одна правка ключа в боевом файле даёт «новых записей без владельца не заводится» ценой ноля строк кода и мгновенным возвратом. Отвергнуто по причине из раздела «Why»: двойная модель остаётся в коде, и оговорку про бота продолжает платить каждая следующая задача. Размен здесь честный — стоимость сопровождения против стоимости отката, — и выбран он в пользу первой.
  • Сузить бота до исходящего канала. Приём убрать, отправку оставить с одним адресатом — чатом владельца строкой настроек; связь чата с учётной записью для этого не нужна. Смягчает главный риск: человек хотя бы узнаёт исход. Отвергнуто потому, что заводит понятие «канал уведомления владельца», которое тут же переделает задача ntfy-delivery, и держит ради этого клиент, договор и зависимость — плату за временный мост.

Open Questions

  • Критерии приёмки постановка не назвала: они предложены в tasks.md и подтверждаются человеком на чекпоинте.
  • Судьба задач беклога telegram-account-link и bot-api-only-through-bot-client: обе теряют предмет до возвращения входа. Решает владелец; это изменение их не трогает.