diff --git a/docs/backlog/README.md b/docs/backlog/README.md
index f0c674a..19c3315 100644
--- a/docs/backlog/README.md
+++ b/docs/backlog/README.md
@@ -40,7 +40,6 @@ Tududi (проект `jellybit`) больше **не** держит беклог
## Низкий
- [Ревью уведомлений в Telegram (аудит текстов и формата)](telegram-revyu-uvedomleniy.md) — зонтичный проход по всем текстам бота: полнота карточек, единый язык, оформление; порождает под-задачи
-- [Download id в Telegram моноширинным (code) для tap-to-copy](telegram-download-id-code.md) — слать id как `code`; требует включить parse mode (HTML) в send() + escape всех текстов
- [Мгновенные обновления через SSE](sse-obnovleniya.md) — Живые обновления прогресса сейчас на htmx-поллинге (фаза 2 веб-UI) — просто и работает…
- [Шум ERROR фоновых циклов при недоступной зависимости](oshibki-klassifikaciya-i-konvencii-logirovaniya.md) — Остаток задачи логирования: ext.* ERROR-шторм при недоступном qBittorrent + эскалация устойчивого сбоя тика _(ревью Fable)_
- [Версии/качество одного тайтла (репаки, апгрейд 1080p → 2160p)](versii-kachestvo-repaki.md) — По калибровке болей (2026-07-02) — не боль, из приоритета выпало
diff --git a/docs/backlog/telegram-download-id-code.md b/docs/backlog/telegram-download-id-code.md
deleted file mode 100644
index 98377a6..0000000
--- a/docs/backlog/telegram-download-id-code.md
+++ /dev/null
@@ -1,21 +0,0 @@
-# Download id в Telegram моноширинным (code) для tap-to-copy
-
-**Приоритет:** низкий
-
-Сейчас download id выводится в уведомлениях бота обычным текстом с префиксом `#`
-(`internal/tgbot/bot.go:288`, `render.go:26/50/151`). Хотим слать id как
-`code`-текст (моноширинный) — в клиентах Telegram по нему работает tap-to-copy,
-удобно скопировать id для перехода на `/download/{id}` или для диагностики.
-
-Что учесть:
-
-- `send()` (`internal/tgbot/bot.go:442`) сейчас не выставляет `ParseMode` — всё
- уходит plain text. Для `code` нужно включить parse mode. Рекомендуется **HTML**
- (`%s`) — экранирование проще и локальнее, чем у MarkdownV2.
-- Включение parse mode затрагивает **все** исходящие сообщения: спецсимволы в
- display name, путях, названиях сломают разметку, если их не экранировать.
- Escape-хелпера сейчас нет — нужен, плюс аккуратный проход по всем текстам
- `render.go`. Это часть общего [[telegram-revyu-uvedomleniy]] — имеет смысл делать
- вместе.
-
-Связано: `internal/tgbot` (`render.go`, `bot.go`).
diff --git a/docs/backlog/telegram-revyu-uvedomleniy.md b/docs/backlog/telegram-revyu-uvedomleniy.md
index 706e866..57b3c44 100644
--- a/docs/backlog/telegram-revyu-uvedomleniy.md
+++ b/docs/backlog/telegram-revyu-uvedomleniy.md
@@ -13,8 +13,9 @@
запись матча в метабазе, download id, причину `failed`.
- **Единый язык:** в текстах бота вперемешку «задача»/«раздача»/«загрузка» —
свести к доменному `Download` (см. [[ubiquitous-language-slovar]]).
-- **Оформление:** моноширинный download id [[telegram-download-id-code]], возможно
- акценты — упирается в parse mode + escape (общая развилка с задачей про code).
+- **Оформление:** моноширинный download id — уже сделано (HTML parse mode +
+ escape всех текстов, capability `notifications`); осталось при желании добавить
+ акценты поверх включённого parse mode.
- **Не дублировать** уже заведённое: матч метабазы в боте
[[telegram-match-metabazy]], мульти-бот адресация уведомлений
[[uvedomleniya-multi-bot]], выбор из нескольких находок [[telegram-vybor-nahodok]].
diff --git a/internal/tgbot/bot.go b/internal/tgbot/bot.go
index 0577b25..d912806 100644
--- a/internal/tgbot/bot.go
+++ b/internal/tgbot/bot.go
@@ -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(): текст карточки содержит -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 — ради моноширинного у 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 + "."
}
diff --git a/internal/tgbot/bot_test.go b/internal/tgbot/bot_test.go
index 7b24603..41809d7 100644
--- a/internal/tgbot/bot_test.go
+++ b/internal/tgbot/bot_test.go
@@ -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 — уходить моноширинным .
+// Иначе спецсимволы (`<`/`>`/`&`) в названии/пути сломали бы разметку.
+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 x"
+ // Источник длиннее лимита 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 не в : %q", msg.text)
+ }
+ // Спецсимволы названия экранированы, сырая разметка не просочилась.
+ if !strings.Contains(msg.text, "Tom & Jerry <b>x</b>") {
+ t.Errorf("название не экранировано: %q", msg.text)
+ }
+ if strings.Contains(msg.text, "") {
+ 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 "
+ rd.Download.ErrorMsg = store.NullString("path & 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 не в : %q", msg.text)
+ }
+ if strings.Contains(msg.text, "") {
+ t.Errorf("сырая разметка просочилась: %q", msg.text)
+ }
+}
+
func TestBot_CallbackRetry(t *testing.T) {
b, _, _, rev := newTestBot(t, []int64{7})
rev.data = reviewData(store.StateFailed)
diff --git a/internal/tgbot/document_test.go b/internal/tgbot/document_test.go
index 5fada10..1f2cc62 100644
--- a/internal/tgbot/document_test.go
+++ b/internal/tgbot/document_test.go
@@ -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)
}
}
diff --git a/internal/tgbot/render.go b/internal/tgbot/render.go
index b210a65..21d646c 100644
--- a/internal/tgbot/render.go
+++ b/internal/tgbot/render.go
@@ -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 в моноширинный — в клиентах 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
@@ -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 оборачивается в (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 {
diff --git a/openspec/changes/archive/2026-07-18-telegram-download-id-code/proposal.md b/openspec/changes/archive/2026-07-18-telegram-download-id-code/proposal.md
new file mode 100644
index 0000000..7ec2e6f
--- /dev/null
+++ b/openspec/changes/archive/2026-07-18-telegram-download-id-code/proposal.md
@@ -0,0 +1,50 @@
+## Why
+
+Download id в уведомлениях бота сейчас уходит обычным текстом с префиксом `#`
+(`internal/tgbot/bot.go`, `render.go`). В клиентах Telegram по моноширинному
+``-тексту работает tap-to-copy — удобно скопировать id для перехода на
+`/download/{id}` или для диагностики по логам. Обычный текст так скопировать
+нельзя.
+
+Чтобы получить ``, нужно включить у исходящих сообщений parse mode. Это
+делает разметку значимой для **всех** текстов бота: спецсимволы (`<`, `>`, `&`)
+в display name, путях, распознанных названиях, причинах и тексте ошибок сломают
+сообщение или будут истолкованы как разметка. Escape-хелпера сейчас нет — его
+надо ввести и аккуратно применить ко всем внешним фрагментам.
+
+## What Changes
+
+- **Download id выводится моноширинным** (`{id}`) во всех
+ уведомлениях бота — tap-to-copy. Визуальный префикс (`#` / `download_id=`)
+ остаётся вне ``, чтобы копировался чистый id.
+- **Включается HTML parse mode** у всех исходящих сообщений — как у `send()`, так
+ и у edit-пути обновления карточки (`refreshCard`).
+- **Вводится escape-хелпер**; все внешние/недоверенные фрагменты (display name,
+ распознанное название, источник/контекст, путь плана, причины распознавания,
+ provider, `error_code`/`error_msg`) экранируются перед вставкой в размеченное
+ сообщение. Инвариант «выход LLM недоверенный» распространяется на разметку.
+- Секреты по-прежнему не попадают в сообщения и логи (без изменений).
+
+## Capabilities
+
+### New Capabilities
+
+Нет.
+
+### Modified Capabilities
+
+- `notifications`: добавляется требование к **формату** уведомлений — download id
+ моноширинным (tap-to-copy) и инвариант безопасного экранирования внешнего
+ текста при включённом форматировании. Условия и события доставки уведомлений
+ (падение, review, готовность, рассинхрон) без изменений.
+
+## Impact
+
+- **Спеки:** дельта `notifications` (два ADDED-требования: формат id и
+ экранирование).
+- **Код:** `internal/tgbot/bot.go` (`send` — ParseMode HTML; `refreshCard` —
+ ParseMode HTML на edit; композиция id как ``), `internal/tgbot/render.go`
+ (escape-хелпер + экранирование всех внешних фрагментов, id в ``).
+- **Тесты:** `internal/tgbot/bot_test.go` — обновить ожидания текста (id теперь в
+ ``), добавить проверку экранирования спецсимволов во внешнем фрагменте.
+- **Миграции БД:** нет.
diff --git a/openspec/changes/archive/2026-07-18-telegram-download-id-code/specs/notifications/spec.md b/openspec/changes/archive/2026-07-18-telegram-download-id-code/specs/notifications/spec.md
new file mode 100644
index 0000000..02b5984
--- /dev/null
+++ b/openspec/changes/archive/2026-07-18-telegram-download-id-code/specs/notifications/spec.md
@@ -0,0 +1,46 @@
+## ADDED Requirements
+
+### Requirement: Download id в уведомлениях моноширинным для tap-to-copy
+
+Уведомления, содержащие download id, система SHALL отображать id моноширинным
+блоком (в Telegram — ``), чтобы в клиенте работало tap-to-copy: id можно
+скопировать одним касанием для перехода на `/download/{id}` или диагностики по
+логам. Визуальный префикс (`#` / `download_id=`) SHALL оставаться вне
+моноширинного блока, чтобы копировался чистый id без лишних символов.
+
+#### Scenario: Id карточки review копируется одним касанием
+
+- **GIVEN** уведомление о входе загрузки в `review` с download id
+- **WHEN** бот рендерит сообщение
+- **THEN** download id выводится моноширинным блоком (tap-to-copy), а префикс `#`
+ остаётся обычным текстом вне блока
+
+#### Scenario: Id в сообщении об ошибке копируется
+
+- **GIVEN** сообщение об отказе операции с `download_id`
+- **WHEN** бот рендерит сообщение
+- **THEN** значение download id выводится моноширинным блоком для копирования
+
+### Requirement: Экранирование внешнего текста при форматированных уведомлениях
+
+При включённом форматировании исходящих сообщений (parse mode) система MUST
+экранировать все внешние/недоверенные фрагменты перед вставкой в размеченное
+сообщение: display name, распознанное название, источник/контекст, целевой путь,
+причины распознавания, provider, код и текст ошибки. Это защищает от того, что
+спецсимволы разметки сломают сообщение или что разметка будет инъектирована из
+недоверенного источника (инвариант «выход LLM недоверенный»). Секреты
+(токены/ключи/пароли) MUST NOT попадать в текст уведомлений и логи.
+
+#### Scenario: Спецсимволы в названии не ломают разметку
+
+- **GIVEN** уведомление, где display name или распознанное название содержит
+ символы разметки (`<`, `>`, `&`)
+- **WHEN** бот рендерит форматированное сообщение
+- **THEN** эти символы экранируются, сообщение доставляется корректно, а разметка
+ из недоверенного текста не интерпретируется
+
+#### Scenario: Внешний путь и причины экранируются
+
+- **GIVEN** уведомление с целевым путём плана и причинами распознавания
+- **WHEN** бот рендерит форматированное сообщение
+- **THEN** символы разметки в пути и причинах экранируются перед вставкой
diff --git a/openspec/changes/archive/2026-07-18-telegram-download-id-code/tasks.md b/openspec/changes/archive/2026-07-18-telegram-download-id-code/tasks.md
new file mode 100644
index 0000000..2c00dfa
--- /dev/null
+++ b/openspec/changes/archive/2026-07-18-telegram-download-id-code/tasks.md
@@ -0,0 +1,30 @@
+## 1. Код
+
+- [x] 1.1 `internal/tgbot/render.go`: добавить хелпер `esc` (экранирование
+ внешнего текста для HTML parse mode Telegram) и `idCode` (обёртка download id в
+ `` для tap-to-copy)
+- [x] 1.2 `internal/tgbot/bot.go`: в `send()` выставить `ParseMode = HTML`; в
+ `refreshCard()` выставить `ParseMode = HTML` на edit-конфиге (оба варианта:
+ с клавиатурой и без)
+- [x] 1.3 Заменить вывод download id на `idCode(id)` во всех сообщениях
+ (`bot.go`: pending/принято/дубль/refine/`opErr`; `render.go`: карточки, дефолтная
+ ветка `renderCard`, распознаю/раскладываю, done/failed, **`renderDesync`**)
+- [x] 1.4 Экранировать все внешние фрагменты в `render.go` через `esc`: display
+ name / распознанное название, источник/контекст, путь плана, причины,
+ provider **и `provider_id`**, `error_code`, `error_msg`. Не забыть
+ **`renderDesync`** (внешний `displayTitle` уходит в `send` с HTML).
+ **Порядок:** `esc` применяем ПОСЛЕДНИМ шагом — над уже усечённым текстом
+ (после `shorten`/`tailPath`/`firstLine`), иначе обрез посреди сущности
+ `<` даст битую разметку → Telegram 400 → сообщение не доставится.
+
+## 2. Тесты
+
+- [x] 2.1 `internal/tgbot/bot_test.go`: обновить ожидания текста под ``-id
+- [x] 2.2 Добавить тест: внешний фрагмент со спецсимволами (`<`/`>`/`&`) в
+ уведомлении экранируется (не ломает разметку) — покрыть desync/failed-путь и
+ кейс усечения длинного значения со спецсимволом у границы `shorten`
+
+## 3. Спека
+
+- [x] 3.1 Дельта `notifications` (два ADDED-требования); `openspec validate
+ --strict telegram-download-id-code`
diff --git a/openspec/specs/notifications/spec.md b/openspec/specs/notifications/spec.md
index 6e28ce4..fe94a13 100644
--- a/openspec/specs/notifications/spec.md
+++ b/openspec/specs/notifications/spec.md
@@ -54,3 +54,48 @@ Telegram / бейдж в вебе) — пользователя зовут, а
- **WHEN** задача переходит в `orphaned`
- **THEN** автор загрузки получает уведомление о рассинхроне
+### Requirement: Download id в уведомлениях моноширинным для tap-to-copy
+
+Уведомления, содержащие download id, система SHALL отображать id моноширинным
+блоком (в Telegram — ``), чтобы в клиенте работало tap-to-copy: id можно
+скопировать одним касанием для перехода на `/download/{id}` или диагностики по
+логам. Визуальный префикс (`#` / `download_id=`) SHALL оставаться вне
+моноширинного блока, чтобы копировался чистый id без лишних символов.
+
+#### Scenario: Id карточки review копируется одним касанием
+
+- **GIVEN** уведомление о входе загрузки в `review` с download id
+- **WHEN** бот рендерит сообщение
+- **THEN** download id выводится моноширинным блоком (tap-to-copy), а префикс `#`
+ остаётся обычным текстом вне блока
+
+#### Scenario: Id в сообщении об ошибке копируется
+
+- **GIVEN** сообщение об отказе операции с `download_id`
+- **WHEN** бот рендерит сообщение
+- **THEN** значение download id выводится моноширинным блоком для копирования
+
+### Requirement: Экранирование внешнего текста при форматированных уведомлениях
+
+При включённом форматировании исходящих сообщений (parse mode) система MUST
+экранировать все внешние/недоверенные фрагменты перед вставкой в размеченное
+сообщение: display name, распознанное название, источник/контекст, целевой путь,
+причины распознавания, provider, код и текст ошибки. Это защищает от того, что
+спецсимволы разметки сломают сообщение или что разметка будет инъектирована из
+недоверенного источника (инвариант «выход LLM недоверенный»). Секреты
+(токены/ключи/пароли) MUST NOT попадать в текст уведомлений и логи.
+
+#### Scenario: Спецсимволы в названии не ломают разметку
+
+- **GIVEN** уведомление, где display name или распознанное название содержит
+ символы разметки (`<`, `>`, `&`)
+- **WHEN** бот рендерит форматированное сообщение
+- **THEN** эти символы экранируются, сообщение доставляется корректно, а разметка
+ из недоверенного текста не интерпретируется
+
+#### Scenario: Внешний путь и причины экранируются
+
+- **GIVEN** уведомление с целевым путём плана и причинами распознавания
+- **WHEN** бот рендерит форматированное сообщение
+- **THEN** символы разметки в пути и причинах экранируются перед вставкой
+