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:
@@ -45,7 +45,6 @@ Tududi (проект `jellybit`) больше **не** держит беклог
|
||||
- [Версии/качество одного тайтла (репаки, апгрейд 1080p → 2160p)](versii-kachestvo-repaki.md) — По калибровке болей (2026-07-02) — не боль, из приоритета выпало
|
||||
- [[идея] Многоступенчатая верификация привязки](mnogostupenchataya-verifikaciya.md) — ИДЕЯ (требует проработки)
|
||||
- [Согласование канона нумерации серий с провайдером тега](kanon-numeracii-vs-provajder.md) — Косметика/редкость: порядок просмотра ок, но у тайтлов со спорным порядком (Бибоп) Jellyfin подтягивает не те подписи серий, если канон файлов ≠ дефолтный порядок провайдера тега
|
||||
- [Выбор из нескольких находок метабазы в Telegram](telegram-vybor-nahodok.md) — Когда распознавание даёт несколько подходящих кандидатов в метабазе, предлагать их в…
|
||||
- [Добавление торрентов файлом/ссылкой — «единое окно» (остаток: URL)](dobavlenie-edinoe-okno.md) — magnet и .torrent-файл приняты; остался фетч .torrent по URL (нужен SSRF-гард)
|
||||
- [Раздачи-копии диска (DVD/BluRay: VIDEO_TS/BDMV)](disk-kopii-video-ts-bdmv.md) — Иногда для очень редких фильмов скачивается не один видеофайл, а полная копия диска…
|
||||
- [Проверка свободного места перед copy-fallback](svobodnoe-mesto-copy-fallback.md) — Когда хардлинк невозможен (EXDEV/ENOTSUP/…), layout копирует файл, дублируя место на диске
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
# Выбор из нескольких находок метабазы в Telegram
|
||||
|
||||
**Приоритет:** низкий
|
||||
|
||||
Когда распознавание даёт несколько подходящих кандидатов в метабазе, предлагать их в Telegram списком (кнопки) для ручного выбора, а не молча брать первый/лучший. Веб остаётся точкой точных правок (полный выбор источника уже реализован), бот — быстрый выбор из готового короткого списка.
|
||||
|
||||
Связано: specs/review-ux.md (боты — быстрые действия, веб — точные правки), specs/recognition.md (кандидаты матча).
|
||||
@@ -43,6 +43,7 @@ type Reviewer interface {
|
||||
ReviewData(ctx context.Context, id string) (*worker.ReviewData, error)
|
||||
Apply(ctx context.Context, id string) error
|
||||
Refine(ctx context.Context, id string, hint string) error
|
||||
ChooseCandidate(ctx context.Context, id, candidateID string) error
|
||||
SetType(ctx context.Context, id string, mediaType string) error
|
||||
Defer(ctx context.Context, id string) error
|
||||
Cancel(ctx context.Context, id string) error
|
||||
@@ -362,6 +363,33 @@ func (b *Bot) handleCallback(ctx context.Context, cq *tgbotapi.CallbackQuery) {
|
||||
b.answer(cq.ID, "Жду подсказку")
|
||||
b.send(chatID, "Ответьте сообщением с подсказкой для #"+idCode(id)+".", nil)
|
||||
return
|
||||
case "sources":
|
||||
// Первый шаг выбора базы: перечитываем задачу ради свежего списка
|
||||
// кандидатов и разворачиваем клавиатуру списка. Домен не трогаем.
|
||||
rd, rerr := b.reviewer.ReviewData(ctx, id)
|
||||
if rerr != nil {
|
||||
b.log.Warn("telegram sources review data failed", "download_id", id, "error", rerr)
|
||||
b.answer(cq.ID, "Ошибка")
|
||||
return
|
||||
}
|
||||
b.answer(cq.ID, "")
|
||||
b.editMarkup(chatID, msgID, b.candidatesKeyboard(rd))
|
||||
return
|
||||
case "srcback":
|
||||
// Возврат из списка кандидатов к обычной карточке (домен не трогаем).
|
||||
b.answer(cq.ID, "Отменено")
|
||||
b.refreshCard(ctx, chatID, msgID, id)
|
||||
return
|
||||
case "pick":
|
||||
// Выбор кандидата: val — id кандидата из callback_data (недоверенный
|
||||
// вход), валидируем как ULID на границе до доменного вызова.
|
||||
candidateID, perr := ident.Parse(val)
|
||||
if perr != nil {
|
||||
b.answer(cq.ID, "Кнопка устарела — откройте задачу в вебе")
|
||||
return
|
||||
}
|
||||
err = b.reviewer.ChooseCandidate(ctx, id, candidateID)
|
||||
note = "Источник выбран"
|
||||
default:
|
||||
b.answer(cq.ID, "")
|
||||
return
|
||||
|
||||
@@ -2,6 +2,7 @@ package tgbot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"io"
|
||||
"log/slog"
|
||||
"strings"
|
||||
@@ -72,6 +73,7 @@ type fakeReviewer struct {
|
||||
retried []string
|
||||
deleted []string
|
||||
dismissed []string
|
||||
chosen map[string]string // downloadID → выбранный candidateID
|
||||
}
|
||||
|
||||
func (f *fakeReviewer) ReviewData(context.Context, string) (*worker.ReviewData, error) {
|
||||
@@ -88,6 +90,13 @@ func (f *fakeReviewer) Refine(_ context.Context, id string, hint string) error {
|
||||
f.refined[id] = hint
|
||||
return nil
|
||||
}
|
||||
func (f *fakeReviewer) ChooseCandidate(_ context.Context, id, candidateID string) error {
|
||||
if f.chosen == nil {
|
||||
f.chosen = map[string]string{}
|
||||
}
|
||||
f.chosen[id] = candidateID
|
||||
return nil
|
||||
}
|
||||
func (f *fakeReviewer) SetType(_ context.Context, id string, t string) error {
|
||||
if f.typed == nil {
|
||||
f.typed = map[string]string{}
|
||||
@@ -429,6 +438,126 @@ func TestParseCallback(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// candID1/candID2 — валидные lowercase-ULID id кандидатов (callback-data
|
||||
// валидируется как ULID).
|
||||
const (
|
||||
candID1 = "01arz3ndektsv4rrffq69g5fb1"
|
||||
candID2 = "01arz3ndektsv4rrffq69g5fb2"
|
||||
)
|
||||
|
||||
// findButton ищет inline-кнопку по callback data во всей клавиатуре.
|
||||
func findButton(kb *tgbotapi.InlineKeyboardMarkup, data string) (tgbotapi.InlineKeyboardButton, bool) {
|
||||
for _, row := range kb.InlineKeyboard {
|
||||
for _, btn := range row {
|
||||
if btn.CallbackData != nil && *btn.CallbackData == data {
|
||||
return btn, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return tgbotapi.InlineKeyboardButton{}, false
|
||||
}
|
||||
|
||||
// withCandidates возвращает ReviewData с двумя кандидатами (второй — выбран).
|
||||
func withCandidates() *worker.ReviewData {
|
||||
rd := reviewData(store.StateReview)
|
||||
rd.Candidates = []store.MetadataCandidate{
|
||||
{ID: candID1, Provider: "tvdb", ProviderID: "269613", Title: sql.NullString{String: "Fargo", Valid: true}, Year: sql.NullInt64{Int64: 2014, Valid: true}},
|
||||
{ID: candID2, Provider: "tmdb", ProviderID: "60622", Title: sql.NullString{String: "Фарго", Valid: true}, Year: sql.NullInt64{Int64: 2015, Valid: true}, Chosen: true},
|
||||
}
|
||||
return rd
|
||||
}
|
||||
|
||||
// Карточка ревью с кандидатами показывает кнопку выбора базы; без кандидатов — нет.
|
||||
func TestBot_ReviewKeyboardBaseButton(t *testing.T) {
|
||||
b, _, _, _ := newTestBot(t, []int64{7})
|
||||
|
||||
kb := b.reviewKeyboard(withCandidates())
|
||||
if _, ok := findButton(kb, "sources:"+tid); !ok {
|
||||
t.Error("карточка с кандидатами должна иметь кнопку выбора базы")
|
||||
}
|
||||
|
||||
if kbNone := b.reviewKeyboard(reviewData(store.StateReview)); func() bool {
|
||||
_, ok := findButton(kbNone, "sources:"+tid)
|
||||
return ok
|
||||
}() {
|
||||
t.Error("без кандидатов кнопки выбора базы быть не должно")
|
||||
}
|
||||
}
|
||||
|
||||
// Список кандидатов: по кнопке на кандидата (pick:<id>:<candID>) + «Назад»;
|
||||
// выбранный помечен галочкой, дубли по provider:id схлопнуты.
|
||||
func TestBot_CandidatesKeyboard(t *testing.T) {
|
||||
b, _, _, _ := newTestBot(t, []int64{7})
|
||||
rd := withCandidates()
|
||||
// Дубль второго кандидата (тот же provider:id) — должен схлопнуться.
|
||||
rd.Candidates = append(rd.Candidates, store.MetadataCandidate{ID: "01arz3ndektsv4rrffq69g5fb3", Provider: "tmdb", ProviderID: "60622"})
|
||||
|
||||
kb := b.candidatesKeyboard(rd)
|
||||
if _, ok := findButton(kb, "pick:"+tid+":"+candID1); !ok {
|
||||
t.Error("нет кнопки первого кандидата")
|
||||
}
|
||||
chosen, ok := findButton(kb, "pick:"+tid+":"+candID2)
|
||||
if !ok {
|
||||
t.Fatal("нет кнопки выбранного кандидата")
|
||||
}
|
||||
if !strings.HasPrefix(chosen.Text, "✓ ") {
|
||||
t.Errorf("выбранный кандидат должен быть помечен ✓, got %q", chosen.Text)
|
||||
}
|
||||
if _, ok := findButton(kb, "srcback:"+tid); !ok {
|
||||
t.Error("нет кнопки возврата")
|
||||
}
|
||||
// Дубль не породил третью кнопку выбора.
|
||||
if _, ok := findButton(kb, "pick:"+tid+":01arz3ndektsv4rrffq69g5fb3"); ok {
|
||||
t.Error("дубль provider:id должен быть схлопнут")
|
||||
}
|
||||
}
|
||||
|
||||
// Выбор кандидата: pick с валидным id зовёт ChooseCandidate и обновляет карточку.
|
||||
func TestBot_CallbackPick(t *testing.T) {
|
||||
b, api, _, rev := newTestBot(t, []int64{7})
|
||||
rev.data = withCandidates()
|
||||
b.handleCallback(context.Background(), cbFrom(7, "pick:"+tid+":"+candID1))
|
||||
|
||||
if rev.chosen[tid] != candID1 {
|
||||
t.Errorf("ChooseCandidate получил %q, want %q", rev.chosen[tid], candID1)
|
||||
}
|
||||
if len(api.edits) != 1 { // карточка обновлена на месте (refreshCard)
|
||||
t.Errorf("edits = %v, want 1", api.edits)
|
||||
}
|
||||
}
|
||||
|
||||
// Невалидный id кандидата из callback отклоняется на границе — домен не зовём.
|
||||
func TestBot_CallbackPickInvalidID(t *testing.T) {
|
||||
b, api, _, rev := newTestBot(t, []int64{7})
|
||||
b.handleCallback(context.Background(), cbFrom(7, "pick:"+tid+":not-a-ulid"))
|
||||
|
||||
if len(rev.chosen) != 0 {
|
||||
t.Errorf("невалидный id не должен доходить до домена, chosen = %v", rev.chosen)
|
||||
}
|
||||
if len(api.answers) != 1 || !strings.Contains(api.answers[0], "устарела") {
|
||||
t.Errorf("answers = %v, want понятный ответ", api.answers)
|
||||
}
|
||||
}
|
||||
|
||||
// «Назад» из списка кандидатов возвращает карточку, домен не трогает.
|
||||
func TestBot_CallbackSourcesBack(t *testing.T) {
|
||||
b, api, _, rev := newTestBot(t, []int64{7})
|
||||
rev.data = withCandidates()
|
||||
|
||||
b.handleCallback(context.Background(), cbFrom(7, "sources:"+tid))
|
||||
if len(rev.chosen) != 0 {
|
||||
t.Error("разворачивание списка не должно трогать домен")
|
||||
}
|
||||
|
||||
b.handleCallback(context.Background(), cbFrom(7, "srcback:"+tid))
|
||||
if len(rev.chosen) != 0 {
|
||||
t.Error("возврат не должен трогать домен")
|
||||
}
|
||||
if len(api.edits) != 1 { // srcback обновляет карточку на месте
|
||||
t.Errorf("edits = %v, want 1 (srcback refreshCard)", api.edits)
|
||||
}
|
||||
}
|
||||
|
||||
// Нажатие устаревшей кнопки со старым числовым id получает понятный ответ.
|
||||
func TestBot_CallbackStaleButton(t *testing.T) {
|
||||
b, api, _, rev := newTestBot(t, []int64{7})
|
||||
|
||||
@@ -102,6 +102,10 @@ func (b *Bot) reviewKeyboard(rd *worker.ReviewData) *tgbotapi.InlineKeyboardMark
|
||||
if len(rd.Preview) > 0 {
|
||||
row1 = append(row1, tgbotapi.NewInlineKeyboardButtonData("✅ Применить", "apply:"+sid))
|
||||
}
|
||||
// Быстрый выбор базы из готового списка кандидатов (веб — точный ввод id/URL).
|
||||
if n := len(dedupCandidates(rd.Candidates)); n > 0 {
|
||||
row1 = append(row1, tgbotapi.NewInlineKeyboardButtonData(fmt.Sprintf("🗂 База (%d)", n), "sources:"+sid))
|
||||
}
|
||||
row1 = append(row1, tgbotapi.NewInlineKeyboardButtonData("📺↔🎬 Тип", "type:"+sid+":"+oppositeType(string(rd.Plan.Type))))
|
||||
|
||||
row2 := tgbotapi.NewInlineKeyboardRow(
|
||||
@@ -119,6 +123,68 @@ func (b *Bot) reviewKeyboard(rd *worker.ReviewData) *tgbotapi.InlineKeyboardMark
|
||||
return &kb
|
||||
}
|
||||
|
||||
// candidatesKeyboard — второй шаг выбора базы: список кандидатов метабазы (по
|
||||
// кнопке на кандидата) + возврат к карточке. Текст кнопки Telegram не парсится как
|
||||
// HTML, поэтому название кандидата в подписи не экранируем (инъекция разметки в
|
||||
// кнопке невозможна); длинную подпись усекаем — косметика. Выбранный (chosen)
|
||||
// помечаем галочкой. callback выбора — `pick:<id>:<candidateID>` (id кандидата
|
||||
// валидируется как ULID на границе в handleCallback).
|
||||
func (b *Bot) candidatesKeyboard(rd *worker.ReviewData) *tgbotapi.InlineKeyboardMarkup {
|
||||
sid := rd.Download.ID
|
||||
var rows [][]tgbotapi.InlineKeyboardButton
|
||||
for _, c := range dedupCandidates(rd.Candidates) {
|
||||
rows = append(rows, tgbotapi.NewInlineKeyboardRow(
|
||||
tgbotapi.NewInlineKeyboardButtonData(candidateLabel(c), "pick:"+sid+":"+c.ID),
|
||||
))
|
||||
}
|
||||
rows = append(rows, tgbotapi.NewInlineKeyboardRow(
|
||||
tgbotapi.NewInlineKeyboardButtonData("← Назад", "srcback:"+sid),
|
||||
))
|
||||
kb := tgbotapi.NewInlineKeyboardMarkup(rows...)
|
||||
return &kb
|
||||
}
|
||||
|
||||
// dedupCandidates отсеивает повторы по provider:provider_id (стабильный порядок
|
||||
// исходного списка) — как единый список источников в вебе. Потолок — сами
|
||||
// кандидаты (recognize.maxCandidates=8), отдельно не режем. Если среди дублей
|
||||
// одного ключа выбранный (Chosen) идёт не первым, он вытесняет ранее оставленный
|
||||
// невыбранный — чтобы галочка и id кнопки указывали на выбранного.
|
||||
func dedupCandidates(cands []store.MetadataCandidate) []store.MetadataCandidate {
|
||||
idx := map[string]int{}
|
||||
out := make([]store.MetadataCandidate, 0, len(cands))
|
||||
for _, c := range cands {
|
||||
key := c.Provider + ":" + c.ProviderID
|
||||
if i, ok := idx[key]; ok {
|
||||
if c.Chosen && !out[i].Chosen {
|
||||
out[i] = c
|
||||
}
|
||||
continue
|
||||
}
|
||||
idx[key] = len(out)
|
||||
out = append(out, c)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// candidateLabel — подпись кнопки кандидата: `provider · Название (год)`, у
|
||||
// выбранного — префикс «✓ ». Обычный текст (не HTML), усекаем для читаемости.
|
||||
func candidateLabel(c store.MetadataCandidate) string {
|
||||
label := c.Provider
|
||||
if c.Title.Valid && c.Title.String != "" {
|
||||
label += " · " + c.Title.String
|
||||
} else if c.ProviderID != "" {
|
||||
label += " " + c.ProviderID
|
||||
}
|
||||
if c.Year.Valid && c.Year.Int64 != 0 {
|
||||
label += fmt.Sprintf(" (%d)", c.Year.Int64)
|
||||
}
|
||||
label = shorten(label, 48)
|
||||
if c.Chosen {
|
||||
label = "✓ " + label
|
||||
}
|
||||
return label
|
||||
}
|
||||
|
||||
// displayTitle — имя раздачи для уведомлений: имя раздачи (display_name, то,
|
||||
// что ушло в qBittorrent и показывается в веб-UI) → распознанное название.
|
||||
// Пусто, если ни того ни другого нет. Держим заголовки консистентными с UI.
|
||||
|
||||
@@ -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) и плодит сообщения.
|
||||
@@ -0,0 +1,59 @@
|
||||
## Why
|
||||
|
||||
Распознавание уже копит кандидатов метабазы (`recognize.Result.Candidates` →
|
||||
`metadata_candidate`), и веб даёт полный «единый список источников совпадения»
|
||||
для точного выбора. В Telegram же карточка подтверждения показывает лишь
|
||||
эффективный матч (строка «База: …» / «нет матча»), но **не даёт выбрать** между
|
||||
несколькими найденными кандидатами: когда единичного сильного матча нет (частый
|
||||
триггер ревью — «несколько кандидатов»), из бота нельзя быстро закрепить нужный,
|
||||
приходится открывать веб. Ядро для этого уже готово: команда
|
||||
`worker.ChooseCandidate` пиннит кандидата как источник (ручной матч), не
|
||||
запуская авто-раскладку.
|
||||
|
||||
## What Changes
|
||||
|
||||
- **Карточка подтверждения (review)** в боте, когда для распознавания есть
|
||||
сохранённые кандидаты метабазы, показывает кнопку выбора базы. По ней карточка
|
||||
двухшагово (как подтверждение удаления) разворачивает **список кандидатов
|
||||
inline-кнопками** — быстрый выбор из готового короткого списка.
|
||||
- **Выбор кандидата** кнопкой вызывает `ChooseCandidate` (пиннинг источника,
|
||||
ручной матч) и обновляет карточку на месте. Активный (уже выбранный) кандидат
|
||||
помечается в списке. Кнопка «← Назад» возвращает карточку без изменений.
|
||||
- **Веб остаётся точкой точных правок** (ввод id/URL вручную, «без базы»,
|
||||
предпросмотр путей): бот — только быстрый выбор из уже найденных кандидатов.
|
||||
- **Безопасность границы:** id кандидата приходит в `callback_data` — недоверенный
|
||||
вход; перед доменным вызовом он валидируется как ULID (`ident.Parse`), как это
|
||||
уже делает веб-транспорт. Выбор кандидата — ручной матч, авто-раскладку не
|
||||
запускает (нужно явное «Применить»), инвариант авто-раскладки не двигается.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
Нет.
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `notifications`: добавляется требование к **поверхности** бота — предлагать
|
||||
выбор среди сохранённых кандидатов метабазы в карточке подтверждения
|
||||
(inline-кнопки, двухшаговое разворачивание), с валидацией id кандидата из
|
||||
`callback_data` на границе и без авто-раскладки. Доменная механика выбора
|
||||
(`ChooseCandidate`, пиннинг источника) уже покрыта capability `review` — здесь
|
||||
не дублируется.
|
||||
- `review`: MODIFIED требование «Разделение труда транспортов в ревью» — быстрый
|
||||
выбор источника из готового списка кандидатов теперь и Telegram-действие; точный
|
||||
ручной ввод id/URL и «без базы» остаются за вебом. Снимает межспековое
|
||||
расхождение (прежде выбор источника целиком относился к веб-точным-правкам).
|
||||
|
||||
## Impact
|
||||
|
||||
- **Спеки:** дельта `notifications` — ADDED «Выбор кандидата метабазы из карточки
|
||||
подтверждения бота»; дельта `review` — MODIFIED «Разделение труда транспортов в
|
||||
ревью» (быстрый выбор кандидата — Telegram-действие).
|
||||
- **Код:** `internal/tgbot/bot.go` (интерфейс `Reviewer` + `ChooseCandidate`;
|
||||
callback-и списка/выбора/назад), `internal/tgbot/render.go` (кнопка выбора базы
|
||||
в `reviewKeyboard`, клавиатура списка кандидатов).
|
||||
- **Тесты:** `internal/tgbot` — карточка с кандидатами содержит кнопку выбора;
|
||||
разворачивание даёт список; выбор зовёт `ChooseCandidate` с валидным id;
|
||||
невалидный id из callback отклоняется.
|
||||
- **Миграции БД:** нет (кандидаты уже в БД — таблица `metadata_candidate`).
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Выбор кандидата метабазы из карточки подтверждения бота
|
||||
|
||||
Карточка подтверждения бота SHALL предлагать выбрать источник из сохранённых
|
||||
кандидатов метабазы inline-кнопками, когда для распознавания загрузки есть хотя
|
||||
бы один такой кандидат (типичный триггер ревью — несколько подходящих записей без
|
||||
единичного сильного матча), а не молча оставлять загрузку с первым/лучшим или без
|
||||
базы. Когда сохранённых кандидатов нет, карточка кнопку выбора базы показывать
|
||||
SHALL NOT. Выбор SHALL быть двухшаговым: кнопка выбора базы разворачивает список
|
||||
кандидатов (по кнопке на кандидата, выбранный помечен), а нажатие кандидата
|
||||
пиннит его как источник и обновляет карточку. Отдельная кнопка SHALL возвращать
|
||||
карточку без изменений.
|
||||
|
||||
Выбор кандидата из бота — **ручной матч** (пиннинг источника): он SHALL NOT
|
||||
запускать авто-раскладку; раскладка выполняется отдельным подтверждением
|
||||
(«Применить»). Бот SHALL оставаться быстрым выбором из готового короткого списка
|
||||
кандидатов, тогда как точные правки (ручной ввод id/URL, «без базы») — на веб-
|
||||
поверхности.
|
||||
|
||||
Id кандидата приходит в `callback_data` и является недоверенным входом: перед
|
||||
доменным вызовом выбора система MUST валидировать его как ULID на границе
|
||||
транспорта; невалидный id MUST отклоняться без изменения состояния загрузки.
|
||||
Текст inline-кнопок Telegram не интерпретируется как разметка, поэтому название
|
||||
кандидата в подписи кнопки инъекции разметки не создаёт.
|
||||
|
||||
#### Scenario: Карточка с кандидатами предлагает выбор базы
|
||||
|
||||
- **GIVEN** загрузка в `review`, для распознавания которой сохранены кандидаты метабазы
|
||||
- **WHEN** бот рендерит карточку подтверждения
|
||||
- **THEN** карточка содержит кнопку выбора базы, ведущую к списку кандидатов inline-кнопками
|
||||
|
||||
#### Scenario: Выбор кандидата пиннит источник без авто-раскладки
|
||||
|
||||
- **GIVEN** развёрнутый в карточке список кандидатов метабазы
|
||||
- **WHEN** пользователь нажимает кнопку кандидата
|
||||
- **THEN** этот кандидат закрепляется как источник (ручной матч), карточка
|
||||
обновляется на месте, а авто-раскладка не запускается — для раскладки нужно
|
||||
явное «Применить»
|
||||
|
||||
#### Scenario: Id кандидата из callback валидируется на границе
|
||||
|
||||
- **GIVEN** callback выбора кандидата, где значение id кандидата недоверенное/невалидное
|
||||
- **WHEN** бот обрабатывает callback
|
||||
- **THEN** id кандидата валидируется как ULID до доменного вызова, а невалидное
|
||||
значение отклоняется без изменения состояния загрузки
|
||||
|
||||
#### Scenario: Возврат из списка не меняет состояние
|
||||
|
||||
- **GIVEN** развёрнутый в карточке список кандидатов
|
||||
- **WHEN** пользователь нажимает кнопку возврата
|
||||
- **THEN** бот возвращает исходную карточку подтверждения, не меняя выбранный источник
|
||||
|
||||
#### Scenario: Без кандидатов кнопки выбора базы нет
|
||||
|
||||
- **GIVEN** загрузка в `review`, для распознавания которой кандидаты метабазы не сохранены
|
||||
- **WHEN** бот рендерит карточку подтверждения
|
||||
- **THEN** карточка не содержит кнопку выбора базы
|
||||
@@ -0,0 +1,24 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Разделение труда транспортов в ревью
|
||||
|
||||
Состояние ревью SHALL быть единым (в SQLite) для всех транспортов. Веб-UI SHALL
|
||||
быть поверхностью точных правок (маппинг файлов, ручной ввод/выбор источника по
|
||||
id или URL, «без базы», предпросмотр). Telegram SHALL давать быстрые действия
|
||||
(одобрить, подсказать, переключить тип, **быстрый выбор источника из готового
|
||||
списка кандидатов метабазы**, «Позже»/«Отклонить») и эскалацию в веб deep-link'ом
|
||||
на ту же страницу; точечные правки, не помещающиеся в чат (ручной ввод id/URL,
|
||||
маппинг файлов), SHALL делаться в вебе.
|
||||
|
||||
#### Scenario: Эскалация из Telegram в веб
|
||||
|
||||
- **GIVEN** загрузка в `review`, требующая точечного маппинга файлов
|
||||
- **WHEN** пользователь в Telegram выбирает «В вебе»
|
||||
- **THEN** бот даёт deep-link на страницу ревью той же загрузки
|
||||
|
||||
#### Scenario: Быстрый выбор кандидата в Telegram, точный ввод — в вебе
|
||||
|
||||
- **GIVEN** загрузка в `review` с сохранёнными кандидатами метабазы
|
||||
- **WHEN** пользователь выбирает кандидата inline-кнопкой в Telegram
|
||||
- **THEN** кандидат закрепляется как источник (тот же единый выбор источника, что
|
||||
и в вебе), а ручной ввод id/URL и «без базы» остаются точными правками веба
|
||||
@@ -0,0 +1,39 @@
|
||||
## 1. Интерфейс транспорта
|
||||
|
||||
- [x] 1.1 `internal/tgbot/bot.go`: добавить в интерфейс `Reviewer` метод
|
||||
`ChooseCandidate(ctx context.Context, id, candidateID string) error`.
|
||||
- [x] 1.2 `internal/tgbot/bot_test.go`: дополнить `fakeReviewer` методом
|
||||
`ChooseCandidate` (запоминает выбор для проверки).
|
||||
|
||||
## 2. Клавиатуры (render.go)
|
||||
|
||||
- [x] 2.1 `reviewKeyboard`: когда `len(rd.Candidates) > 0`, добавить кнопку
|
||||
«🗂 База (N)» с callback `sources:<id>`.
|
||||
- [x] 2.2 Новая `candidatesKeyboard(rd)`: по кнопке на кандидата (дедуп по
|
||||
`provider:provider_id`, потолок), текст `provider · Title (Year)`, у `Chosen` —
|
||||
префикс «✓ », callback `pick:<id>:<candidateID>`; нижняя кнопка «← Назад» с
|
||||
callback `srcback:<id>`.
|
||||
|
||||
## 3. Обработка callback (bot.go)
|
||||
|
||||
- [x] 3.1 `handleCallback`: ветки `sources` (answer + `editMarkup`
|
||||
списком кандидатов, перечитав `ReviewData`; домен не трогаем) и `srcback`
|
||||
(answer «Отменено» + `refreshCard`).
|
||||
- [x] 3.2 Ветка `pick`: валидировать `val` (id кандидата) через `ident.Parse`
|
||||
(невалидный → answer «Кнопка устарела…», состояние не меняем), затем
|
||||
`reviewer.ChooseCandidate(ctx, id, candidateID)`; успех → answer «Источник
|
||||
выбран» + `refreshCard`; ошибка — общая ветка ошибки.
|
||||
|
||||
## 4. Тесты
|
||||
|
||||
- [x] 4.1 `internal/tgbot`: карточка ревью с кандидатами содержит кнопку
|
||||
`sources:<id>`; без кандидатов — не содержит.
|
||||
- [x] 4.2 `sources` разворачивает клавиатуру с кнопками `pick:<id>:<candID>` и
|
||||
«← Назад»; выбранный кандидат помечен «✓ ».
|
||||
- [x] 4.3 `pick` с валидным id зовёт `ChooseCandidate` с этим id и обновляет
|
||||
карточку; `pick` с невалидным id — не зовёт домен.
|
||||
|
||||
## 5. Спека и проверки
|
||||
|
||||
- [x] 5.1 `openspec validate --strict telegram-vybor-nahodok`.
|
||||
- [x] 5.2 `task test` и `task lint` — зелёные.
|
||||
@@ -167,3 +167,60 @@ MUST экранировать их перед вставкой в размече
|
||||
- **THEN** показываются эффективные provider и id (как на веб-странице загрузки),
|
||||
а не значения сырого распознавания
|
||||
|
||||
### Requirement: Выбор кандидата метабазы из карточки подтверждения бота
|
||||
|
||||
Карточка подтверждения бота SHALL предлагать выбрать источник из сохранённых
|
||||
кандидатов метабазы inline-кнопками, когда для распознавания загрузки есть хотя
|
||||
бы один такой кандидат (типичный триггер ревью — несколько подходящих записей без
|
||||
единичного сильного матча), а не молча оставлять загрузку с первым/лучшим или без
|
||||
базы. Когда сохранённых кандидатов нет, карточка кнопку выбора базы показывать
|
||||
SHALL NOT. Выбор SHALL быть двухшаговым: кнопка выбора базы разворачивает список
|
||||
кандидатов (по кнопке на кандидата, выбранный помечен), а нажатие кандидата
|
||||
пиннит его как источник и обновляет карточку. Отдельная кнопка SHALL возвращать
|
||||
карточку без изменений.
|
||||
|
||||
Выбор кандидата из бота — **ручной матч** (пиннинг источника): он SHALL NOT
|
||||
запускать авто-раскладку; раскладка выполняется отдельным подтверждением
|
||||
(«Применить»). Бот SHALL оставаться быстрым выбором из готового короткого списка
|
||||
кандидатов, тогда как точные правки (ручной ввод id/URL, «без базы») — на веб-
|
||||
поверхности.
|
||||
|
||||
Id кандидата приходит в `callback_data` и является недоверенным входом: перед
|
||||
доменным вызовом выбора система MUST валидировать его как ULID на границе
|
||||
транспорта; невалидный id MUST отклоняться без изменения состояния загрузки.
|
||||
Текст inline-кнопок Telegram не интерпретируется как разметка, поэтому название
|
||||
кандидата в подписи кнопки инъекции разметки не создаёт.
|
||||
|
||||
#### Scenario: Карточка с кандидатами предлагает выбор базы
|
||||
|
||||
- **GIVEN** загрузка в `review`, для распознавания которой сохранены кандидаты метабазы
|
||||
- **WHEN** бот рендерит карточку подтверждения
|
||||
- **THEN** карточка содержит кнопку выбора базы, ведущую к списку кандидатов inline-кнопками
|
||||
|
||||
#### Scenario: Выбор кандидата пиннит источник без авто-раскладки
|
||||
|
||||
- **GIVEN** развёрнутый в карточке список кандидатов метабазы
|
||||
- **WHEN** пользователь нажимает кнопку кандидата
|
||||
- **THEN** этот кандидат закрепляется как источник (ручной матч), карточка
|
||||
обновляется на месте, а авто-раскладка не запускается — для раскладки нужно
|
||||
явное «Применить»
|
||||
|
||||
#### Scenario: Id кандидата из callback валидируется на границе
|
||||
|
||||
- **GIVEN** callback выбора кандидата, где значение id кандидата недоверенное/невалидное
|
||||
- **WHEN** бот обрабатывает callback
|
||||
- **THEN** id кандидата валидируется как ULID до доменного вызова, а невалидное
|
||||
значение отклоняется без изменения состояния загрузки
|
||||
|
||||
#### Scenario: Возврат из списка не меняет состояние
|
||||
|
||||
- **GIVEN** развёрнутый в карточке список кандидатов
|
||||
- **WHEN** пользователь нажимает кнопку возврата
|
||||
- **THEN** бот возвращает исходную карточку подтверждения, не меняя выбранный источник
|
||||
|
||||
#### Scenario: Без кандидатов кнопки выбора базы нет
|
||||
|
||||
- **GIVEN** загрузка в `review`, для распознавания которой кандидаты метабазы не сохранены
|
||||
- **WHEN** бот рендерит карточку подтверждения
|
||||
- **THEN** карточка не содержит кнопку выбора базы
|
||||
|
||||
|
||||
@@ -329,10 +329,12 @@ SHALL относиться именно к активному источнику
|
||||
### Requirement: Разделение труда транспортов в ревью
|
||||
|
||||
Состояние ревью SHALL быть единым (в SQLite) для всех транспортов. Веб-UI SHALL
|
||||
быть поверхностью точных правок (маппинг файлов, выбор/ввод источника,
|
||||
предпросмотр). Telegram SHALL давать быстрые действия (одобрить, подсказать,
|
||||
переключить тип, «Позже»/«Отклонить») и эскалацию в веб deep-link'ом на ту же
|
||||
страницу; точечные правки, не помещающиеся в чат, SHALL делаться в вебе.
|
||||
быть поверхностью точных правок (маппинг файлов, ручной ввод/выбор источника по
|
||||
id или URL, «без базы», предпросмотр). Telegram SHALL давать быстрые действия
|
||||
(одобрить, подсказать, переключить тип, **быстрый выбор источника из готового
|
||||
списка кандидатов метабазы**, «Позже»/«Отклонить») и эскалацию в веб deep-link'ом
|
||||
на ту же страницу; точечные правки, не помещающиеся в чат (ручной ввод id/URL,
|
||||
маппинг файлов), SHALL делаться в вебе.
|
||||
|
||||
#### Scenario: Эскалация из Telegram в веб
|
||||
|
||||
@@ -340,6 +342,13 @@ SHALL относиться именно к активному источнику
|
||||
- **WHEN** пользователь в Telegram выбирает «В вебе»
|
||||
- **THEN** бот даёт deep-link на страницу ревью той же загрузки
|
||||
|
||||
#### Scenario: Быстрый выбор кандидата в Telegram, точный ввод — в вебе
|
||||
|
||||
- **GIVEN** загрузка в `review` с сохранёнными кандидатами метабазы
|
||||
- **WHEN** пользователь выбирает кандидата inline-кнопкой в Telegram
|
||||
- **THEN** кандидат закрепляется как источник (тот же единый выбор источника, что
|
||||
и в вебе), а ручной ввод id/URL и «без базы» остаются точными правками веба
|
||||
|
||||
### Requirement: Петлевые действия ревью обновляют экран на месте
|
||||
|
||||
Петлевые действия распознавания на экране ревью — **Распознать заново** (`rerecognize`) и **Уточнить** (`refine`) — SHALL выполняться htmx-запросом и обновлять тело экрана ревью на месте (partial swap), без полной перезагрузки страницы и без сброса позиции прокрутки. Поскольку эти действия асинхронны (переводят загрузку в `recognizing`, распознавание доделывает воркер), своп SHALL отражать актуальное состояние — состояние `recognizing` с индикацией «идёт распознавание», а не мгновенно готовый план. Накопленные подсказки и ручные override MUST переживать перераспознавание. Это согласуется с уже действующим частичным свопом при смене выбранного источника (см. «Единый список источников совпадения на ревью»).
|
||||
|
||||
Reference in New Issue
Block a user