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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user