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** символы разметки в пути и причинах экранируются перед вставкой +