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