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
+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})