Files
jellybit/openspec/changes/archive/2026-07-18-telegram-metabase-match/design.md
T
avandClaude Opus 4.8 10a6348d39 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>
2026-07-18 15:41:28 +03:00

5.8 KiB
Raw Blame History

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 дополнительно заменяет " на &quot; (порядок безопасен: esc уже перевёл & в &amp;, так что & в &quot; не удвоится). Ссылка рисуется только при непустом URL из MatchURL(). Миграции не нужны — данные матча уже в БД (recognition, metadata_candidate).