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:
av
2026-07-18 16:02:47 +03:00
co-authored by Claude Opus 4.8
parent 10a6348d39
commit f75d6b1f91
12 changed files with 541 additions and 12 deletions
@@ -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) и плодит сообщения.