Files
avandClaude Opus 4.8 f75d6b1f91 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>
2026-07-18 16:02:47 +03:00

69 lines
5.1 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
## Контекст
Ядро выбора источника уже есть: `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) и плодит сообщения.