Files
jellybit/internal/tgbot/render.go
T
avandClaude Opus 4.8 f75d6b1f91 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>
2026-07-18 16:02:47 +03:00

415 lines
18 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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), чтобы обрез не разрубил сущность
// `&lt;` на битую разметку.
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 заменяем `"` на
// `&quot;`; порядок безопасен: esc уже перевёл `&` в `&amp;`, повторно `&` в
// `&quot;` не удвоится.
func escHref(url string) string { return strings.ReplaceAll(esc(url), `"`, "&quot;") }
// 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]) + "…"
}