tgbot: показывать запись матча метабазы (провайдер+id+ссылка)
В карточке подтверждения и уведомлении о готовности бот теперь показывает
запись матча метабазы — провайдер, 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>
This commit is contained in:
@@ -0,0 +1,75 @@
|
||||
# Design
|
||||
|
||||
## Контекст
|
||||
|
||||
Билдер URL записи метабазы сейчас живёт в транспорте `internal/httpapi`
|
||||
(`providerURL` — канонический URL по provider/id/type; `matchURL` — выбор ссылки
|
||||
матча: URL выбранного кандидата, если его provider+id совпадают с эффективными,
|
||||
иначе канонический). Telegram-транспорт (`internal/tgbot`) не может переиспользовать
|
||||
эти функции: транспорт не должен зависеть от другого транспорта, да и незачем
|
||||
дублировать логику. Оба транспорта уже зависят от ядра `internal/worker` и его
|
||||
`ReviewData`.
|
||||
|
||||
## Решение
|
||||
|
||||
### Билдер URL — в ядро worker
|
||||
|
||||
Переносим в `internal/worker`:
|
||||
|
||||
- `func ProviderURL(provider, id, mediaType string) string` — экспортируемая
|
||||
чистая функция (канонический URL; пустой id или неизвестный провайдер → пусто).
|
||||
- `func (rd *ReviewData) MatchURL() string` — метод: приоритет URL выбранного
|
||||
кандидата (при совпадении provider+id с эффективными), иначе
|
||||
`ProviderURL(rd.Provider, rd.ProviderID, string(rd.Plan.Type))`. Тип медиа для
|
||||
URL всегда `rd.Plan.Type` (так и звали оба вызова в httpapi), поэтому метод
|
||||
берёт его сам — вызывающему не нужно передавать.
|
||||
|
||||
httpapi делегирует: `view.MatchURL = rd.MatchURL()`; `sourceMatchURL` зовёт
|
||||
`worker.ProviderURL(...)`. Обратный разбор `parseProviderURL` (URL → provider/id)
|
||||
и парсинг ручного ввода остаются в httpapi — это транспортный ввод, не общий
|
||||
билдер. Тесты `providerURL`/`matchURL` переезжают в `internal/worker`.
|
||||
|
||||
### Что показываем в боте
|
||||
|
||||
`baseLine` меняем: принимает `*worker.ReviewData` (а не сырой `*store.Recognition`),
|
||||
использует **эффективные** `rd.Provider`/`rd.ProviderID` (как веб — с учётом
|
||||
ручных правок) и `rd.MatchURL()`:
|
||||
|
||||
- есть URL → `<a href="URL">provider id ↗</a>` (provider, id экранированы через
|
||||
`esc`; URL — через `escHref`, см. «Безопасность»);
|
||||
- нет URL → `provider id` текстом (как раньше);
|
||||
- матча нет (`""`/`none`) → возвращает пусто (вызывающий решает, показывать ли
|
||||
индикатор).
|
||||
|
||||
Строку матча показываем в двух местах:
|
||||
|
||||
- **карточка ревью** (`reviewCard`) — точка подтверждения, где привязку ещё можно
|
||||
поправить; строка «База: …» уже была, добавляем в неё ссылку и переводим на
|
||||
эффективный провайдер. Когда матча нет — сохраняем текущее поведение: «База:
|
||||
нет матча» (в ревью полезно видеть, что база не выбрана).
|
||||
- **уведомление о готовности** (`renderDone`) — добавляем строку «База: …**только
|
||||
при наличии матча**, чтобы ошибочную привязку было видно и в финальном пинге
|
||||
(файлы уже разложены, но расхождение заметно сразу). Без матча строку опускаем
|
||||
— в готовности «нет матча» лишний шум.
|
||||
|
||||
Асимметрия «нет матча» между поверхностями осознанная: индикатор в ревью помогает
|
||||
(можно добавить базу), в финальном пинге — нет. `baseLine` поэтому отдаёт пусто на
|
||||
«нет матча», а текст «нет матча» подставляет `reviewCard` (единственная
|
||||
поверхность, где он нужен).
|
||||
|
||||
Прочие уведомления (падение/рассинхрон) матч не показывают: там нет
|
||||
подтверждённого результата раскладки, релевантна причина сбоя, а не запись базы.
|
||||
|
||||
### Безопасность
|
||||
|
||||
`provider`, `id`, `URL` — недоверенные (метабаза/LLM/ручной ввод). `provider` и id
|
||||
экранируются через `esc` (текстовый контекст). Для **URL контекст другой —
|
||||
значение атрибута `href`**, а `esc` (`tgbotapi.EscapeText(ModeHTML)`) заменяет
|
||||
только `<`, `>`, `&` и **не трогает `"`**. Между тем `ProviderURL` подставляет id
|
||||
в URL сырым (`"…/title/" + id`), а id недоверенный: id с `"` разорвал бы атрибут
|
||||
`href` и Telegram отклонил бы сообщение (parse error) → уведомление о матче тихо
|
||||
не доставилось бы. Поэтому URL экранируем хелпером `escHref`, который поверх `esc`
|
||||
дополнительно заменяет `"` на `"` (порядок безопасен: `esc` уже перевёл `&` в
|
||||
`&`, так что `&` в `"` не удвоится). Ссылка рисуется только при непустом
|
||||
URL из `MatchURL()`. Миграции не нужны — данные матча уже в БД (`recognition`,
|
||||
`metadata_candidate`).
|
||||
@@ -0,0 +1,58 @@
|
||||
## Why
|
||||
|
||||
Веб уже показывает, с какой именно записью метабазы сматчилась загрузка:
|
||||
страница `/download/{id}` и экран ревью выводят provider, id и — если URL
|
||||
строится — ссылку на страницу записи (`internal/httpapi`: `matchURL`/
|
||||
`providerURL`, шаблон `download_main.html`). В Telegram матч показан беднее:
|
||||
карточка ревью (`internal/tgbot/render.go`, `baseLine`) выводит только
|
||||
`provider id` из **сырого** распознавания и **без ссылки**, а уведомление о
|
||||
готовности матч не показывает вовсе. Из-за этого ошибочную привязку (не тот
|
||||
фильм/сезон) из бота не видно — приходится открывать веб.
|
||||
|
||||
## What Changes
|
||||
|
||||
- **Карточка подтверждения (review)** в боте показывает матч со **ссылкой** на
|
||||
запись метабазы (когда URL строится) — как веб: `provider id ↗`. Без URL —
|
||||
тем же текстом, что и раньше.
|
||||
- **Уведомление о готовности** (`renderDone`) показывает строку матча (provider,
|
||||
id, ссылка) — чтобы ошибочную привязку было видно и в финальном пинге.
|
||||
- **Provider/id берутся эффективные** (`ReviewData.Provider`/`ProviderID`, с
|
||||
учётом ручных правок), консистентно с веб-страницей и экраном ревью, а не из
|
||||
сырого распознавания.
|
||||
- **Единый билдер URL:** канонический `providerURL` и выбор ссылки матча
|
||||
`matchURL` переезжают из `internal/httpapi` в ядро `internal/worker`
|
||||
(`worker.ProviderURL` + метод `(*ReviewData).MatchURL()`), чтобы оба
|
||||
транспорта (веб и Telegram) строили ссылку одинаково. httpapi делегирует туда.
|
||||
- **Экранирование:** provider, id и URL — недоверенные, экранируются перед
|
||||
вставкой в HTML-сообщение (инвариант «выход LLM недоверенный»); ссылка
|
||||
рисуется только при непустом URL. URL — в контексте атрибута `href`: помимо
|
||||
`<`/`>`/`&` экранируется и кавычка (иначе изготовленный id разорвёт атрибут и
|
||||
Telegram отклонит сообщение).
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
Нет.
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `notifications`: добавляется требование к **содержанию** уведомлений/
|
||||
подтверждений — показ записи матча метабазы (provider, id, ссылка) в карточке
|
||||
ревью и уведомлении о готовности, эффективным провайдером, с экранированием.
|
||||
Условия и события доставки (падение, review, готовность, рассинхрон) без
|
||||
изменений.
|
||||
|
||||
## Impact
|
||||
|
||||
- **Спеки:** дельта `notifications` — ADDED «Показ записи матча метабазы» +
|
||||
MODIFIED «Экранирование внешнего текста» (в перечень добавлены id матча и URL,
|
||||
экранирование учитывает контекст `href`/кавычку).
|
||||
- **Код:** `internal/worker/review.go` (новые `ProviderURL` + метод `MatchURL`),
|
||||
`internal/httpapi/review.go`/`download.go` (делегируют в worker; локальные
|
||||
`providerURL`/`matchURL` удаляются, `sourceMatchURL` зовёт `worker.ProviderURL`),
|
||||
`internal/tgbot/render.go` (`baseLine` по `ReviewData` со ссылкой; строка матча
|
||||
в `renderDone`).
|
||||
- **Тесты:** тесты `providerURL`/`matchURL` переезжают в `internal/worker`;
|
||||
`internal/tgbot` — проверка ссылки и экранирования в строке матча.
|
||||
- **Миграции БД:** нет (данные матча уже в БД).
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### 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 (как на веб-странице загрузки),
|
||||
а не значения сырого распознавания
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### 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` экранируется, атрибут остаётся целым и
|
||||
сообщение доставляется
|
||||
@@ -0,0 +1,42 @@
|
||||
## 1. Ядро: общий билдер URL
|
||||
|
||||
- [x] 1.1 `internal/worker/review.go`: добавить экспортируемую
|
||||
`func ProviderURL(provider, id, mediaType string) string` (канонический URL;
|
||||
пустой id / неизвестный провайдер → пусто) и метод
|
||||
`func (rd *ReviewData) MatchURL() string` (приоритет URL выбранного кандидата
|
||||
при совпадении provider+id с эффективными, иначе `ProviderURL` по
|
||||
`rd.Provider`/`rd.ProviderID`/`rd.Plan.Type`).
|
||||
- [x] 1.2 Перенести тесты `providerURL`/`matchURL` в `internal/worker`
|
||||
(из `internal/httpapi/providerurl_test.go`), поправив на новые имена/сигнатуры.
|
||||
|
||||
## 2. httpapi: делегирование
|
||||
|
||||
- [x] 2.1 `internal/httpapi/review.go`/`download.go`: удалить локальные
|
||||
`providerURL`/`matchURL`; `view.MatchURL = rd.MatchURL()`; `sourceMatchURL`
|
||||
зовёт `worker.ProviderURL(src.Provider, src.ProviderID, src.Type)`.
|
||||
`parseProviderURL`/`parseManualSource` остаются в httpapi.
|
||||
|
||||
## 3. tgbot: показ матча
|
||||
|
||||
- [x] 3.1 `internal/tgbot/render.go`: `escHref` — экранирование URL для значения
|
||||
атрибута `href` (поверх `esc` заменяет `"` → `"`).
|
||||
- [x] 3.2 `baseLine` принимает `*worker.ReviewData`, использует эффективные
|
||||
`rd.Provider`/`rd.ProviderID` и `rd.MatchURL()`: при непустом URL —
|
||||
`<a href="...">provider id ↗</a>` (provider/id через `esc`, URL через `escHref`),
|
||||
иначе текст `provider id`; при отсутствии матча — пусто.
|
||||
- [x] 3.3 `reviewCard` зовёт `baseLine(rd)`, при пустом результате показывает
|
||||
«База: нет матча» (сохранение поведения); `renderDone` добавляет строку
|
||||
«База: …» только при непустом `baseLine(rd)` (без матча — опускает).
|
||||
|
||||
## 4. Тесты
|
||||
|
||||
- [x] 4.1 `internal/tgbot`: карточка ревью с матчем содержит `<a href=...↗`;
|
||||
провайдер/id экранированы; матч без URL — текстом; `renderDone` содержит строку
|
||||
матча при матче и опускает её без матча; карточка ревью без матча — «нет матча».
|
||||
- [x] 4.2 `internal/tgbot`: id с `"` — кавычка в `href` экранируется (`"`),
|
||||
атрибут остаётся целым.
|
||||
|
||||
## 5. Спека и проверки
|
||||
|
||||
- [x] 5.1 `openspec validate --strict telegram-metabase-match`.
|
||||
- [x] 5.2 `task test` и `task lint` — зелёные.
|
||||
Reference in New Issue
Block a user