tgbot: download id моноширинным (code) для tap-to-copy
Включён HTML parse mode у всех исходящих сообщений бота (send + edit-путь
refreshCard), download id выводится моноширинным <code> — в клиентах Telegram
по нему работает tap-to-copy (скопировать id для /download/{id} или диагностики).
Префикс # / download_id= остаётся вне <code>, чтобы копировался чистый id.
Parse mode делает разметку значимой для всех текстов, поэтому добавлен
escape-хелпер и экранированы все внешние/недоверенные фрагменты: display name,
распознанное название, источник/контекст, целевой путь, причины, provider,
error_code/error_msg (инвариант «выход LLM недоверенный»). esc применяется
последним шагом, после усечения, чтобы обрез не разрубил HTML-сущность.
Capability notifications: два ADDED-требования (формат id + экранирование).
Беклог: задача закрыта, зонтичный telegram-revyu-uvedomleniy обновлён.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+55
-29
@@ -11,6 +11,21 @@ import (
|
||||
"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 в моноширинный <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
|
||||
@@ -20,15 +35,16 @@ func (b *Bot) renderCard(rd *worker.ReviewData) (string, *tgbotapi.InlineKeyboar
|
||||
case store.StateReview, store.StateDeferred:
|
||||
return b.reviewCard(rd)
|
||||
case store.StateRecognizing:
|
||||
return "⏳ Распознаю #" + id + "…", b.webOnly(id)
|
||||
return "⏳ Распознаю #" + idCode(id) + "…", b.webOnly(id)
|
||||
case store.StateLinking:
|
||||
return "⏳ Раскладываю #" + id + "…", nil
|
||||
return "⏳ Раскладываю #" + idCode(id) + "…", nil
|
||||
case store.StateDone:
|
||||
return b.renderDone(rd), b.deletableKeyboard(id)
|
||||
default:
|
||||
text := fmt.Sprintf("Задача #%s — %s.", id, state)
|
||||
// state — внутренний enum состояния (не внешний ввод), экранировать не нужно.
|
||||
text := fmt.Sprintf("Задача #%s — %s.", idCode(id), state)
|
||||
if msg := rd.Download.ErrorMsg.String; msg != "" {
|
||||
text += "\n" + msg
|
||||
text += "\n" + esc(msg)
|
||||
}
|
||||
switch state {
|
||||
case store.StateFailed, store.StateStuck:
|
||||
@@ -46,19 +62,21 @@ func (b *Bot) reviewCard(rd *worker.ReviewData) (string, *tgbotapi.InlineKeyboar
|
||||
id := rd.Download.ID
|
||||
var sb strings.Builder
|
||||
|
||||
fmt.Fprintf(&sb, "🟡 Нужно подтверждение #%s\n", id)
|
||||
fmt.Fprintf(&sb, "🟡 Нужно подтверждение #%s\n", idCode(id))
|
||||
if src := contextOrSource(rd); src != "" {
|
||||
fmt.Fprintf(&sb, "Источник: %s\n", shorten(src, 80))
|
||||
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", strings.Join(reasons, " · "))
|
||||
fmt.Fprintf(&sb, "Причины: %s\n", esc(strings.Join(reasons, " · ")))
|
||||
}
|
||||
if n := len(rd.Preview); n > 0 {
|
||||
fmt.Fprintf(&sb, "План: %d файлов → %s", n, tailPath(rd.Preview[0].Dst))
|
||||
fmt.Fprintf(&sb, "План: %d файлов → %s", n, esc(tailPath(rd.Preview[0].Dst)))
|
||||
}
|
||||
|
||||
return strings.TrimRight(sb.String(), "\n"), b.reviewKeyboard(rd)
|
||||
@@ -99,32 +117,36 @@ func displayTitle(rd *worker.ReviewData) string {
|
||||
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 {
|
||||
title := displayTitle(rd)
|
||||
if title == "" {
|
||||
title = "#" + rd.Download.ID
|
||||
}
|
||||
label := titleLabel(rd)
|
||||
n := len(rd.Preview)
|
||||
if n == 0 {
|
||||
return fmt.Sprintf("✅ Готово: «%s» разложен.", title)
|
||||
return fmt.Sprintf("✅ Готово: %s разложен.", label)
|
||||
}
|
||||
return fmt.Sprintf("✅ Готово: «%s» — разложено файлов: %d.", title, n)
|
||||
return fmt.Sprintf("✅ Готово: %s — разложено файлов: %d.", label, n)
|
||||
}
|
||||
|
||||
// renderDesync — уведомление о рассинхроне (источник/цель удалены вручную).
|
||||
func (b *Bot) renderDesync(rd *worker.ReviewData, event worker.NotifyEvent) string {
|
||||
title := displayTitle(rd)
|
||||
if title == "" {
|
||||
title = "#" + rd.Download.ID
|
||||
}
|
||||
label := titleLabel(rd)
|
||||
switch event {
|
||||
case worker.EventTargetMissing:
|
||||
return fmt.Sprintf("⚠️ «%s»: файлы удалены из библиотеки, источник на месте — можно привязать заново.", title)
|
||||
return fmt.Sprintf("⚠️ %s: файлы удалены из библиотеки, источник на месте — можно привязать заново.", label)
|
||||
case worker.EventOrphaned:
|
||||
return fmt.Sprintf("⚠️ «%s»: источник удалён из qBittorrent, библиотечная копия осталась последней (откат недоступен).", title)
|
||||
return fmt.Sprintf("⚠️ %s: источник удалён из qBittorrent, библиотечная копия осталась последней (откат недоступен).", label)
|
||||
default:
|
||||
return fmt.Sprintf("⚠️ «%s»: рассинхрон состояния.", title)
|
||||
return fmt.Sprintf("⚠️ %s: рассинхрон состояния.", label)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,20 +160,20 @@ func (b *Bot) renderFailed(rd *worker.ReviewData) (string, *tgbotapi.InlineKeybo
|
||||
}
|
||||
// Заголовок (display_name) для читаемости + #id для поиска по логам.
|
||||
if title := displayTitle(rd); title != "" {
|
||||
fmt.Fprintf(&sb, "❌ «%s» — задача #%s %s", title, id, verb)
|
||||
fmt.Fprintf(&sb, "❌ «%s» — задача #%s %s", esc(title), idCode(id), verb)
|
||||
} else {
|
||||
fmt.Fprintf(&sb, "❌ Задача #%s %s", id, verb)
|
||||
fmt.Fprintf(&sb, "❌ Задача #%s %s", idCode(id), verb)
|
||||
}
|
||||
if code := rd.Download.ErrorCode.String; code != "" {
|
||||
fmt.Fprintf(&sb, " (%s)", code)
|
||||
fmt.Fprintf(&sb, " (%s)", esc(code))
|
||||
}
|
||||
sb.WriteString(".")
|
||||
if msg := rd.Download.ErrorMsg.String; msg != "" {
|
||||
sb.WriteString("\n")
|
||||
sb.WriteString(msg)
|
||||
sb.WriteString(esc(msg))
|
||||
}
|
||||
if src := contextOrSource(rd); src != "" {
|
||||
fmt.Fprintf(&sb, "\nИсточник: %s", shorten(src, 80))
|
||||
fmt.Fprintf(&sb, "\nИсточник: %s", esc(shorten(src, 80)))
|
||||
}
|
||||
return sb.String(), b.retryKeyboard(id)
|
||||
}
|
||||
@@ -236,21 +258,25 @@ func guessLine(rd *worker.ReviewData) string {
|
||||
if title == "" {
|
||||
title = "не распознано"
|
||||
}
|
||||
s := fmt.Sprintf("%s %s «%s»", emoji, kind, 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 rec.Provider.String + " " + rec.ProviderID.String
|
||||
return esc(rec.Provider.String) + " " + esc(rec.ProviderID.String)
|
||||
}
|
||||
return rec.Provider.String
|
||||
return esc(rec.Provider.String)
|
||||
}
|
||||
|
||||
func contextOrSource(rd *worker.ReviewData) string {
|
||||
|
||||
Reference in New Issue
Block a user