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