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 в моноширинный — в клиентах Telegram по // нему работает tap-to-copy (скопировать id для /download/{id} или диагностики). // Визуальный префикс (`#` / `download_id=`) держим ВНЕ code, чтобы копировался // чистый id. func idCode(id string) string { return "" + esc(id) + "" } // 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 кандидата // валидируется как 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 оборачивается в (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(`%s ↗`, 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]) + "…" }