Когда для распознавания сохранены кандидаты метабазы, карточка подтверждения бота показывает кнопку «🗂 База (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>
415 lines
18 KiB
Go
415 lines
18 KiB
Go
package tgbot
|
||
|
||
import (
|
||
"fmt"
|
||
"path/filepath"
|
||
"strings"
|
||
|
||
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
|
||
|
||
"git.vakhrushev.me/av/jellybit/internal/store"
|
||
"git.vakhrushev.me/av/jellybit/internal/worker"
|
||
)
|
||
|
||
// esc экранирует внешний/недоверенный текст для HTML parse mode Telegram (его
|
||
// включает send(); см. bot.go). Применяем к КАЖДОМУ фрагменту, пришедшему извне
|
||
// (display name, названия, пути, причины, provider, текст/код ошибки, источник):
|
||
// иначе `<`/`>`/`&` в них сломают разметку или инъектируют её (инвариант «выход
|
||
// LLM недоверенный»). ВАЖНО: экранируем ПОСЛЕДНИМ шагом — над уже усечённым
|
||
// текстом (после shorten/tailPath/firstLine), чтобы обрез не разрубил сущность
|
||
// `<` на битую разметку.
|
||
func esc(s string) string { return tgbotapi.EscapeText(tgbotapi.ModeHTML, s) }
|
||
|
||
// escHref экранирует URL для вставки в значение атрибута `href`. Контекст
|
||
// атрибута строже текстового: esc (EscapeText) закрывает `<`/`>`/`&`, но НЕ
|
||
// трогает `"`, а недоверенный id уходит в URL сырым (см. worker.ProviderURL) —
|
||
// кавычка в id разорвала бы атрибут и Telegram отклонил бы сообщение (parse
|
||
// error → уведомление не доставится). Поэтому поверх esc заменяем `"` на
|
||
// `"`; порядок безопасен: esc уже перевёл `&` в `&`, повторно `&` в
|
||
// `"` не удвоится.
|
||
func escHref(url string) string { return strings.ReplaceAll(esc(url), `"`, """) }
|
||
|
||
// idCode оборачивает download id в моноширинный <code> — в клиентах Telegram по
|
||
// нему работает tap-to-copy (скопировать id для /download/{id} или диагностики).
|
||
// Визуальный префикс (`#` / `download_id=`) держим ВНЕ code, чтобы копировался
|
||
// чистый id.
|
||
func idCode(id string) string { return "<code>" + esc(id) + "</code>" }
|
||
|
||
// renderCard строит текст и клавиатуру карточки по состоянию задачи.
|
||
func (b *Bot) renderCard(rd *worker.ReviewData) (string, *tgbotapi.InlineKeyboardMarkup) {
|
||
id := rd.Download.ID
|
||
state := rd.Download.State
|
||
|
||
switch state {
|
||
case store.StateReview, store.StateDeferred:
|
||
return b.reviewCard(rd)
|
||
case store.StateRecognizing:
|
||
return "⏳ Распознаю #" + idCode(id) + "…", b.webOnly(id)
|
||
case store.StateLinking:
|
||
return "⏳ Раскладываю #" + idCode(id) + "…", nil
|
||
case store.StateDone:
|
||
return b.renderDone(rd), b.deletableKeyboard(id)
|
||
default:
|
||
// state — внутренний enum состояния (не внешний ввод), экранировать не нужно.
|
||
text := fmt.Sprintf("Задача #%s — %s.", idCode(id), state)
|
||
if msg := rd.Download.ErrorMsg.String; msg != "" {
|
||
text += "\n" + esc(msg)
|
||
}
|
||
switch state {
|
||
case store.StateFailed, store.StateStuck:
|
||
// failed/stuck — даём кнопку повтора.
|
||
return text, b.retryKeyboard(id)
|
||
case store.StateOrphaned, store.StateTargetMissing:
|
||
// Рассинхрон — можно подчистить остатки полным удалением.
|
||
return text, b.deletableKeyboard(id)
|
||
}
|
||
return text, b.webOnly(id)
|
||
}
|
||
}
|
||
|
||
func (b *Bot) reviewCard(rd *worker.ReviewData) (string, *tgbotapi.InlineKeyboardMarkup) {
|
||
id := rd.Download.ID
|
||
var sb strings.Builder
|
||
|
||
fmt.Fprintf(&sb, "🟡 Нужно подтверждение #%s\n", idCode(id))
|
||
if src := contextOrSource(rd); src != "" {
|
||
fmt.Fprintf(&sb, "Источник: %s\n", esc(shorten(src, 80)))
|
||
}
|
||
// guessLine/baseLine возвращают уже экранированный текст (внешние title/provider
|
||
// внутри) — повторно не экранируем.
|
||
fmt.Fprintf(&sb, "Похоже на: %s\n", guessLine(rd))
|
||
// В ревью показываем «нет матча» явно — полезно видеть, что база не выбрана.
|
||
if base := baseLine(rd); base != "" {
|
||
fmt.Fprintf(&sb, "База: %s\n", base)
|
||
} else {
|
||
sb.WriteString("База: нет матча\n")
|
||
}
|
||
if reasons := rd.Recognition.ReasonList(); len(reasons) > 0 {
|
||
fmt.Fprintf(&sb, "Причины: %s\n", esc(strings.Join(reasons, " · ")))
|
||
}
|
||
if n := len(rd.Preview); n > 0 {
|
||
fmt.Fprintf(&sb, "План: %d файлов → %s", n, esc(tailPath(rd.Preview[0].Dst)))
|
||
}
|
||
|
||
return strings.TrimRight(sb.String(), "\n"), b.reviewKeyboard(rd)
|
||
}
|
||
|
||
func (b *Bot) reviewKeyboard(rd *worker.ReviewData) *tgbotapi.InlineKeyboardMarkup {
|
||
id := rd.Download.ID
|
||
sid := id
|
||
|
||
var row1 []tgbotapi.InlineKeyboardButton
|
||
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(
|
||
tgbotapi.NewInlineKeyboardButtonData("🔁 Уточнить", "refine:"+sid),
|
||
tgbotapi.NewInlineKeyboardButtonData("🕗 Позже", "defer:"+sid),
|
||
)
|
||
|
||
var row3 []tgbotapi.InlineKeyboardButton
|
||
if url := b.reviewURL(id); url != "" {
|
||
row3 = append(row3, tgbotapi.NewInlineKeyboardButtonURL("🌐 В вебе", url))
|
||
}
|
||
row3 = append(row3, tgbotapi.NewInlineKeyboardButtonData("❌ Отклонить", "reject:"+sid))
|
||
|
||
kb := tgbotapi.NewInlineKeyboardMarkup(row1, row2, row3)
|
||
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.
|
||
func displayTitle(rd *worker.ReviewData) string {
|
||
if t := strings.TrimSpace(rd.Download.DisplayName); t != "" {
|
||
return t
|
||
}
|
||
return rd.Plan.Title
|
||
}
|
||
|
||
// titleLabel — экранированная метка задачи для уведомлений: «display name» (в
|
||
// кавычках) либо моноширинный #id как фолбек. Готова к вставке в HTML-сообщение:
|
||
// внешний title экранируется, id оборачивается в <code> (tap-to-copy).
|
||
func titleLabel(rd *worker.ReviewData) string {
|
||
if t := displayTitle(rd); t != "" {
|
||
return "«" + esc(t) + "»"
|
||
}
|
||
return "#" + idCode(rd.Download.ID)
|
||
}
|
||
|
||
// renderDone — короткое сообщение о готовности. Дополняем строкой матча (база +
|
||
// ссылка), чтобы ошибочную привязку было видно и в финальном пинге; без матча
|
||
// строку опускаем — в готовности «нет матча» лишний шум.
|
||
func (b *Bot) renderDone(rd *worker.ReviewData) string {
|
||
label := titleLabel(rd)
|
||
var sb strings.Builder
|
||
n := len(rd.Preview)
|
||
if n == 0 {
|
||
fmt.Fprintf(&sb, "✅ Готово: %s разложен.", label)
|
||
} else {
|
||
fmt.Fprintf(&sb, "✅ Готово: %s — разложено файлов: %d.", label, n)
|
||
}
|
||
if base := baseLine(rd); base != "" {
|
||
fmt.Fprintf(&sb, "\nБаза: %s", base)
|
||
}
|
||
return sb.String()
|
||
}
|
||
|
||
// renderDesync — уведомление о рассинхроне (источник/цель удалены вручную).
|
||
func (b *Bot) renderDesync(rd *worker.ReviewData, event worker.NotifyEvent) string {
|
||
label := titleLabel(rd)
|
||
switch event {
|
||
case worker.EventTargetMissing:
|
||
return fmt.Sprintf("⚠️ %s: файлы удалены из библиотеки, источник на месте — можно привязать заново.", label)
|
||
case worker.EventOrphaned:
|
||
return fmt.Sprintf("⚠️ %s: источник удалён из qBittorrent, библиотечная копия осталась последней (откат недоступен).", label)
|
||
default:
|
||
return fmt.Sprintf("⚠️ %s: рассинхрон состояния.", label)
|
||
}
|
||
}
|
||
|
||
// renderFailed — уведомление об упавшей/зависшей задаче с кнопкой повтора.
|
||
func (b *Bot) renderFailed(rd *worker.ReviewData) (string, *tgbotapi.InlineKeyboardMarkup) {
|
||
id := rd.Download.ID
|
||
var sb strings.Builder
|
||
verb := "не удалась"
|
||
if rd.Download.State == store.StateStuck {
|
||
verb = "зависла"
|
||
}
|
||
// Заголовок (display_name) для читаемости + #id для поиска по логам.
|
||
if title := displayTitle(rd); title != "" {
|
||
fmt.Fprintf(&sb, "❌ «%s» — задача #%s %s", esc(title), idCode(id), verb)
|
||
} else {
|
||
fmt.Fprintf(&sb, "❌ Задача #%s %s", idCode(id), verb)
|
||
}
|
||
if code := rd.Download.ErrorCode.String; code != "" {
|
||
fmt.Fprintf(&sb, " (%s)", esc(code))
|
||
}
|
||
sb.WriteString(".")
|
||
if msg := rd.Download.ErrorMsg.String; msg != "" {
|
||
sb.WriteString("\n")
|
||
sb.WriteString(esc(msg))
|
||
}
|
||
if src := contextOrSource(rd); src != "" {
|
||
fmt.Fprintf(&sb, "\nИсточник: %s", esc(shorten(src, 80)))
|
||
}
|
||
return sb.String(), b.retryKeyboard(id)
|
||
}
|
||
|
||
// retryKeyboard — клавиатура для failed/stuck: повтор + опц. ссылка в веб.
|
||
func (b *Bot) retryKeyboard(id string) *tgbotapi.InlineKeyboardMarkup {
|
||
row := []tgbotapi.InlineKeyboardButton{
|
||
tgbotapi.NewInlineKeyboardButtonData("🔄 Повторить", "retry:"+id),
|
||
// Стоп-кран: закрыть зависшую задачу, не трогая файлы/раздачу.
|
||
tgbotapi.NewInlineKeyboardButtonData("✖️ Закрыть", "dismiss:"+id),
|
||
}
|
||
if url := b.reviewURL(id); url != "" {
|
||
row = append(row, tgbotapi.NewInlineKeyboardButtonURL("🌐 В вебе", url))
|
||
}
|
||
kb := tgbotapi.NewInlineKeyboardMarkup(tgbotapi.NewInlineKeyboardRow(row...))
|
||
return &kb
|
||
}
|
||
|
||
// deletableKeyboard — клавиатура состояний, откуда доступно полное удаление
|
||
// (done/orphaned/target_missing): ссылка в веб (опц.) + «Закрыть» (стоп-кран, лишь
|
||
// статус) + «Удалить» (снос раздачи+файлов). Обе команды двухшаговые — кнопка
|
||
// ведёт на подтверждение.
|
||
func (b *Bot) deletableKeyboard(id string) *tgbotapi.InlineKeyboardMarkup {
|
||
var row []tgbotapi.InlineKeyboardButton
|
||
if url := b.reviewURL(id); url != "" {
|
||
row = append(row, tgbotapi.NewInlineKeyboardButtonURL("🌐 В вебе", url))
|
||
}
|
||
row = append(row,
|
||
tgbotapi.NewInlineKeyboardButtonData("✖️ Закрыть", "dismiss:"+id),
|
||
tgbotapi.NewInlineKeyboardButtonData("🗑 Удалить", "delete:"+id),
|
||
)
|
||
kb := tgbotapi.NewInlineKeyboardMarkup(tgbotapi.NewInlineKeyboardRow(row...))
|
||
return &kb
|
||
}
|
||
|
||
// deleteConfirmKeyboard — шаг подтверждения удаления: необратимое снятие
|
||
// раздачи+файлов и хардлинков. Явное «Да» отделено от отмены.
|
||
func (b *Bot) deleteConfirmKeyboard(id string) *tgbotapi.InlineKeyboardMarkup {
|
||
kb := tgbotapi.NewInlineKeyboardMarkup(tgbotapi.NewInlineKeyboardRow(
|
||
tgbotapi.NewInlineKeyboardButtonData("❗ Да, удалить", "delete_confirm:"+id),
|
||
tgbotapi.NewInlineKeyboardButtonData("Отмена", "delete_cancel:"+id),
|
||
))
|
||
return &kb
|
||
}
|
||
|
||
// dismissConfirmKeyboard — шаг подтверждения закрытия (стоп-кран): перевод в
|
||
// «отменено» без действий над файлами/раздачей. Явное «Да» отделено от отмены.
|
||
func (b *Bot) dismissConfirmKeyboard(id string) *tgbotapi.InlineKeyboardMarkup {
|
||
kb := tgbotapi.NewInlineKeyboardMarkup(tgbotapi.NewInlineKeyboardRow(
|
||
tgbotapi.NewInlineKeyboardButtonData("✖️ Да, закрыть", "dismiss_confirm:"+id),
|
||
tgbotapi.NewInlineKeyboardButtonData("Отмена", "dismiss_cancel:"+id),
|
||
))
|
||
return &kb
|
||
}
|
||
|
||
func (b *Bot) webOnly(id string) *tgbotapi.InlineKeyboardMarkup {
|
||
url := b.reviewURL(id)
|
||
if url == "" {
|
||
return nil
|
||
}
|
||
kb := tgbotapi.NewInlineKeyboardMarkup(tgbotapi.NewInlineKeyboardRow(
|
||
tgbotapi.NewInlineKeyboardButtonURL("🌐 Открыть в вебе", url),
|
||
))
|
||
return &kb
|
||
}
|
||
|
||
func (b *Bot) reviewURL(id string) string {
|
||
if b.webBase == "" {
|
||
return ""
|
||
}
|
||
return b.webBase + "/review/" + id
|
||
}
|
||
|
||
// --- мелкие хелперы ---
|
||
|
||
func guessLine(rd *worker.ReviewData) string {
|
||
emoji, kind := "🎬", "фильм"
|
||
if rd.Plan.Type == "series" {
|
||
emoji, kind = "📺", "сериал"
|
||
}
|
||
title := rd.Plan.Title
|
||
if title == "" {
|
||
title = "не распознано"
|
||
}
|
||
// Возвращаем уже экранированный текст (title — внешний/распознанный):
|
||
// вызывающий вставляет как есть, без повторного esc.
|
||
s := fmt.Sprintf("%s %s «%s»", emoji, kind, esc(title))
|
||
if rd.Plan.Year != 0 {
|
||
s += fmt.Sprintf(" (%d)", rd.Plan.Year)
|
||
}
|
||
return s
|
||
}
|
||
|
||
// baseLine — запись матча метабазы для уведомлений: provider и id (эффективные,
|
||
// с учётом ручных правок — как на веб-странице загрузки), при возможности
|
||
// построить URL — ссылкой на страницу записи (тот же билдер, что и веб:
|
||
// worker.ReviewData.MatchURL). Возвращает уже экранированный HTML (provider/id —
|
||
// внешние, URL — в контексте href): вызывающий вставляет как есть. Пусто, если
|
||
// матча нет (провайдер пуст/none) — поверхность сама решает, показывать ли
|
||
// индикатор «нет матча».
|
||
func baseLine(rd *worker.ReviewData) string {
|
||
prov := rd.Provider
|
||
if prov == "" || prov == "none" {
|
||
return ""
|
||
}
|
||
label := esc(prov)
|
||
if rd.ProviderID != "" {
|
||
label += " " + esc(rd.ProviderID)
|
||
}
|
||
if url := rd.MatchURL(); url != "" {
|
||
return fmt.Sprintf(`<a href="%s">%s ↗</a>`, escHref(url), label)
|
||
}
|
||
return label
|
||
}
|
||
|
||
func contextOrSource(rd *worker.ReviewData) string {
|
||
if c := strings.TrimSpace(rd.Download.Context); c != "" {
|
||
return firstLine(c)
|
||
}
|
||
return rd.Download.SourceRef
|
||
}
|
||
|
||
func oppositeType(t string) string {
|
||
if t == "series" {
|
||
return "movie"
|
||
}
|
||
return "series"
|
||
}
|
||
|
||
func firstLine(s string) string {
|
||
if i := strings.IndexByte(s, '\n'); i >= 0 {
|
||
return s[:i]
|
||
}
|
||
return s
|
||
}
|
||
|
||
func tailPath(p string) string {
|
||
dir, file := filepath.Split(p)
|
||
parent := filepath.Base(strings.TrimRight(dir, "/"))
|
||
if parent == "." || parent == "/" || parent == "" {
|
||
return file
|
||
}
|
||
return parent + "/" + file
|
||
}
|
||
|
||
func shorten(s string, n int) string {
|
||
r := []rune(s)
|
||
if len(r) <= n {
|
||
return s
|
||
}
|
||
return string(r[:n]) + "…"
|
||
}
|