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
@@ -0,0 +1,50 @@
## Why
Download id в уведомлениях бота сейчас уходит обычным текстом с префиксом `#`
(`internal/tgbot/bot.go`, `render.go`). В клиентах Telegram по моноширинному
`<code>`-тексту работает tap-to-copy — удобно скопировать id для перехода на
`/download/{id}` или для диагностики по логам. Обычный текст так скопировать
нельзя.
Чтобы получить `<code>`, нужно включить у исходящих сообщений parse mode. Это
делает разметку значимой для **всех** текстов бота: спецсимволы (`<`, `>`, `&`)
в display name, путях, распознанных названиях, причинах и тексте ошибок сломают
сообщение или будут истолкованы как разметка. Escape-хелпера сейчас нет — его
надо ввести и аккуратно применить ко всем внешним фрагментам.
## What Changes
- **Download id выводится моноширинным** (`<code>{id}</code>`) во всех
уведомлениях бота — tap-to-copy. Визуальный префикс (`#` / `download_id=`)
остаётся вне `<code>`, чтобы копировался чистый 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 как `<code>`), `internal/tgbot/render.go`
(escape-хелпер + экранирование всех внешних фрагментов, id в `<code>`).
- **Тесты:** `internal/tgbot/bot_test.go` — обновить ожидания текста (id теперь в
`<code>`), добавить проверку экранирования спецсимволов во внешнем фрагменте.
- **Миграции БД:** нет.
@@ -0,0 +1,46 @@
## ADDED Requirements
### Requirement: Download id в уведомлениях моноширинным для tap-to-copy
Уведомления, содержащие download id, система SHALL отображать id моноширинным
блоком (в Telegram — `<code>`), чтобы в клиенте работало 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** символы разметки в пути и причинах экранируются перед вставкой
@@ -0,0 +1,30 @@
## 1. Код
- [x] 1.1 `internal/tgbot/render.go`: добавить хелпер `esc` (экранирование
внешнего текста для HTML parse mode Telegram) и `idCode` (обёртка download id в
`<code>` для 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`), иначе обрез посреди сущности
`&lt;` даст битую разметку → Telegram 400 → сообщение не доставится.
## 2. Тесты
- [x] 2.1 `internal/tgbot/bot_test.go`: обновить ожидания текста под `<code>`-id
- [x] 2.2 Добавить тест: внешний фрагмент со спецсимволами (`<`/`>`/`&`) в
уведомлении экранируется (не ломает разметку) — покрыть desync/failed-путь и
кейс усечения длинного значения со спецсимволом у границы `shorten`
## 3. Спека
- [x] 3.1 Дельта `notifications` (два ADDED-требования); `openspec validate
--strict telegram-download-id-code`
+45
View File
@@ -54,3 +54,48 @@ Telegram / бейдж в вебе) — пользователя зовут, а
- **WHEN** задача переходит в `orphaned`
- **THEN** автор загрузки получает уведомление о рассинхроне
### Requirement: Download id в уведомлениях моноширинным для tap-to-copy
Уведомления, содержащие download id, система SHALL отображать id моноширинным
блоком (в Telegram — `<code>`), чтобы в клиенте работало 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** символы разметки в пути и причинах экранируются перед вставкой