# notifications Specification ## Purpose Уведомление автора загрузки о значимых событиях: падения (`failed`/`stuck`, включая приёмный `qbit_add` мимо поллинга) с дебаунсом повторов, приглашение в `review` и готовность, рассинхрон (`orphaned`/`target_missing`). Единое место доставки пингов, над которым транспорты (Telegram и др.) — тонкие адаптеры. ## Requirements ### Requirement: Уведомление о падении загрузки Любой переход загрузки в `failed`/`stuck` система SHALL сопровождать уведомлением автора загрузки через настроенный механизм (`notifier`), чтобы падение не оставалось незамеченным. Это SHALL включать приёмное падение `qbit_add` (не удалось добавить раздачу в qBittorrent), которое идёт мимо поллинг-цикла worker. #### Scenario: Уведомление при падении приёма - **GIVEN** приём загрузки, где добавление в qBittorrent не удалось - **WHEN** загрузка помечается `failed` с `error_code` `qbit_add` - **THEN** автор загрузки получает уведомление о падении ### Requirement: Дебаунс повторных падений Повторные падения одной задачи в пределах окна дебаунса система SHALL уведомлять лишь один раз, чтобы мерцающий stalled-торрент (`stuck` ↔ `downloading`) не спамил автора. #### Scenario: Мерцающий stalled не спамит - **GIVEN** задача, многократно переходящая `stuck` ↔ `downloading` в пределах окна дебаунса - **WHEN** происходят повторные падения - **THEN** уведомление отправляется один раз за окно ### Requirement: Пинг о входе в review и готовности При переходе загрузки в `review` система SHALL пинговать автора (сообщение в Telegram / бейдж в вебе) — пользователя зовут, а не он опрашивает. После успешного применения (готовность) система SHALL показывать, что создано. #### Scenario: Пинг при входе в review - **GIVEN** загрузка переходит в `review` - **WHEN** происходит переход - **THEN** автор получает пинг с приглашением подтвердить раскладку ### Requirement: Уведомление о рассинхроне При переходе задачи в `orphaned` или `target_missing` система SHALL уведомлять автора загрузки через настроенный механизм уведомлений (`notifier`), чтобы рассинхрон не оставался незамеченным. #### Scenario: Уведомление при потере источника - **WHEN** задача переходит в `orphaned` - **THEN** автор загрузки получает уведомление о рассинхроне ### Requirement: Download id в уведомлениях моноширинным для tap-to-copy Уведомления, содержащие download id, система SHALL отображать id моноширинным блоком (в Telegram — ``), чтобы в клиенте работало tap-to-copy: id можно скопировать одним касанием для перехода на `/download/{id}` или диагностики по логам. Визуальный префикс (`#` / `download_id=`) SHALL оставаться вне моноширинного блока, чтобы копировался чистый id без лишних символов. #### Scenario: Id карточки review копируется одним касанием - **GIVEN** уведомление о входе загрузки в `review` с download id - **WHEN** бот рендерит сообщение - **THEN** download id выводится моноширинным блоком (tap-to-copy), а префикс `#` остаётся обычным текстом вне блока #### Scenario: Id в сообщении об ошибке копируется - **GIVEN** сообщение об отказе операции с `download_id` - **WHEN** бот рендерит сообщение - **THEN** значение download id выводится моноширинным блоком для копирования ### Requirement: Экранирование внешнего текста при форматированных уведомлениях При включённом форматировании исходящих сообщений (parse mode) система MUST экранировать все внешние/недоверенные фрагменты перед вставкой в размеченное сообщение: display name, распознанное название, источник/контекст, целевой путь, причины распознавания, provider, id матча, ссылку на запись метабазы (URL), код и текст ошибки. Экранирование MUST учитывать контекст вставки: для значения в атрибуте (URL в `href`) — в том числе кавычку, чтобы недоверенное значение не разорвало атрибут. Это защищает от того, что спецсимволы разметки сломают сообщение или что разметка будет инъектирована из недоверенного источника (инвариант «выход LLM недоверенный»). Секреты (токены/ключи/пароли) MUST NOT попадать в текст уведомлений и логи. #### Scenario: Спецсимволы в названии не ломают разметку - **GIVEN** уведомление, где display name или распознанное название содержит символы разметки (`<`, `>`, `&`) - **WHEN** бот рендерит форматированное сообщение - **THEN** эти символы экранируются, сообщение доставляется корректно, а разметка из недоверенного текста не интерпретируется #### Scenario: Внешний путь и причины экранируются - **GIVEN** уведомление с целевым путём плана и причинами распознавания - **WHEN** бот рендерит форматированное сообщение - **THEN** символы разметки в пути и причинах экранируются перед вставкой #### Scenario: Кавычка в URL записи не разрывает атрибут href - **GIVEN** уведомление со ссылкой на запись метабазы, где id (а значит URL) содержит кавычку - **WHEN** бот рендерит ссылку матча - **THEN** кавычка в значении `href` экранируется, атрибут остаётся целым и сообщение доставляется ### Requirement: Показ записи матча метабазы в уведомлениях Уведомления и подтверждения бота по загрузке с матчем метабазы система SHALL сопровождать записью матча: provider и id, а при возможности построить URL записи — ссылкой на страницу записи (тот же канонический билдер URL, что и веб). Это SHALL применяться в карточке подтверждения (`review`) и в уведомлении о готовности. Provider и id SHALL отражать эффективный выбор (с учётом ручных правок), консистентно с веб-страницей загрузки и экраном ревью. Когда URL не строится, матч SHALL показываться текстом (provider и id без ссылки), чтобы ошибочную привязку было видно из бота. Отсутствие матча (`none`/пусто) поверхности отражают по-разному: карточка подтверждения SHALL показывать явный индикатор «нет матча» (в ревью полезно видеть, что база не выбрана), а уведомление о готовности строку матча в этом случае SHALL опускать (в финальном пинге «нет матча» — шум). Provider, id и URL — недоверенные (метабаза/LLM/ручной ввод), поэтому система MUST экранировать их перед вставкой в размеченное сообщение так, чтобы значение не могло разорвать разметку в своём контексте (для URL в атрибуте `href` — с учётом кавычки), иначе изготовленный id способен сломать сообщение и подавить доставку уведомления (инвариант «выход LLM недоверенный»). #### Scenario: Матч со ссылкой в карточке review - **GIVEN** загрузка в `review` с подтверждённым матчем метабазы, для которого строится URL записи - **WHEN** бот рендерит карточку подтверждения - **THEN** матч выводится ссылкой на страницу записи с provider и id, а provider, id и URL экранированы (в т.ч. кавычка в значении `href`) #### Scenario: Матч без строящегося URL показывается текстом - **GIVEN** загрузка с матчем, для провайдера которого URL записи не строится - **WHEN** бот рендерит карточку подтверждения - **THEN** матч выводится текстом (provider и id) без ссылки #### Scenario: Показ матча в уведомлении о готовности - **GIVEN** загрузка с матчем метабазы, перешедшая в готовность - **WHEN** бот рендерит уведомление о готовности - **THEN** уведомление содержит запись матча (provider, id, при возможности — ссылку), чтобы ошибочную привязку было видно после раскладки #### Scenario: Нет матча — индикатор в review, пропуск в готовности - **GIVEN** загрузка без матча метабазы (`none`/пусто) - **WHEN** бот рендерит карточку подтверждения, а затем уведомление о готовности - **THEN** карточка подтверждения показывает индикатор «нет матча», а уведомление о готовности строку матча не содержит #### Scenario: Эффективный провайдер после ручной правки - **GIVEN** загрузка, где провайдер/id матча переопределены вручную - **WHEN** бот рендерит запись матча - **THEN** показываются эффективные provider и id (как на веб-странице загрузки), а не значения сырого распознавания