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:
av
2026-07-18 15:17:26 +03:00
co-authored by Claude Opus 4.8
parent 85e27b28e2
commit 4a58b0bda0
11 changed files with 330 additions and 69 deletions
+16 -7
View File
@@ -161,7 +161,7 @@ func (b *Bot) handleMessage(ctx context.Context, m *tgbotapi.Message) {
b.send(m.Chat.ID, opErr("Не удалось обработать подсказку", id), nil)
return
}
b.send(m.Chat.ID, "Подсказка принята, перераспознаю #"+id+"…", nil)
b.send(m.Chat.ID, "Подсказка принята, перераспознаю #"+idCode(id)+"…", nil)
return
}
@@ -275,17 +275,17 @@ func (b *Bot) ingestAndReply(ctx context.Context, chatID int64, req ingest.Reque
switch res.State {
case store.StateTargetMissing:
// Источник жив, цель удалена — из target_missing доступна перепривязка.
b.send(chatID, fmt.Sprintf("♻️ Этот торрент уже есть как запись #%s без цели — привяжите заново или закройте её.", res.DownloadID), nil)
b.send(chatID, fmt.Sprintf("♻️ Этот торрент уже есть как запись #%s без цели — привяжите заново или закройте её.", idCode(res.DownloadID)), nil)
case store.StateOrphaned:
// Источник пропал: relink из orphaned нет, рабочий путь — закрыть и
// добавить заново (тогда приём заведёт свежую загрузку).
b.send(chatID, fmt.Sprintf("♻️ Этот торрент уже есть как осиротевшая запись #%s — закройте её, затем добавьте заново.", res.DownloadID), nil)
b.send(chatID, fmt.Sprintf("♻️ Этот торрент уже есть как осиротевшая запись #%s — закройте её, затем добавьте заново.", idCode(res.DownloadID)), nil)
default:
b.send(chatID, fmt.Sprintf("♻️ Дубль уже активной загрузки #%s — добавление отменено.", res.DownloadID), nil)
b.send(chatID, fmt.Sprintf("♻️ Дубль уже активной загрузки #%s — добавление отменено.", idCode(res.DownloadID)), nil)
}
return
}
b.send(chatID, fmt.Sprintf("Принято #%s — добавляю в qBittorrent.\nПозову, когда нужно подтверждение.", res.DownloadID), nil)
b.send(chatID, fmt.Sprintf("Принято #%s — добавляю в qBittorrent.\nПозову, когда нужно подтверждение.", idCode(res.DownloadID)), nil)
}
const helpText = `jellybit-бот: пришлите magnet-ссылку, .torrent-файл или перешлите сообщение торрент-бота — поставлю на закачку.
@@ -360,7 +360,7 @@ func (b *Bot) handleCallback(ctx context.Context, cq *tgbotapi.CallbackQuery) {
case "refine":
b.setPending(chatID, id)
b.answer(cq.ID, "Жду подсказку")
b.send(chatID, "Ответьте сообщением с подсказкой для #"+id+".", nil)
b.send(chatID, "Ответьте сообщением с подсказкой для #"+idCode(id)+".", nil)
return
default:
b.answer(cq.ID, "")
@@ -406,6 +406,9 @@ func (b *Bot) refreshCard(ctx context.Context, chatID int64, msgID int, id strin
} else {
edit = tgbotapi.NewEditMessageText(chatID, msgID, text)
}
// Тот же HTML parse mode, что и в send(): текст карточки содержит <code>-id и
// экранированные внешние фрагменты — правка на месте должна их так же трактовать.
edit.ParseMode = tgbotapi.ModeHTML
if _, err := b.api.Send(edit); err != nil {
b.log.Warn("telegram edit card failed", "download_id", id, "error", logging.SanitizeErr(err))
}
@@ -441,6 +444,10 @@ func (b *Bot) Notify(ctx context.Context, downloadID string, event worker.Notify
func (b *Bot) send(chatID int64, text string, kb *tgbotapi.InlineKeyboardMarkup) {
msg := tgbotapi.NewMessage(chatID, text)
// HTML parse mode — ради моноширинного <code> у download id (tap-to-copy).
// Разметка становится значимой для ВСЕХ сообщений: внешний текст (имена,
// пути, причины, ошибки) обязан быть экранирован через esc() при рендере.
msg.ParseMode = tgbotapi.ModeHTML
msg.DisableWebPagePreview = true
if kb != nil {
msg.ReplyMarkup = *kb
@@ -489,7 +496,9 @@ func (b *Bot) takePending(chatID int64) (string, bool) {
// операции ещё нет (downloadID == "") — дружелюбный текст без ключа.
func opErr(msg string, downloadID string) string {
if downloadID != "" {
return fmt.Sprintf("%s (download_id=%s).", msg, downloadID)
// download_id моноширинным для tap-to-copy; msg — контролируемый литерал
// вызывающего (без спецсимволов разметки), потому не экранируется.
return fmt.Sprintf("%s (download_id=%s).", msg, idCode(downloadID))
}
return msg + "."
}