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:
@@ -46,7 +46,6 @@ Tududi (проект `jellybit`) больше **не** держит беклог
|
|||||||
- [[идея] Многоступенчатая верификация привязки](mnogostupenchataya-verifikaciya.md) — ИДЕЯ (требует проработки)
|
- [[идея] Многоступенчатая верификация привязки](mnogostupenchataya-verifikaciya.md) — ИДЕЯ (требует проработки)
|
||||||
- [Согласование канона нумерации серий с провайдером тега](kanon-numeracii-vs-provajder.md) — Косметика/редкость: порядок просмотра ок, но у тайтлов со спорным порядком (Бибоп) Jellyfin подтягивает не те подписи серий, если канон файлов ≠ дефолтный порядок провайдера тега
|
- [Согласование канона нумерации серий с провайдером тега](kanon-numeracii-vs-provajder.md) — Косметика/редкость: порядок просмотра ок, но у тайтлов со спорным порядком (Бибоп) Jellyfin подтягивает не те подписи серий, если канон файлов ≠ дефолтный порядок провайдера тега
|
||||||
- [Выбор из нескольких находок метабазы в Telegram](telegram-vybor-nahodok.md) — Когда распознавание даёт несколько подходящих кандидатов в метабазе, предлагать их в…
|
- [Выбор из нескольких находок метабазы в Telegram](telegram-vybor-nahodok.md) — Когда распознавание даёт несколько подходящих кандидатов в метабазе, предлагать их в…
|
||||||
- [Улучшения UI: показывать матч с записью метабазы в Telegram](telegram-match-metabazy.md) — Название/год/провайдер+id в боте уже выводятся; осталась кликабельная ссылка на запись
|
|
||||||
- [Добавление торрентов файлом/ссылкой — «единое окно» (остаток: URL)](dobavlenie-edinoe-okno.md) — magnet и .torrent-файл приняты; остался фетч .torrent по URL (нужен SSRF-гард)
|
- [Добавление торрентов файлом/ссылкой — «единое окно» (остаток: URL)](dobavlenie-edinoe-okno.md) — magnet и .torrent-файл приняты; остался фетч .torrent по URL (нужен SSRF-гард)
|
||||||
- [Раздачи-копии диска (DVD/BluRay: VIDEO_TS/BDMV)](disk-kopii-video-ts-bdmv.md) — Иногда для очень редких фильмов скачивается не один видеофайл, а полная копия диска…
|
- [Раздачи-копии диска (DVD/BluRay: VIDEO_TS/BDMV)](disk-kopii-video-ts-bdmv.md) — Иногда для очень редких фильмов скачивается не один видеофайл, а полная копия диска…
|
||||||
- [Проверка свободного места перед copy-fallback](svobodnoe-mesto-copy-fallback.md) — Когда хардлинк невозможен (EXDEV/ENOTSUP/…), layout копирует файл, дублируя место на диске
|
- [Проверка свободного места перед copy-fallback](svobodnoe-mesto-copy-fallback.md) — Когда хардлинк невозможен (EXDEV/ENOTSUP/…), layout копирует файл, дублируя место на диске
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
# Улучшения UI: показывать матч с записью метабазы в Telegram
|
|
||||||
|
|
||||||
**Приоритет:** низкий
|
|
||||||
|
|
||||||
Web-сторона реализована: страница загрузки /download/{id} и экран ревью показывают, с какой именно записью метабазы (TMDB/TVDB/IMDb) сматчилась загрузка — провайдер, id и ссылку. Осталось довести то же в Telegram: в уведомлениях/подтверждениях показывать запись матча (название, год, провайдер-id, ссылку), чтобы ошибочную привязку было видно и из бота. Полный выбор источника в вебе уже реализован.
|
|
||||||
|
|
||||||
Связано: specs/review-ux.md, specs/recognition.md (матч в базе), specs/architecture.md → «Транспорты».
|
|
||||||
@@ -160,7 +160,7 @@ func (s *server) buildDownloadView(id string, rd *worker.ReviewData) downloadDet
|
|||||||
default:
|
default:
|
||||||
view.Provider = rd.Provider
|
view.Provider = rd.Provider
|
||||||
view.ProviderID = rd.ProviderID
|
view.ProviderID = rd.ProviderID
|
||||||
view.MatchURL = matchURL(rd, view.MediaType)
|
view.MatchURL = rd.MatchURL()
|
||||||
}
|
}
|
||||||
if rd.Recognition.Confidence.Valid {
|
if rd.Recognition.Confidence.Valid {
|
||||||
view.Confidence = strconv.FormatFloat(rd.Recognition.Confidence.Float64, 'f', 2, 64)
|
view.Confidence = strconv.FormatFloat(rd.Recognition.Confidence.Float64, 'f', 2, 64)
|
||||||
|
|||||||
@@ -2,36 +2,8 @@ package httpapi
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"git.vakhrushev.me/av/jellybit/internal/store"
|
|
||||||
"git.vakhrushev.me/av/jellybit/internal/worker"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestProviderURL(t *testing.T) {
|
|
||||||
cases := []struct {
|
|
||||||
name string
|
|
||||||
provider string
|
|
||||||
id string
|
|
||||||
mtype string
|
|
||||||
want string
|
|
||||||
}{
|
|
||||||
{"tmdb movie", "tmdb", "693134", "movie", "https://www.themoviedb.org/movie/693134"},
|
|
||||||
{"tmdb series", "tmdb", "60622", "series", "https://www.themoviedb.org/tv/60622"},
|
|
||||||
{"tvdb series", "tvdb", "269613", "series", "https://www.thetvdb.com/dereferrer/series/269613"},
|
|
||||||
{"tvdb movie", "tvdb", "12345", "movie", "https://www.thetvdb.com/dereferrer/movie/12345"},
|
|
||||||
{"imdb", "imdb", "tt0111161", "movie", "https://www.imdb.com/title/tt0111161"},
|
|
||||||
{"unknown provider → пусто", "kinopoisk", "42", "movie", ""},
|
|
||||||
{"пустой id → пусто", "tmdb", "", "movie", ""},
|
|
||||||
}
|
|
||||||
for _, c := range cases {
|
|
||||||
t.Run(c.name, func(t *testing.T) {
|
|
||||||
if got := providerURL(c.provider, c.id, c.mtype); got != c.want {
|
|
||||||
t.Errorf("providerURL(%q,%q,%q) = %q, want %q", c.provider, c.id, c.mtype, got, c.want)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseManualSource(t *testing.T) {
|
func TestParseManualSource(t *testing.T) {
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -70,28 +42,3 @@ func TestParseManualSource(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestMatchURLNoLinkWhenUnbuildable — эффективный провайдер, для которого URL не
|
|
||||||
// строится и совпадающего кандидата нет, даёт пустую ссылку (транспорт покажет
|
|
||||||
// матч текстом — сценарий «URL записи неизвестен»).
|
|
||||||
func TestMatchURLNoLinkWhenUnbuildable(t *testing.T) {
|
|
||||||
rd := &worker.ReviewData{Provider: "kinopoisk", ProviderID: "42"}
|
|
||||||
if got := matchURL(rd, "movie"); got != "" {
|
|
||||||
t.Errorf("matchURL = %q, want пусто (URL не строится, кандидата нет)", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestMatchURLPrefersMatchingCandidate — URL берётся у выбранного кандидата,
|
|
||||||
// когда его provider+id совпадают с эффективными.
|
|
||||||
func TestMatchURLPrefersMatchingCandidate(t *testing.T) {
|
|
||||||
rd := &worker.ReviewData{
|
|
||||||
Provider: "tmdb", ProviderID: "693134",
|
|
||||||
Candidates: []store.MetadataCandidate{{
|
|
||||||
Provider: "tmdb", ProviderID: "693134", Chosen: true,
|
|
||||||
URL: store.NullString("https://custom.example/x"),
|
|
||||||
}},
|
|
||||||
}
|
|
||||||
if got := matchURL(rd, "movie"); got != "https://custom.example/x" {
|
|
||||||
t.Errorf("matchURL = %q, want URL выбранного кандидата", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -135,7 +135,7 @@ func buildReviewView(id string, rd *worker.ReviewData, errMsg string) reviewView
|
|||||||
default:
|
default:
|
||||||
view.Provider = rd.Provider
|
view.Provider = rd.Provider
|
||||||
view.ProviderID = rd.ProviderID
|
view.ProviderID = rd.ProviderID
|
||||||
view.MatchURL = matchURL(rd, string(rd.Plan.Type))
|
view.MatchURL = rd.MatchURL()
|
||||||
}
|
}
|
||||||
if rec.Confidence.Valid {
|
if rec.Confidence.Valid {
|
||||||
view.Confidence = strconv.FormatFloat(rec.Confidence.Float64, 'f', 2, 64)
|
view.Confidence = strconv.FormatFloat(rec.Confidence.Float64, 'f', 2, 64)
|
||||||
@@ -294,7 +294,7 @@ func looksLikeURL(s string) bool {
|
|||||||
strings.Contains(s, ".org/") || strings.Contains(s, ".com/")
|
strings.Contains(s, ".org/") || strings.Contains(s, ".com/")
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseProviderURL — обратная к providerURL: URL записи → (provider, id).
|
// parseProviderURL — обратная к worker.ProviderURL: URL записи → (provider, id).
|
||||||
// TMDB/IMDb извлекаются из URL; TVDB — только dereferrer с числовым id (URL
|
// TMDB/IMDb извлекаются из URL; TVDB — только dereferrer с числовым id (URL
|
||||||
// сайта thetvdb.com/series/{slug} числового id не содержит → не распознаём).
|
// сайта thetvdb.com/series/{slug} числового id не содержит → не распознаём).
|
||||||
func parseProviderURL(raw string) (provider, id string, ok bool) {
|
func parseProviderURL(raw string) (provider, id string, ok bool) {
|
||||||
@@ -336,12 +336,12 @@ func leadingDigits(s string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// sourceMatchURL — ссылка на запись источника-кандидата: URL кандидата, если
|
// sourceMatchURL — ссылка на запись источника-кандидата: URL кандидата, если
|
||||||
// есть, иначе канонический URL из provider/id (обратный порядок к matchURL).
|
// есть, иначе канонический URL из provider/id (обратный порядок к MatchURL).
|
||||||
func sourceMatchURL(src worker.SourceOption) string {
|
func sourceMatchURL(src worker.SourceOption) string {
|
||||||
if src.URL != "" {
|
if src.URL != "" {
|
||||||
return src.URL
|
return src.URL
|
||||||
}
|
}
|
||||||
return providerURL(src.Provider, src.ProviderID, src.Type)
|
return worker.ProviderURL(src.Provider, src.ProviderID, src.Type)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *server) handleDefer(w http.ResponseWriter, r *http.Request) {
|
func (s *server) handleDefer(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -503,47 +503,6 @@ func (s *server) reviewBlockAction(w http.ResponseWriter, r *http.Request, fn fu
|
|||||||
s.render(w, "review_source_swap", view)
|
s.render(w, "review_source_swap", view)
|
||||||
}
|
}
|
||||||
|
|
||||||
// matchURL выбирает ссылку на подтверждённую запись метабазы. Приоритет — URL
|
|
||||||
// выбранного кандидата, но только если его provider+id совпадают с эффективными
|
|
||||||
// (человек мог выбрать кандидата, затем вручную переопределить id — тогда
|
|
||||||
// кандидат указывает на другую запись). Иначе строим канонический URL; если не
|
|
||||||
// удаётся — возвращаем пусто (транспорт покажет матч текстом).
|
|
||||||
func matchURL(rd *worker.ReviewData, mediaType string) string {
|
|
||||||
for _, c := range rd.Candidates {
|
|
||||||
if c.Chosen && c.Provider == rd.Provider && c.ProviderID == rd.ProviderID &&
|
|
||||||
c.URL.Valid && c.URL.String != "" {
|
|
||||||
return c.URL.String
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return providerURL(rd.Provider, rd.ProviderID, mediaType)
|
|
||||||
}
|
|
||||||
|
|
||||||
// providerURL строит канонический URL записи метабазы с учётом типа медиа.
|
|
||||||
// Пустой id или неизвестный провайдер → пусто.
|
|
||||||
func providerURL(provider, id, mediaType string) string {
|
|
||||||
if id == "" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
switch provider {
|
|
||||||
case "tmdb":
|
|
||||||
kind := "movie"
|
|
||||||
if mediaType == "series" {
|
|
||||||
kind = "tv"
|
|
||||||
}
|
|
||||||
return "https://www.themoviedb.org/" + kind + "/" + id
|
|
||||||
case "tvdb":
|
|
||||||
kind := "series"
|
|
||||||
if mediaType == "movie" {
|
|
||||||
kind = "movie"
|
|
||||||
}
|
|
||||||
return "https://www.thetvdb.com/dereferrer/" + kind + "/" + id
|
|
||||||
case "imdb":
|
|
||||||
return "https://www.imdb.com/title/" + id
|
|
||||||
default:
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func redirectReview(w http.ResponseWriter, r *http.Request, id string, msg string) {
|
func redirectReview(w http.ResponseWriter, r *http.Request, id string, msg string) {
|
||||||
u := "/review/" + id
|
u := "/review/" + id
|
||||||
if msg != "" {
|
if msg != "" {
|
||||||
|
|||||||
@@ -127,6 +127,9 @@ func reviewData(state store.State) *worker.ReviewData {
|
|||||||
Provider: store.NullString("tvdb"), ProviderID: store.NullString("269613"),
|
Provider: store.NullString("tvdb"), ProviderID: store.NullString("269613"),
|
||||||
Reasons: `["неполный пак"]`,
|
Reasons: `["неполный пак"]`,
|
||||||
},
|
},
|
||||||
|
// Эффективные provider/id (как заполняет effectiveProvider) — их и
|
||||||
|
// показывают уведомления/карточка, консистентно с веб-страницей.
|
||||||
|
Provider: "tvdb", ProviderID: "269613",
|
||||||
Plan: recognize.Plan{
|
Plan: recognize.Plan{
|
||||||
Type: recognize.MediaSeries, Title: "Фарго", Year: 2015,
|
Type: recognize.MediaSeries, Title: "Фарго", Year: 2015,
|
||||||
Files: []recognize.PlanFile{{Src: "e1.mkv", Role: recognize.RoleEpisode, Season: &s, Episode: &e}},
|
Files: []recognize.PlanFile{{Src: "e1.mkv", Role: recognize.RoleEpisode, Season: &s, Episode: &e}},
|
||||||
|
|||||||
+42
-12
@@ -20,6 +20,15 @@ import (
|
|||||||
// `<` на битую разметку.
|
// `<` на битую разметку.
|
||||||
func esc(s string) string { return tgbotapi.EscapeText(tgbotapi.ModeHTML, s) }
|
func esc(s string) string { return tgbotapi.EscapeText(tgbotapi.ModeHTML, s) }
|
||||||
|
|
||||||
|
// escHref экранирует URL для вставки в значение атрибута `href`. Контекст
|
||||||
|
// атрибута строже текстового: esc (EscapeText) закрывает `<`/`>`/`&`, но НЕ
|
||||||
|
// трогает `"`, а недоверенный id уходит в URL сырым (см. worker.ProviderURL) —
|
||||||
|
// кавычка в id разорвала бы атрибут и Telegram отклонил бы сообщение (parse
|
||||||
|
// error → уведомление не доставится). Поэтому поверх esc заменяем `"` на
|
||||||
|
// `"`; порядок безопасен: esc уже перевёл `&` в `&`, повторно `&` в
|
||||||
|
// `"` не удвоится.
|
||||||
|
func escHref(url string) string { return strings.ReplaceAll(esc(url), `"`, """) }
|
||||||
|
|
||||||
// idCode оборачивает download id в моноширинный <code> — в клиентах Telegram по
|
// idCode оборачивает download id в моноширинный <code> — в клиентах Telegram по
|
||||||
// нему работает tap-to-copy (скопировать id для /download/{id} или диагностики).
|
// нему работает tap-to-copy (скопировать id для /download/{id} или диагностики).
|
||||||
// Визуальный префикс (`#` / `download_id=`) держим ВНЕ code, чтобы копировался
|
// Визуальный префикс (`#` / `download_id=`) держим ВНЕ code, чтобы копировался
|
||||||
@@ -69,8 +78,11 @@ func (b *Bot) reviewCard(rd *worker.ReviewData) (string, *tgbotapi.InlineKeyboar
|
|||||||
// guessLine/baseLine возвращают уже экранированный текст (внешние title/provider
|
// guessLine/baseLine возвращают уже экранированный текст (внешние title/provider
|
||||||
// внутри) — повторно не экранируем.
|
// внутри) — повторно не экранируем.
|
||||||
fmt.Fprintf(&sb, "Похоже на: %s\n", guessLine(rd))
|
fmt.Fprintf(&sb, "Похоже на: %s\n", guessLine(rd))
|
||||||
if base := baseLine(rd.Recognition); base != "" {
|
// В ревью показываем «нет матча» явно — полезно видеть, что база не выбрана.
|
||||||
|
if base := baseLine(rd); base != "" {
|
||||||
fmt.Fprintf(&sb, "База: %s\n", base)
|
fmt.Fprintf(&sb, "База: %s\n", base)
|
||||||
|
} else {
|
||||||
|
sb.WriteString("База: нет матча\n")
|
||||||
}
|
}
|
||||||
if reasons := rd.Recognition.ReasonList(); len(reasons) > 0 {
|
if reasons := rd.Recognition.ReasonList(); len(reasons) > 0 {
|
||||||
fmt.Fprintf(&sb, "Причины: %s\n", esc(strings.Join(reasons, " · ")))
|
fmt.Fprintf(&sb, "Причины: %s\n", esc(strings.Join(reasons, " · ")))
|
||||||
@@ -127,14 +139,22 @@ func titleLabel(rd *worker.ReviewData) string {
|
|||||||
return "#" + idCode(rd.Download.ID)
|
return "#" + idCode(rd.Download.ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// renderDone — короткое сообщение о готовности.
|
// renderDone — короткое сообщение о готовности. Дополняем строкой матча (база +
|
||||||
|
// ссылка), чтобы ошибочную привязку было видно и в финальном пинге; без матча
|
||||||
|
// строку опускаем — в готовности «нет матча» лишний шум.
|
||||||
func (b *Bot) renderDone(rd *worker.ReviewData) string {
|
func (b *Bot) renderDone(rd *worker.ReviewData) string {
|
||||||
label := titleLabel(rd)
|
label := titleLabel(rd)
|
||||||
|
var sb strings.Builder
|
||||||
n := len(rd.Preview)
|
n := len(rd.Preview)
|
||||||
if n == 0 {
|
if n == 0 {
|
||||||
return fmt.Sprintf("✅ Готово: %s разложен.", label)
|
fmt.Fprintf(&sb, "✅ Готово: %s разложен.", label)
|
||||||
|
} else {
|
||||||
|
fmt.Fprintf(&sb, "✅ Готово: %s — разложено файлов: %d.", label, n)
|
||||||
}
|
}
|
||||||
return fmt.Sprintf("✅ Готово: %s — разложено файлов: %d.", label, n)
|
if base := baseLine(rd); base != "" {
|
||||||
|
fmt.Fprintf(&sb, "\nБаза: %s", base)
|
||||||
|
}
|
||||||
|
return sb.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
// renderDesync — уведомление о рассинхроне (источник/цель удалены вручную).
|
// renderDesync — уведомление о рассинхроне (источник/цель удалены вручную).
|
||||||
@@ -267,16 +287,26 @@ func guessLine(rd *worker.ReviewData) string {
|
|||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
// baseLine возвращает уже экранированный текст (provider/provider_id — внешние,
|
// baseLine — запись матча метабазы для уведомлений: provider и id (эффективные,
|
||||||
// из метабазы/LLM): вызывающий вставляет как есть, без повторного esc.
|
// с учётом ручных правок — как на веб-странице загрузки), при возможности
|
||||||
func baseLine(rec *store.Recognition) string {
|
// построить URL — ссылкой на страницу записи (тот же билдер, что и веб:
|
||||||
if rec == nil || !rec.Provider.Valid || rec.Provider.String == "" || rec.Provider.String == "none" {
|
// worker.ReviewData.MatchURL). Возвращает уже экранированный HTML (provider/id —
|
||||||
return "нет матча"
|
// внешние, URL — в контексте href): вызывающий вставляет как есть. Пусто, если
|
||||||
|
// матча нет (провайдер пуст/none) — поверхность сама решает, показывать ли
|
||||||
|
// индикатор «нет матча».
|
||||||
|
func baseLine(rd *worker.ReviewData) string {
|
||||||
|
prov := rd.Provider
|
||||||
|
if prov == "" || prov == "none" {
|
||||||
|
return ""
|
||||||
}
|
}
|
||||||
if rec.ProviderID.Valid && rec.ProviderID.String != "" {
|
label := esc(prov)
|
||||||
return esc(rec.Provider.String) + " " + esc(rec.ProviderID.String)
|
if rd.ProviderID != "" {
|
||||||
|
label += " " + esc(rd.ProviderID)
|
||||||
}
|
}
|
||||||
return esc(rec.Provider.String)
|
if url := rd.MatchURL(); url != "" {
|
||||||
|
return fmt.Sprintf(`<a href="%s">%s ↗</a>`, escHref(url), label)
|
||||||
|
}
|
||||||
|
return label
|
||||||
}
|
}
|
||||||
|
|
||||||
func contextOrSource(rd *worker.ReviewData) string {
|
func contextOrSource(rd *worker.ReviewData) string {
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
package tgbot
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.vakhrushev.me/av/jellybit/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Карточка ревью показывает запись матча ссылкой на страницу записи (тот же
|
||||||
|
// билдер URL, что и веб): provider+id внутри <a href>, стрелка ↗, экранировано.
|
||||||
|
func TestReviewCard_ShowsMatchLink(t *testing.T) {
|
||||||
|
b, _, _, _ := newTestBot(t, []int64{7})
|
||||||
|
rd := reviewData(store.StateReview) // tvdb/269613, сериал
|
||||||
|
text, _ := b.renderCard(rd)
|
||||||
|
|
||||||
|
want := `<a href="https://www.thetvdb.com/dereferrer/series/269613">tvdb 269613 ↗</a>`
|
||||||
|
if !strings.Contains(text, "База: "+want) {
|
||||||
|
t.Errorf("нет ссылки матча в карточке ревью:\n%s", text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Запись матча берёт эффективные provider/id (с учётом ручных правок), а не
|
||||||
|
// сырое распознавание — консистентно с веб-страницей загрузки.
|
||||||
|
func TestReviewCard_UsesEffectiveProvider(t *testing.T) {
|
||||||
|
b, _, _, _ := newTestBot(t, []int64{7})
|
||||||
|
rd := reviewData(store.StateReview)
|
||||||
|
// Сырое распознавание — одно, эффективный выбор (ручная правка) — другое.
|
||||||
|
rd.Recognition.Provider = store.NullString("tvdb")
|
||||||
|
rd.Recognition.ProviderID = store.NullString("269613")
|
||||||
|
rd.Provider, rd.ProviderID = "imdb", "tt0111161"
|
||||||
|
text, _ := b.renderCard(rd)
|
||||||
|
|
||||||
|
if !strings.Contains(text, "imdb tt0111161") {
|
||||||
|
t.Errorf("должен показываться эффективный провайдер:\n%s", text)
|
||||||
|
}
|
||||||
|
if strings.Contains(text, "tvdb") || strings.Contains(text, "269613") {
|
||||||
|
t.Errorf("сырое распознавание не должно просачиваться:\n%s", text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Матч без строящегося URL (неизвестный провайдер) — текстом provider id, без
|
||||||
|
// ссылки.
|
||||||
|
func TestReviewCard_MatchWithoutURLAsText(t *testing.T) {
|
||||||
|
b, _, _, _ := newTestBot(t, []int64{7})
|
||||||
|
rd := reviewData(store.StateReview)
|
||||||
|
rd.Provider, rd.ProviderID = "kinopoisk", "42"
|
||||||
|
text, _ := b.renderCard(rd)
|
||||||
|
|
||||||
|
if !strings.Contains(text, "База: kinopoisk 42") {
|
||||||
|
t.Errorf("матч без URL должен быть текстом:\n%s", text)
|
||||||
|
}
|
||||||
|
if strings.Contains(text, "<a href") {
|
||||||
|
t.Errorf("для несобираемого URL ссылки быть не должно:\n%s", text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Нет матча: карточка ревью показывает индикатор «нет матча».
|
||||||
|
func TestReviewCard_NoMatchIndicator(t *testing.T) {
|
||||||
|
b, _, _, _ := newTestBot(t, []int64{7})
|
||||||
|
rd := reviewData(store.StateReview)
|
||||||
|
rd.Provider, rd.ProviderID = "none", ""
|
||||||
|
text, _ := b.renderCard(rd)
|
||||||
|
|
||||||
|
if !strings.Contains(text, "База: нет матча") {
|
||||||
|
t.Errorf("нет индикатора «нет матча»:\n%s", text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Уведомление о готовности содержит запись матча (база + ссылка) — чтобы
|
||||||
|
// ошибочную привязку было видно после раскладки.
|
||||||
|
func TestRenderDone_ShowsMatch(t *testing.T) {
|
||||||
|
b, _, _, _ := newTestBot(t, []int64{7})
|
||||||
|
rd := reviewData(store.StateDone)
|
||||||
|
text := b.renderDone(rd)
|
||||||
|
|
||||||
|
if !strings.Contains(text, "Готово") {
|
||||||
|
t.Fatalf("не сообщение о готовности:\n%s", text)
|
||||||
|
}
|
||||||
|
if !strings.Contains(text, `База: <a href="https://www.thetvdb.com/dereferrer/series/269613">tvdb 269613 ↗</a>`) {
|
||||||
|
t.Errorf("нет строки матча в готовности:\n%s", text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Без матча уведомление о готовности строку матча опускает (в финальном пинге
|
||||||
|
// «нет матча» — шум).
|
||||||
|
func TestRenderDone_OmitsBaseWithoutMatch(t *testing.T) {
|
||||||
|
b, _, _, _ := newTestBot(t, []int64{7})
|
||||||
|
rd := reviewData(store.StateDone)
|
||||||
|
rd.Provider, rd.ProviderID = "none", ""
|
||||||
|
text := b.renderDone(rd)
|
||||||
|
|
||||||
|
if strings.Contains(text, "База") {
|
||||||
|
t.Errorf("без матча строки «База» в готовности быть не должно:\n%s", text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Кавычка в id (а значит в URL) экранируется в значении href ("), атрибут
|
||||||
|
// остаётся целым — иначе Telegram отклонил бы сообщение (parse error).
|
||||||
|
func TestReviewCard_QuoteInURLEscaped(t *testing.T) {
|
||||||
|
b, _, _, _ := newTestBot(t, []int64{7})
|
||||||
|
rd := reviewData(store.StateReview)
|
||||||
|
rd.Provider, rd.ProviderID = "imdb", `tt1"onmouseover=x`
|
||||||
|
text, _ := b.renderCard(rd)
|
||||||
|
|
||||||
|
if strings.Contains(text, `="x`) || !strings.Contains(text, """) {
|
||||||
|
t.Errorf("кавычка в href не экранирована:\n%s", text)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
package worker
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.vakhrushev.me/av/jellybit/internal/recognize"
|
||||||
|
"git.vakhrushev.me/av/jellybit/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestProviderURL(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
provider string
|
||||||
|
id string
|
||||||
|
mtype string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"tmdb movie", "tmdb", "693134", "movie", "https://www.themoviedb.org/movie/693134"},
|
||||||
|
{"tmdb series", "tmdb", "60622", "series", "https://www.themoviedb.org/tv/60622"},
|
||||||
|
{"tvdb series", "tvdb", "269613", "series", "https://www.thetvdb.com/dereferrer/series/269613"},
|
||||||
|
{"tvdb movie", "tvdb", "12345", "movie", "https://www.thetvdb.com/dereferrer/movie/12345"},
|
||||||
|
{"imdb", "imdb", "tt0111161", "movie", "https://www.imdb.com/title/tt0111161"},
|
||||||
|
{"unknown provider → пусто", "kinopoisk", "42", "movie", ""},
|
||||||
|
{"пустой id → пусто", "tmdb", "", "movie", ""},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
if got := ProviderURL(c.provider, c.id, c.mtype); got != c.want {
|
||||||
|
t.Errorf("ProviderURL(%q,%q,%q) = %q, want %q", c.provider, c.id, c.mtype, got, c.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMatchURLNoLinkWhenUnbuildable — эффективный провайдер, для которого URL не
|
||||||
|
// строится и совпадающего кандидата нет, даёт пустую ссылку (транспорт покажет
|
||||||
|
// матч текстом — сценарий «URL записи неизвестен»).
|
||||||
|
func TestMatchURLNoLinkWhenUnbuildable(t *testing.T) {
|
||||||
|
rd := &ReviewData{Provider: "kinopoisk", ProviderID: "42"}
|
||||||
|
if got := rd.MatchURL(); got != "" {
|
||||||
|
t.Errorf("MatchURL = %q, want пусто (URL не строится, кандидата нет)", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMatchURLCanonicalFromEffective — без кандидатов ссылка строится по
|
||||||
|
// эффективным provider/id и типу медиа плана.
|
||||||
|
func TestMatchURLCanonicalFromEffective(t *testing.T) {
|
||||||
|
rd := &ReviewData{
|
||||||
|
Provider: "tmdb", ProviderID: "60622",
|
||||||
|
Plan: recognize.Plan{Type: recognize.MediaSeries},
|
||||||
|
}
|
||||||
|
if got := rd.MatchURL(); got != "https://www.themoviedb.org/tv/60622" {
|
||||||
|
t.Errorf("MatchURL = %q, want канонический URL по типу плана", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMatchURLPrefersMatchingCandidate — URL берётся у выбранного кандидата,
|
||||||
|
// когда его provider+id совпадают с эффективными.
|
||||||
|
func TestMatchURLPrefersMatchingCandidate(t *testing.T) {
|
||||||
|
rd := &ReviewData{
|
||||||
|
Provider: "tmdb", ProviderID: "693134",
|
||||||
|
Candidates: []store.MetadataCandidate{{
|
||||||
|
Provider: "tmdb", ProviderID: "693134", Chosen: true,
|
||||||
|
URL: store.NullString("https://custom.example/x"),
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
if got := rd.MatchURL(); got != "https://custom.example/x" {
|
||||||
|
t.Errorf("MatchURL = %q, want URL выбранного кандидата", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -957,6 +957,49 @@ type ReviewData struct {
|
|||||||
Overrides map[string]string
|
Overrides map[string]string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MatchURL — ссылка на подтверждённую запись метабазы для этой загрузки. Общий
|
||||||
|
// билдер для всех транспортов (веб и Telegram строят ссылку одинаково). Приоритет
|
||||||
|
// — URL выбранного кандидата, но только если его provider+id совпадают с
|
||||||
|
// эффективными (человек мог выбрать кандидата, затем вручную переопределить id —
|
||||||
|
// тогда кандидат указывает на другую запись). Иначе строим канонический URL по
|
||||||
|
// эффективным provider/id и типу медиа плана; если не удаётся — пусто (транспорт
|
||||||
|
// покажет матч текстом).
|
||||||
|
func (rd *ReviewData) MatchURL() string {
|
||||||
|
for _, c := range rd.Candidates {
|
||||||
|
if c.Chosen && c.Provider == rd.Provider && c.ProviderID == rd.ProviderID &&
|
||||||
|
c.URL.Valid && c.URL.String != "" {
|
||||||
|
return c.URL.String
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ProviderURL(rd.Provider, rd.ProviderID, string(rd.Plan.Type))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProviderURL строит канонический URL записи метабазы с учётом типа медиа.
|
||||||
|
// Пустой id или неизвестный провайдер → пусто.
|
||||||
|
func ProviderURL(provider, id, mediaType string) string {
|
||||||
|
if id == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
switch provider {
|
||||||
|
case "tmdb":
|
||||||
|
kind := "movie"
|
||||||
|
if mediaType == "series" {
|
||||||
|
kind = "tv"
|
||||||
|
}
|
||||||
|
return "https://www.themoviedb.org/" + kind + "/" + id
|
||||||
|
case "tvdb":
|
||||||
|
kind := "series"
|
||||||
|
if mediaType == "movie" {
|
||||||
|
kind = "movie"
|
||||||
|
}
|
||||||
|
return "https://www.thetvdb.com/dereferrer/" + kind + "/" + id
|
||||||
|
case "imdb":
|
||||||
|
return "https://www.imdb.com/title/" + id
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// SourceKind — вид источника в едином списке ревью.
|
// SourceKind — вид источника в едином списке ревью.
|
||||||
type SourceKind string
|
type SourceKind string
|
||||||
|
|
||||||
|
|||||||
@@ -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` — зелёные.
|
||||||
@@ -80,10 +80,13 @@ Telegram / бейдж в вебе) — пользователя зовут, а
|
|||||||
При включённом форматировании исходящих сообщений (parse mode) система MUST
|
При включённом форматировании исходящих сообщений (parse mode) система MUST
|
||||||
экранировать все внешние/недоверенные фрагменты перед вставкой в размеченное
|
экранировать все внешние/недоверенные фрагменты перед вставкой в размеченное
|
||||||
сообщение: display name, распознанное название, источник/контекст, целевой путь,
|
сообщение: display name, распознанное название, источник/контекст, целевой путь,
|
||||||
причины распознавания, provider, код и текст ошибки. Это защищает от того, что
|
причины распознавания, provider, id матча, ссылку на запись метабазы (URL), код и
|
||||||
спецсимволы разметки сломают сообщение или что разметка будет инъектирована из
|
текст ошибки. Экранирование MUST учитывать контекст вставки: для значения в
|
||||||
недоверенного источника (инвариант «выход LLM недоверенный»). Секреты
|
атрибуте (URL в `href`) — в том числе кавычку, чтобы недоверенное значение не
|
||||||
(токены/ключи/пароли) MUST NOT попадать в текст уведомлений и логи.
|
разорвало атрибут. Это защищает от того, что спецсимволы разметки сломают
|
||||||
|
сообщение или что разметка будет инъектирована из недоверенного источника
|
||||||
|
(инвариант «выход LLM недоверенный»). Секреты (токены/ключи/пароли) MUST NOT
|
||||||
|
попадать в текст уведомлений и логи.
|
||||||
|
|
||||||
#### Scenario: Спецсимволы в названии не ломают разметку
|
#### Scenario: Спецсимволы в названии не ломают разметку
|
||||||
|
|
||||||
@@ -99,3 +102,68 @@ Telegram / бейдж в вебе) — пользователя зовут, а
|
|||||||
- **WHEN** бот рендерит форматированное сообщение
|
- **WHEN** бот рендерит форматированное сообщение
|
||||||
- **THEN** символы разметки в пути и причинах экранируются перед вставкой
|
- **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 (как на веб-странице загрузки),
|
||||||
|
а не значения сырого распознавания
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user