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:
+16
-7
@@ -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 + "."
|
||||
}
|
||||
|
||||
@@ -27,17 +27,18 @@ type fakeAPI struct {
|
||||
}
|
||||
|
||||
type sentMsg struct {
|
||||
chatID int64
|
||||
text string
|
||||
hasKB bool
|
||||
chatID int64
|
||||
text string
|
||||
hasKB bool
|
||||
parseMode string
|
||||
}
|
||||
|
||||
func (f *fakeAPI) Send(c tgbotapi.Chattable) (tgbotapi.Message, error) {
|
||||
switch m := c.(type) {
|
||||
case tgbotapi.MessageConfig:
|
||||
f.sent = append(f.sent, sentMsg{m.ChatID, m.Text, m.ReplyMarkup != nil})
|
||||
f.sent = append(f.sent, sentMsg{m.ChatID, m.Text, m.ReplyMarkup != nil, m.ParseMode})
|
||||
case tgbotapi.EditMessageTextConfig:
|
||||
f.edits = append(f.edits, sentMsg{m.ChatID, m.Text, m.ReplyMarkup != nil})
|
||||
f.edits = append(f.edits, sentMsg{m.ChatID, m.Text, m.ReplyMarkup != nil, m.ParseMode})
|
||||
}
|
||||
return tgbotapi.Message{MessageID: 1}, nil
|
||||
}
|
||||
@@ -162,7 +163,7 @@ func TestBot_IngestFromMagnet(t *testing.T) {
|
||||
if ing.lastReq.Context != "крутой сериал" {
|
||||
t.Errorf("context = %q", ing.lastReq.Context)
|
||||
}
|
||||
if len(api.sent) != 1 || !strings.Contains(api.sent[0].text, "Принято #"+tid) {
|
||||
if len(api.sent) != 1 || !strings.Contains(api.sent[0].text, "Принято #"+idCode(tid)) {
|
||||
t.Errorf("sent = %+v", api.sent)
|
||||
}
|
||||
}
|
||||
@@ -252,6 +253,9 @@ func TestBot_CallbackApply(t *testing.T) {
|
||||
if len(api.edits) != 1 { // карточка обновлена на месте
|
||||
t.Errorf("edits = %v", api.edits)
|
||||
}
|
||||
if api.edits[0].parseMode != tgbotapi.ModeHTML {
|
||||
t.Errorf("edit parse mode = %q, want HTML", api.edits[0].parseMode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBot_CallbackType(t *testing.T) {
|
||||
@@ -289,7 +293,7 @@ func TestBot_NotifyReview(t *testing.T) {
|
||||
if len(api.sent) != 2 { // обоим доверенным
|
||||
t.Fatalf("sent to %d chats, want 2", len(api.sent))
|
||||
}
|
||||
if !strings.Contains(api.sent[0].text, "Нужно подтверждение #"+tid) {
|
||||
if !strings.Contains(api.sent[0].text, "Нужно подтверждение #"+idCode(tid)) {
|
||||
t.Errorf("card text = %q", api.sent[0].text)
|
||||
}
|
||||
if !api.sent[0].hasKB {
|
||||
@@ -317,7 +321,7 @@ func TestBot_NotifyFailed(t *testing.T) {
|
||||
// В ошибке — и заголовок (display_name), и #id для поиска по логам.
|
||||
if len(api.sent) != 1 || !strings.Contains(api.sent[0].text, "не удалась") ||
|
||||
!strings.Contains(api.sent[0].text, "Фарго (2015). Сезон 2") ||
|
||||
!strings.Contains(api.sent[0].text, "#"+tid) {
|
||||
!strings.Contains(api.sent[0].text, "#"+idCode(tid)) {
|
||||
t.Errorf("sent = %+v", api.sent)
|
||||
}
|
||||
if !api.sent[0].hasKB { // кнопка повтора
|
||||
@@ -325,6 +329,78 @@ func TestBot_NotifyFailed(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// HTML parse mode включён у всех исходящих: внешний текст (название, источник,
|
||||
// причины) должен экранироваться, а download id — уходить моноширинным <code>.
|
||||
// Иначе спецсимволы (`<`/`>`/`&`) в названии/пути сломали бы разметку.
|
||||
func TestBot_NotifyEscapesExternalText(t *testing.T) {
|
||||
b, api, _, rev := newTestBot(t, []int64{7})
|
||||
rd := reviewData(store.StateReview)
|
||||
rd.Download.DisplayName = "" // чтобы карточка показала Plan.Title через guessLine
|
||||
rd.Plan.Title = "Tom & Jerry <b>x</b>"
|
||||
// Источник длиннее лимита shorten(80) со спецсимволом ровно на границе:
|
||||
// проверяем, что esc идёт ПОСЛЕ усечения (иначе сущность разрубится).
|
||||
rd.Download.Context = strings.Repeat("a", 79) + "&" + strings.Repeat("b", 5)
|
||||
rev.data = rd
|
||||
|
||||
b.Notify(context.Background(), tid, worker.EventReview)
|
||||
|
||||
if len(api.sent) != 1 {
|
||||
t.Fatalf("sent = %+v", api.sent)
|
||||
}
|
||||
msg := api.sent[0]
|
||||
if msg.parseMode != tgbotapi.ModeHTML {
|
||||
t.Errorf("parse mode = %q, want HTML", msg.parseMode)
|
||||
}
|
||||
// download id — моноширинным (tap-to-copy).
|
||||
if !strings.Contains(msg.text, idCode(tid)) {
|
||||
t.Errorf("id не в <code>: %q", msg.text)
|
||||
}
|
||||
// Спецсимволы названия экранированы, сырая разметка не просочилась.
|
||||
if !strings.Contains(msg.text, "Tom & Jerry <b>x</b>") {
|
||||
t.Errorf("название не экранировано: %q", msg.text)
|
||||
}
|
||||
if strings.Contains(msg.text, "<b>") {
|
||||
t.Errorf("сырая разметка просочилась: %q", msg.text)
|
||||
}
|
||||
// Усечённый источник: сущность на границе цела (esc после shorten даёт
|
||||
// «&…», а не разрубленное «&am…»).
|
||||
if !strings.Contains(msg.text, "&…") {
|
||||
t.Errorf("источник обрезан посреди сущности: %q", msg.text)
|
||||
}
|
||||
}
|
||||
|
||||
// Failed-путь (renderFailed): спецсимволы в названии и тексте ошибки
|
||||
// экранируются, #id уходит моноширинным. Закрывает opErr/failed-ветку формата.
|
||||
func TestBot_NotifyFailedEscapesExternalText(t *testing.T) {
|
||||
b, api, _, rev := newTestBot(t, []int64{7})
|
||||
rd := reviewData(store.StateFailed)
|
||||
rd.Download.DisplayName = "A & B <x>"
|
||||
rd.Download.ErrorMsg = store.NullString("path <bad> & stuff")
|
||||
rev.data = rd
|
||||
|
||||
b.Notify(context.Background(), tid, worker.EventFailed)
|
||||
|
||||
if len(api.sent) != 1 {
|
||||
t.Fatalf("sent = %+v", api.sent)
|
||||
}
|
||||
msg := api.sent[0]
|
||||
if msg.parseMode != tgbotapi.ModeHTML {
|
||||
t.Errorf("parse mode = %q, want HTML", msg.parseMode)
|
||||
}
|
||||
if !strings.Contains(msg.text, "«A & B <x>»") {
|
||||
t.Errorf("название failed не экранировано: %q", msg.text)
|
||||
}
|
||||
if !strings.Contains(msg.text, "path <bad> & stuff") {
|
||||
t.Errorf("текст ошибки не экранирован: %q", msg.text)
|
||||
}
|
||||
if !strings.Contains(msg.text, "#"+idCode(tid)) {
|
||||
t.Errorf("id не в <code>: %q", msg.text)
|
||||
}
|
||||
if strings.Contains(msg.text, "<x>") {
|
||||
t.Errorf("сырая разметка просочилась: %q", msg.text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBot_CallbackRetry(t *testing.T) {
|
||||
b, _, _, rev := newTestBot(t, []int64{7})
|
||||
rev.data = reviewData(store.StateFailed)
|
||||
|
||||
@@ -37,7 +37,7 @@ func TestBot_IngestFromDocument(t *testing.T) {
|
||||
if ing.lastReq.Context != "Дюна 2" {
|
||||
t.Errorf("context (подпись) = %q", ing.lastReq.Context)
|
||||
}
|
||||
if len(api.sent) != 1 || !strings.Contains(api.sent[0].text, "Принято #"+tid) {
|
||||
if len(api.sent) != 1 || !strings.Contains(api.sent[0].text, "Принято #"+idCode(tid)) {
|
||||
t.Errorf("sent = %+v", api.sent)
|
||||
}
|
||||
}
|
||||
|
||||
+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