Когда для распознавания сохранены кандидаты метабазы, карточка подтверждения бота показывает кнопку «🗂 База (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>
69 lines
5.1 KiB
Markdown
69 lines
5.1 KiB
Markdown
## Контекст
|
||
|
||
Ядро выбора источника уже есть: `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) и плодит сообщения.
|