From f75d6b1f9119c15fecaf24184fdaf956c75db3e9 Mon Sep 17 00:00:00 2001 From: Anton Vakhrushev Date: Sat, 18 Jul 2026 16:02:47 +0300 Subject: [PATCH] =?UTF-8?q?tgbot:=20=D0=B2=D1=8B=D0=B1=D0=BE=D1=80=20?= =?UTF-8?q?=D0=BA=D0=B0=D0=BD=D0=B4=D0=B8=D0=B4=D0=B0=D1=82=D0=B0=20=D0=BC?= =?UTF-8?q?=D0=B5=D1=82=D0=B0=D0=B1=D0=B0=D0=B7=D1=8B=20inline-=D0=BA?= =?UTF-8?q?=D0=BD=D0=BE=D0=BF=D0=BA=D0=B0=D0=BC=D0=B8=20=D0=B2=20=D0=BA?= =?UTF-8?q?=D0=B0=D1=80=D1=82=D0=BE=D1=87=D0=BA=D0=B5=20=D1=80=D0=B5=D0=B2?= =?UTF-8?q?=D1=8C=D1=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Когда для распознавания сохранены кандидаты метабазы, карточка подтверждения бота показывает кнопку «🗂 База (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) --- docs/backlog/README.md | 1 - docs/backlog/telegram-vybor-nahodok.md | 7 - internal/tgbot/bot.go | 28 ++++ internal/tgbot/bot_test.go | 129 ++++++++++++++++++ internal/tgbot/render.go | 66 +++++++++ .../design.md | 68 +++++++++ .../proposal.md | 59 ++++++++ .../specs/notifications/spec.md | 58 ++++++++ .../specs/review/spec.md | 24 ++++ .../tasks.md | 39 ++++++ openspec/specs/notifications/spec.md | 57 ++++++++ openspec/specs/review/spec.md | 17 ++- 12 files changed, 541 insertions(+), 12 deletions(-) delete mode 100644 docs/backlog/telegram-vybor-nahodok.md create mode 100644 openspec/changes/archive/2026-07-18-telegram-vybor-nahodok/design.md create mode 100644 openspec/changes/archive/2026-07-18-telegram-vybor-nahodok/proposal.md create mode 100644 openspec/changes/archive/2026-07-18-telegram-vybor-nahodok/specs/notifications/spec.md create mode 100644 openspec/changes/archive/2026-07-18-telegram-vybor-nahodok/specs/review/spec.md create mode 100644 openspec/changes/archive/2026-07-18-telegram-vybor-nahodok/tasks.md diff --git a/docs/backlog/README.md b/docs/backlog/README.md index ddc071e..5dd08e0 100644 --- a/docs/backlog/README.md +++ b/docs/backlog/README.md @@ -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 копирует файл, дублируя место на диске diff --git a/docs/backlog/telegram-vybor-nahodok.md b/docs/backlog/telegram-vybor-nahodok.md deleted file mode 100644 index 5f7db0b..0000000 --- a/docs/backlog/telegram-vybor-nahodok.md +++ /dev/null @@ -1,7 +0,0 @@ -# Выбор из нескольких находок метабазы в Telegram - -**Приоритет:** низкий - -Когда распознавание даёт несколько подходящих кандидатов в метабазе, предлагать их в Telegram списком (кнопки) для ручного выбора, а не молча брать первый/лучший. Веб остаётся точкой точных правок (полный выбор источника уже реализован), бот — быстрый выбор из готового короткого списка. - -Связано: specs/review-ux.md (боты — быстрые действия, веб — точные правки), specs/recognition.md (кандидаты матча). diff --git a/internal/tgbot/bot.go b/internal/tgbot/bot.go index d912806..02e5660 100644 --- a/internal/tgbot/bot.go +++ b/internal/tgbot/bot.go @@ -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 diff --git a/internal/tgbot/bot_test.go b/internal/tgbot/bot_test.go index 76326ca..d57a737 100644 --- a/internal/tgbot/bot_test.go +++ b/internal/tgbot/bot_test.go @@ -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::) + «Назад»; +// выбранный помечен галочкой, дубли по 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}) diff --git a/internal/tgbot/render.go b/internal/tgbot/render.go index 3f63f07..0b9df88 100644 --- a/internal/tgbot/render.go +++ b/internal/tgbot/render.go @@ -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 кандидата +// валидируется как 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. diff --git a/openspec/changes/archive/2026-07-18-telegram-vybor-nahodok/design.md b/openspec/changes/archive/2026-07-18-telegram-vybor-nahodok/design.md new file mode 100644 index 0000000..4614134 --- /dev/null +++ b/openspec/changes/archive/2026-07-18-telegram-vybor-nahodok/design.md @@ -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:`. Она не трогает домен — через +`editMarkup` разворачивает клавиатуру списка кандидатов: + +- по одной кнопке на кандидата (full-width row), текст `provider · Title (Year)`, + у выбранного (`Chosen`) — префикс «✓ »; дедуп по `provider:provider_id`, потолок + кнопок (кандидатов и так ≤ `maxCandidates`=8); +- callback кандидата — `pick::`; +- нижняя кнопка «← Назад» с callback `srcback:` — возвращает карточку + (`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) и плодит сообщения. diff --git a/openspec/changes/archive/2026-07-18-telegram-vybor-nahodok/proposal.md b/openspec/changes/archive/2026-07-18-telegram-vybor-nahodok/proposal.md new file mode 100644 index 0000000..d021ee2 --- /dev/null +++ b/openspec/changes/archive/2026-07-18-telegram-vybor-nahodok/proposal.md @@ -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`). diff --git a/openspec/changes/archive/2026-07-18-telegram-vybor-nahodok/specs/notifications/spec.md b/openspec/changes/archive/2026-07-18-telegram-vybor-nahodok/specs/notifications/spec.md new file mode 100644 index 0000000..f321f96 --- /dev/null +++ b/openspec/changes/archive/2026-07-18-telegram-vybor-nahodok/specs/notifications/spec.md @@ -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** карточка не содержит кнопку выбора базы diff --git a/openspec/changes/archive/2026-07-18-telegram-vybor-nahodok/specs/review/spec.md b/openspec/changes/archive/2026-07-18-telegram-vybor-nahodok/specs/review/spec.md new file mode 100644 index 0000000..bc7dff2 --- /dev/null +++ b/openspec/changes/archive/2026-07-18-telegram-vybor-nahodok/specs/review/spec.md @@ -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 и «без базы» остаются точными правками веба diff --git a/openspec/changes/archive/2026-07-18-telegram-vybor-nahodok/tasks.md b/openspec/changes/archive/2026-07-18-telegram-vybor-nahodok/tasks.md new file mode 100644 index 0000000..da3c2e1 --- /dev/null +++ b/openspec/changes/archive/2026-07-18-telegram-vybor-nahodok/tasks.md @@ -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:`. +- [x] 2.2 Новая `candidatesKeyboard(rd)`: по кнопке на кандидата (дедуп по + `provider:provider_id`, потолок), текст `provider · Title (Year)`, у `Chosen` — + префикс «✓ », callback `pick::`; нижняя кнопка «← Назад» с + callback `srcback:`. + +## 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:`; без кандидатов — не содержит. +- [x] 4.2 `sources` разворачивает клавиатуру с кнопками `pick::` и + «← Назад»; выбранный кандидат помечен «✓ ». +- [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` — зелёные. diff --git a/openspec/specs/notifications/spec.md b/openspec/specs/notifications/spec.md index 64af3d7..d568324 100644 --- a/openspec/specs/notifications/spec.md +++ b/openspec/specs/notifications/spec.md @@ -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** карточка не содержит кнопку выбора базы + diff --git a/openspec/specs/review/spec.md b/openspec/specs/review/spec.md index 45dfa1c..ed8c8ea 100644 --- a/openspec/specs/review/spec.md +++ b/openspec/specs/review/spec.md @@ -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 переживать перераспознавание. Это согласуется с уже действующим частичным свопом при смене выбранного источника (см. «Единый список источников совпадения на ревью»).