tgbot: выбор кандидата метабазы inline-кнопками в карточке ревью

Когда для распознавания сохранены кандидаты метабазы, карточка подтверждения
бота показывает кнопку «🗂 База (N)». По ней двухшагово (как delete/dismiss)
разворачивается список кандидатов inline-кнопками; выбор пиннит источник через
worker.ChooseCandidate (ручной матч, без авто-раскладки) и обновляет карточку.
Веб остаётся точкой точных правок (ручной ввод id/URL, «без базы»).

Безопасность границы: id кандидата из callback_data валидируется как ULID
(ident.Parse) до доменного вызова, как в вебе. Текст inline-кнопок Telegram не
парсится как HTML — название кандидата в подписи не экранируется.

SDD: change telegram-vybor-nahodok — дельта notifications (ADDED «Выбор
кандидата метабазы из карточки подтверждения бота») + review (MODIFIED
«Разделение труда транспортов»: быстрый выбор кандидата — Telegram-действие).
Влито в specs, change заархивирован. Миграций БД нет (кандидаты уже в БД).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
av
2026-07-18 16:02:47 +03:00
co-authored by Claude Opus 4.8
parent 10a6348d39
commit f75d6b1f91
12 changed files with 541 additions and 12 deletions
+28
View File
@@ -43,6 +43,7 @@ type Reviewer interface {
ReviewData(ctx context.Context, id string) (*worker.ReviewData, error)
Apply(ctx context.Context, id string) error
Refine(ctx context.Context, id string, hint string) error
ChooseCandidate(ctx context.Context, id, candidateID string) error
SetType(ctx context.Context, id string, mediaType string) error
Defer(ctx context.Context, id string) error
Cancel(ctx context.Context, id string) error
@@ -362,6 +363,33 @@ func (b *Bot) handleCallback(ctx context.Context, cq *tgbotapi.CallbackQuery) {
b.answer(cq.ID, "Жду подсказку")
b.send(chatID, "Ответьте сообщением с подсказкой для #"+idCode(id)+".", nil)
return
case "sources":
// Первый шаг выбора базы: перечитываем задачу ради свежего списка
// кандидатов и разворачиваем клавиатуру списка. Домен не трогаем.
rd, rerr := b.reviewer.ReviewData(ctx, id)
if rerr != nil {
b.log.Warn("telegram sources review data failed", "download_id", id, "error", rerr)
b.answer(cq.ID, "Ошибка")
return
}
b.answer(cq.ID, "")
b.editMarkup(chatID, msgID, b.candidatesKeyboard(rd))
return
case "srcback":
// Возврат из списка кандидатов к обычной карточке (домен не трогаем).
b.answer(cq.ID, "Отменено")
b.refreshCard(ctx, chatID, msgID, id)
return
case "pick":
// Выбор кандидата: val — id кандидата из callback_data (недоверенный
// вход), валидируем как ULID на границе до доменного вызова.
candidateID, perr := ident.Parse(val)
if perr != nil {
b.answer(cq.ID, "Кнопка устарела — откройте задачу в вебе")
return
}
err = b.reviewer.ChooseCandidate(ctx, id, candidateID)
note = "Источник выбран"
default:
b.answer(cq.ID, "")
return
+129
View File
@@ -2,6 +2,7 @@ package tgbot
import (
"context"
"database/sql"
"io"
"log/slog"
"strings"
@@ -72,6 +73,7 @@ type fakeReviewer struct {
retried []string
deleted []string
dismissed []string
chosen map[string]string // downloadID → выбранный candidateID
}
func (f *fakeReviewer) ReviewData(context.Context, string) (*worker.ReviewData, error) {
@@ -88,6 +90,13 @@ func (f *fakeReviewer) Refine(_ context.Context, id string, hint string) error {
f.refined[id] = hint
return nil
}
func (f *fakeReviewer) ChooseCandidate(_ context.Context, id, candidateID string) error {
if f.chosen == nil {
f.chosen = map[string]string{}
}
f.chosen[id] = candidateID
return nil
}
func (f *fakeReviewer) SetType(_ context.Context, id string, t string) error {
if f.typed == nil {
f.typed = map[string]string{}
@@ -429,6 +438,126 @@ func TestParseCallback(t *testing.T) {
}
}
// candID1/candID2 — валидные lowercase-ULID id кандидатов (callback-data
// валидируется как ULID).
const (
candID1 = "01arz3ndektsv4rrffq69g5fb1"
candID2 = "01arz3ndektsv4rrffq69g5fb2"
)
// findButton ищет inline-кнопку по callback data во всей клавиатуре.
func findButton(kb *tgbotapi.InlineKeyboardMarkup, data string) (tgbotapi.InlineKeyboardButton, bool) {
for _, row := range kb.InlineKeyboard {
for _, btn := range row {
if btn.CallbackData != nil && *btn.CallbackData == data {
return btn, true
}
}
}
return tgbotapi.InlineKeyboardButton{}, false
}
// withCandidates возвращает ReviewData с двумя кандидатами (второй — выбран).
func withCandidates() *worker.ReviewData {
rd := reviewData(store.StateReview)
rd.Candidates = []store.MetadataCandidate{
{ID: candID1, Provider: "tvdb", ProviderID: "269613", Title: sql.NullString{String: "Fargo", Valid: true}, Year: sql.NullInt64{Int64: 2014, Valid: true}},
{ID: candID2, Provider: "tmdb", ProviderID: "60622", Title: sql.NullString{String: "Фарго", Valid: true}, Year: sql.NullInt64{Int64: 2015, Valid: true}, Chosen: true},
}
return rd
}
// Карточка ревью с кандидатами показывает кнопку выбора базы; без кандидатов — нет.
func TestBot_ReviewKeyboardBaseButton(t *testing.T) {
b, _, _, _ := newTestBot(t, []int64{7})
kb := b.reviewKeyboard(withCandidates())
if _, ok := findButton(kb, "sources:"+tid); !ok {
t.Error("карточка с кандидатами должна иметь кнопку выбора базы")
}
if kbNone := b.reviewKeyboard(reviewData(store.StateReview)); func() bool {
_, ok := findButton(kbNone, "sources:"+tid)
return ok
}() {
t.Error("без кандидатов кнопки выбора базы быть не должно")
}
}
// Список кандидатов: по кнопке на кандидата (pick:<id>:<candID>) + «Назад»;
// выбранный помечен галочкой, дубли по provider:id схлопнуты.
func TestBot_CandidatesKeyboard(t *testing.T) {
b, _, _, _ := newTestBot(t, []int64{7})
rd := withCandidates()
// Дубль второго кандидата (тот же provider:id) — должен схлопнуться.
rd.Candidates = append(rd.Candidates, store.MetadataCandidate{ID: "01arz3ndektsv4rrffq69g5fb3", Provider: "tmdb", ProviderID: "60622"})
kb := b.candidatesKeyboard(rd)
if _, ok := findButton(kb, "pick:"+tid+":"+candID1); !ok {
t.Error("нет кнопки первого кандидата")
}
chosen, ok := findButton(kb, "pick:"+tid+":"+candID2)
if !ok {
t.Fatal("нет кнопки выбранного кандидата")
}
if !strings.HasPrefix(chosen.Text, "✓ ") {
t.Errorf("выбранный кандидат должен быть помечен ✓, got %q", chosen.Text)
}
if _, ok := findButton(kb, "srcback:"+tid); !ok {
t.Error("нет кнопки возврата")
}
// Дубль не породил третью кнопку выбора.
if _, ok := findButton(kb, "pick:"+tid+":01arz3ndektsv4rrffq69g5fb3"); ok {
t.Error("дубль provider:id должен быть схлопнут")
}
}
// Выбор кандидата: pick с валидным id зовёт ChooseCandidate и обновляет карточку.
func TestBot_CallbackPick(t *testing.T) {
b, api, _, rev := newTestBot(t, []int64{7})
rev.data = withCandidates()
b.handleCallback(context.Background(), cbFrom(7, "pick:"+tid+":"+candID1))
if rev.chosen[tid] != candID1 {
t.Errorf("ChooseCandidate получил %q, want %q", rev.chosen[tid], candID1)
}
if len(api.edits) != 1 { // карточка обновлена на месте (refreshCard)
t.Errorf("edits = %v, want 1", api.edits)
}
}
// Невалидный id кандидата из callback отклоняется на границе — домен не зовём.
func TestBot_CallbackPickInvalidID(t *testing.T) {
b, api, _, rev := newTestBot(t, []int64{7})
b.handleCallback(context.Background(), cbFrom(7, "pick:"+tid+":not-a-ulid"))
if len(rev.chosen) != 0 {
t.Errorf("невалидный id не должен доходить до домена, chosen = %v", rev.chosen)
}
if len(api.answers) != 1 || !strings.Contains(api.answers[0], "устарела") {
t.Errorf("answers = %v, want понятный ответ", api.answers)
}
}
// «Назад» из списка кандидатов возвращает карточку, домен не трогает.
func TestBot_CallbackSourcesBack(t *testing.T) {
b, api, _, rev := newTestBot(t, []int64{7})
rev.data = withCandidates()
b.handleCallback(context.Background(), cbFrom(7, "sources:"+tid))
if len(rev.chosen) != 0 {
t.Error("разворачивание списка не должно трогать домен")
}
b.handleCallback(context.Background(), cbFrom(7, "srcback:"+tid))
if len(rev.chosen) != 0 {
t.Error("возврат не должен трогать домен")
}
if len(api.edits) != 1 { // srcback обновляет карточку на месте
t.Errorf("edits = %v, want 1 (srcback refreshCard)", api.edits)
}
}
// Нажатие устаревшей кнопки со старым числовым id получает понятный ответ.
func TestBot_CallbackStaleButton(t *testing.T) {
b, api, _, rev := newTestBot(t, []int64{7})
+66
View File
@@ -102,6 +102,10 @@ func (b *Bot) reviewKeyboard(rd *worker.ReviewData) *tgbotapi.InlineKeyboardMark
if len(rd.Preview) > 0 {
row1 = append(row1, tgbotapi.NewInlineKeyboardButtonData("✅ Применить", "apply:"+sid))
}
// Быстрый выбор базы из готового списка кандидатов (веб — точный ввод id/URL).
if n := len(dedupCandidates(rd.Candidates)); n > 0 {
row1 = append(row1, tgbotapi.NewInlineKeyboardButtonData(fmt.Sprintf("🗂 База (%d)", n), "sources:"+sid))
}
row1 = append(row1, tgbotapi.NewInlineKeyboardButtonData("📺↔🎬 Тип", "type:"+sid+":"+oppositeType(string(rd.Plan.Type))))
row2 := tgbotapi.NewInlineKeyboardRow(
@@ -119,6 +123,68 @@ func (b *Bot) reviewKeyboard(rd *worker.ReviewData) *tgbotapi.InlineKeyboardMark
return &kb
}
// candidatesKeyboard — второй шаг выбора базы: список кандидатов метабазы (по
// кнопке на кандидата) + возврат к карточке. Текст кнопки Telegram не парсится как
// HTML, поэтому название кандидата в подписи не экранируем (инъекция разметки в
// кнопке невозможна); длинную подпись усекаем — косметика. Выбранный (chosen)
// помечаем галочкой. callback выбора — `pick:<id>:<candidateID>` (id кандидата
// валидируется как ULID на границе в handleCallback).
func (b *Bot) candidatesKeyboard(rd *worker.ReviewData) *tgbotapi.InlineKeyboardMarkup {
sid := rd.Download.ID
var rows [][]tgbotapi.InlineKeyboardButton
for _, c := range dedupCandidates(rd.Candidates) {
rows = append(rows, tgbotapi.NewInlineKeyboardRow(
tgbotapi.NewInlineKeyboardButtonData(candidateLabel(c), "pick:"+sid+":"+c.ID),
))
}
rows = append(rows, tgbotapi.NewInlineKeyboardRow(
tgbotapi.NewInlineKeyboardButtonData("← Назад", "srcback:"+sid),
))
kb := tgbotapi.NewInlineKeyboardMarkup(rows...)
return &kb
}
// dedupCandidates отсеивает повторы по provider:provider_id (стабильный порядок
// исходного списка) — как единый список источников в вебе. Потолок — сами
// кандидаты (recognize.maxCandidates=8), отдельно не режем. Если среди дублей
// одного ключа выбранный (Chosen) идёт не первым, он вытесняет ранее оставленный
// невыбранный — чтобы галочка и id кнопки указывали на выбранного.
func dedupCandidates(cands []store.MetadataCandidate) []store.MetadataCandidate {
idx := map[string]int{}
out := make([]store.MetadataCandidate, 0, len(cands))
for _, c := range cands {
key := c.Provider + ":" + c.ProviderID
if i, ok := idx[key]; ok {
if c.Chosen && !out[i].Chosen {
out[i] = c
}
continue
}
idx[key] = len(out)
out = append(out, c)
}
return out
}
// candidateLabel — подпись кнопки кандидата: `provider · Название (год)`, у
// выбранного — префикс «✓ ». Обычный текст (не HTML), усекаем для читаемости.
func candidateLabel(c store.MetadataCandidate) string {
label := c.Provider
if c.Title.Valid && c.Title.String != "" {
label += " · " + c.Title.String
} else if c.ProviderID != "" {
label += " " + c.ProviderID
}
if c.Year.Valid && c.Year.Int64 != 0 {
label += fmt.Sprintf(" (%d)", c.Year.Int64)
}
label = shorten(label, 48)
if c.Chosen {
label = "✓ " + label
}
return label
}
// displayTitle — имя раздачи для уведомлений: имя раздачи (display_name, то,
// что ушло в qBittorrent и показывается в веб-UI) → распознанное название.
// Пусто, если ни того ни другого нет. Держим заголовки консистентными с UI.