tgbot: выбор кандидата метабазы inline-кнопками в карточке ревью
Когда для распознавания сохранены кандидаты метабазы, карточка подтверждения бота показывает кнопку «🗂 База (N)». По ней двухшагово (как delete/dismiss) разворачивается список кандидатов inline-кнопками; выбор пиннит источник через worker.ChooseCandidate (ручной матч, без авто-раскладки) и обновляет карточку. Веб остаётся точкой точных правок (ручной ввод id/URL, «без базы»). Безопасность границы: id кандидата из callback_data валидируется как ULID (ident.Parse) до доменного вызова, как в вебе. Текст inline-кнопок Telegram не парсится как HTML — название кандидата в подписи не экранируется. SDD: change telegram-vybor-nahodok — дельта notifications (ADDED «Выбор кандидата метабазы из карточки подтверждения бота») + review (MODIFIED «Разделение труда транспортов»: быстрый выбор кандидата — Telegram-действие). Влито в specs, change заархивирован. Миграций БД нет (кандидаты уже в БД). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,68 @@
|
||||
## Контекст
|
||||
|
||||
Ядро выбора источника уже есть: `worker.ChooseCandidate(ctx, id, candidateID)`
|
||||
пиннит кандидата (override provider/id/title/year/director), помечает `chosen`,
|
||||
обновляет display_name — но **не** запускает раскладку (человек подтверждает
|
||||
«Применить»). Веб-транспорт зовёт её из `handleChooseCandidate`, валидируя
|
||||
`candidate_id` через `ident.Parse`. В боте механики выбора нет.
|
||||
|
||||
Карточка ревью в боте (`reviewCard`/`reviewKeyboard`) уже двухшаговая для
|
||||
необратимых действий (delete/dismiss): первая кнопка меняет только клавиатуру
|
||||
(`editMarkup`), подтверждение зовёт домен и обновляет карточку (`refreshCard`).
|
||||
Тот же паттерн переиспользуем для выбора кандидата.
|
||||
|
||||
## Решение
|
||||
|
||||
**Поверхность.** В `reviewKeyboard`, когда `len(rd.Candidates) > 0`, добавляем
|
||||
кнопку «🗂 База (N)» с callback `sources:<id>`. Она не трогает домен — через
|
||||
`editMarkup` разворачивает клавиатуру списка кандидатов:
|
||||
|
||||
- по одной кнопке на кандидата (full-width row), текст `provider · Title (Year)`,
|
||||
у выбранного (`Chosen`) — префикс «✓ »; дедуп по `provider:provider_id`, потолок
|
||||
кнопок (кандидатов и так ≤ `maxCandidates`=8);
|
||||
- callback кандидата — `pick:<downloadID>:<candidateID>`;
|
||||
- нижняя кнопка «← Назад» с callback `srcback:<id>` — возвращает карточку
|
||||
(`refreshCard`), домен не трогает.
|
||||
|
||||
**Обработка callback (`handleCallback`).**
|
||||
|
||||
- `sources` → `answer("")` + `editMarkup(candidatesKeyboard(rd))` (перечитываем
|
||||
`ReviewData` ради свежего списка), домен не трогаем;
|
||||
- `srcback` → `answer("Отменено")` + `refreshCard` (как `delete_cancel`);
|
||||
- `pick` → `candidateID` из `val` (третий сегмент, `parseCallback` уже отдал его
|
||||
как `value`) валидируем `ident.Parse` (невалидный → `answer("Кнопка устарела…")`,
|
||||
состояние не меняем), затем
|
||||
`reviewer.ChooseCandidate(ctx, id, candidateID)`; при успехе
|
||||
`answer("Источник выбран") + refreshCard`, при ошибке — общая ветка ошибки.
|
||||
|
||||
**Интерфейс.** В `Reviewer` (bot.go) добавляем
|
||||
`ChooseCandidate(ctx, id, candidateID string) error`; фейк в тестах дополняем.
|
||||
|
||||
## Границы и безопасность
|
||||
|
||||
- **Недоверенный вход:** `candidate_id` из `callback_data` валидируется как ULID
|
||||
на границе транспорта (как в вебе). Принадлежность кандидата текущему
|
||||
распознаванию доменно проверяет сам `ChooseCandidate` (не наш кандидат →
|
||||
`ErrInvalidInput`).
|
||||
- **Авто-раскладка не двигается:** `ChooseCandidate` только пиннит источник;
|
||||
раскладка — отдельным «Применить». Инвариант «авто только по подтверждённому
|
||||
матчу» не затрагивается.
|
||||
- **Экранирование:** текст inline-кнопок Telegram НЕ парсится как HTML —
|
||||
инъекция разметки из названия кандидата в кнопке невозможна; усечение длинных
|
||||
названий — косметика. `sources`/`srcback` меняют только клавиатуру; `pick`
|
||||
перерисовывает и текст карточки (`refreshCard` → `baseLine` с новым
|
||||
provider/id), но новых **неэкранированных** фрагментов не появляется — `baseLine`
|
||||
уже экранирует provider/id/URL (действующее требование «Показ записи матча…»).
|
||||
|
||||
## Бюджет callback_data
|
||||
|
||||
Лимит Telegram — 64 байта. Худший: `pick:<26>:<26>` = 4+1+26+1+26 = 58 ≤ 64.
|
||||
`sources:<26>` = 34, `srcback:<26>` = 34. Запас есть.
|
||||
|
||||
## Отвергнутые альтернативы
|
||||
|
||||
- **Показывать «без базы» (нейтраль) и ручной ввод id в боте** — это точные
|
||||
правки, зона веба (`ClearProvider`/`SetProviderID`); в боте раздули бы интерфейс
|
||||
и клавиатуру. Бот — быстрый выбор из готового списка кандидатов.
|
||||
- **Отдельное сообщение со списком вместо editMarkup** — расходится с уже
|
||||
принятым двухшаговым паттерном (delete/dismiss) и плодит сообщения.
|
||||
Reference in New Issue
Block a user