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) } // 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.Recognition); base != "" { fmt.Fprintf(&sb, "База: %s\n", base) } 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)) } 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 } // 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) n := len(rd.Preview) if n == 0 { return fmt.Sprintf("✅ Готово: %s разложен.", label) } return fmt.Sprintf("✅ Готово: %s — разложено файлов: %d.", label, n) } // 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/provider_id — внешние, // из метабазы/LLM): вызывающий вставляет как есть, без повторного esc. func baseLine(rec *store.Recognition) string { if rec == nil || !rec.Provider.Valid || rec.Provider.String == "" || rec.Provider.String == "none" { return "нет матча" } if rec.ProviderID.Valid && rec.ProviderID.String != "" { return esc(rec.Provider.String) + " " + esc(rec.ProviderID.String) } return esc(rec.Provider.String) } 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]) + "…" }