В карточке подтверждения и уведомлении о готовности бот теперь показывает
запись матча метабазы — провайдер, id и кликабельную ссылку на страницу
записи, — как веб-страница /download/{id} и экран ревью. Так ошибочную
привязку видно и из Telegram.
Билдер URL записи (providerURL/matchURL) вынесен из internal/httpapi в ядро
internal/worker (worker.ProviderURL + метод (*ReviewData).MatchURL()), чтобы
оба транспорта строили ссылку одинаково; httpapi делегирует туда. baseLine
переведён на эффективные provider/id (с учётом ручных правок), URL в href
экранируется escHref (сверх esc закрывает кавычку — иначе изготовленный id
разорвал бы атрибут и Telegram отклонил бы сообщение). При отсутствии матча
карточка ревью показывает «нет матча», уведомление о готовности строку
опускает.
Capability notifications: ADDED «Показ записи матча метабазы» + MODIFIED
требования об экранировании (id матча и URL, контекст href).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
170 lines
12 KiB
Markdown
170 lines
12 KiB
Markdown
# 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 — `<code>`), чтобы в клиенте работало 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 (как на веб-странице загрузки),
|
||
а не значения сырого распознавания
|
||
|