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:
av
2026-07-18 15:41:28 +03:00
co-authored by Claude Opus 4.8
parent 4a58b0bda0
commit 10a6348d39
15 changed files with 614 additions and 123 deletions
+3
View File
@@ -127,6 +127,9 @@ func reviewData(state store.State) *worker.ReviewData {
Provider: store.NullString("tvdb"), ProviderID: store.NullString("269613"),
Reasons: `["неполный пак"]`,
},
// Эффективные provider/id (как заполняет effectiveProvider) — их и
// показывают уведомления/карточка, консистентно с веб-страницей.
Provider: "tvdb", ProviderID: "269613",
Plan: recognize.Plan{
Type: recognize.MediaSeries, Title: "Фарго", Year: 2015,
Files: []recognize.PlanFile{{Src: "e1.mkv", Role: recognize.RoleEpisode, Season: &s, Episode: &e}},
+42 -12
View File
@@ -20,6 +20,15 @@ import (
// `&lt;` на битую разметку.
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 заменяем `"` на
// `&quot;`; порядок безопасен: esc уже перевёл `&` в `&amp;`, повторно `&` в
// `&quot;` не удвоится.
func escHref(url string) string { return strings.ReplaceAll(esc(url), `"`, "&quot;") }
// idCode оборачивает download id в моноширинный <code> — в клиентах Telegram по
// нему работает tap-to-copy (скопировать id для /download/{id} или диагностики).
// Визуальный префикс (`#` / `download_id=`) держим ВНЕ code, чтобы копировался
@@ -69,8 +78,11 @@ func (b *Bot) reviewCard(rd *worker.ReviewData) (string, *tgbotapi.InlineKeyboar
// guessLine/baseLine возвращают уже экранированный текст (внешние title/provider
// внутри) — повторно не экранируем.
fmt.Fprintf(&sb, "Похоже на: %s\n", guessLine(rd))
if base := baseLine(rd.Recognition); base != "" {
// В ревью показываем «нет матча» явно — полезно видеть, что база не выбрана.
if base := baseLine(rd); base != "" {
fmt.Fprintf(&sb, "База: %s\n", base)
} else {
sb.WriteString("База: нет матча\n")
}
if reasons := rd.Recognition.ReasonList(); len(reasons) > 0 {
fmt.Fprintf(&sb, "Причины: %s\n", esc(strings.Join(reasons, " · ")))
@@ -127,14 +139,22 @@ func titleLabel(rd *worker.ReviewData) string {
return "#" + idCode(rd.Download.ID)
}
// renderDone — короткое сообщение о готовности.
// renderDone — короткое сообщение о готовности. Дополняем строкой матча (база +
// ссылка), чтобы ошибочную привязку было видно и в финальном пинге; без матча
// строку опускаем — в готовности «нет матча» лишний шум.
func (b *Bot) renderDone(rd *worker.ReviewData) string {
label := titleLabel(rd)
var sb strings.Builder
n := len(rd.Preview)
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 — уведомление о рассинхроне (источник/цель удалены вручную).
@@ -267,16 +287,26 @@ func guessLine(rd *worker.ReviewData) string {
return s
}
// baseLine возвращает уже экранированный текст (provider/provider_id — внешние,
// из метабазы/LLM): вызывающий вставляет как есть, без повторного esc.
func baseLine(rec *store.Recognition) string {
if rec == nil || !rec.Provider.Valid || rec.Provider.String == "" || rec.Provider.String == "none" {
return "нет матча"
// baseLine — запись матча метабазы для уведомлений: provider и id (эффективные,
// с учётом ручных правок — как на веб-странице загрузки), при возможности
// построить URL — ссылкой на страницу записи (тот же билдер, что и веб:
// worker.ReviewData.MatchURL). Возвращает уже экранированный HTML (provider/id —
// внешние, URL — в контексте href): вызывающий вставляет как есть. Пусто, если
// матча нет (провайдер пуст/none) — поверхность сама решает, показывать ли
// индикатор «нет матча».
func baseLine(rd *worker.ReviewData) string {
prov := rd.Provider
if prov == "" || prov == "none" {
return ""
}
if rec.ProviderID.Valid && rec.ProviderID.String != "" {
return esc(rec.Provider.String) + " " + esc(rec.ProviderID.String)
label := esc(prov)
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 {
+109
View File
@@ -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 (&quot;), атрибут
// остаётся целым — иначе 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, "&quot;") {
t.Errorf("кавычка в href не экранирована:\n%s", text)
}
}