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:
@@ -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})
|
||||
|
||||
Reference in New Issue
Block a user