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
+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.