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:
@@ -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