Единое окно: полное пользовательское удаление загрузки (delete)

Вторая половина «единого окна»: команда «Удалить» снимает наши библиотечные
хардлинки (гард последней копии осознанно выключен, в отличие от Undo) и сносит
раздачу с файлами из qBittorrent (deleteFiles=true) → терминальный deleted.
Доступна из done/orphaned/target_missing, идемпотентна к отсутствующей стороне;
инициатор различается через error_code=user_delete. Подтверждение обязательно:
веб — danger-секция внизу страницы (hx-confirm + details), Telegram — двухшаговый
inline-confirm. qbt.Delete + layout.Remove (unlink без ErrLastCopy, только свои
ссылки под movies/series). Граф переходов не менялся — рёбра уже были.

OpenSpec: state-reconciliation +1 требование; синк workflow.md; беклог закрыт.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
av
2026-07-10 12:17:51 +03:00
co-authored by Claude Opus 4.8
parent e68f96df9e
commit 3f1a928000
25 changed files with 893 additions and 76 deletions
+26
View File
@@ -45,6 +45,7 @@ type Reviewer interface {
Defer(ctx context.Context, id string) error
Cancel(ctx context.Context, id string) error
Retry(ctx context.Context, id string) error
Delete(ctx context.Context, id string) error
}
// Config — параметры бота.
@@ -305,6 +306,20 @@ func (b *Bot) handleCallback(ctx context.Context, cq *tgbotapi.CallbackQuery) {
case "retry":
err = b.reviewer.Retry(ctx, id)
note = "Повторяю…"
case "delete":
// Первый шаг: показываем подтверждение (необратимое действие — снос
// раздачи+файлов и хардлинков). Домен не трогаем, меняем клавиатуру.
b.answer(cq.ID, "")
b.editMarkup(chatID, msgID, b.deleteConfirmKeyboard(id))
return
case "delete_cancel":
// Отмена подтверждения — возвращаем обычную карточку.
b.answer(cq.ID, "Отменено")
b.refreshCard(ctx, chatID, msgID, id)
return
case "delete_confirm":
err = b.reviewer.Delete(ctx, id)
note = "Удаляю…"
case "type":
err = b.reviewer.SetType(ctx, id, val)
note = "Меняю тип…"
@@ -397,6 +412,17 @@ func (b *Bot) answer(callbackID, text string) {
}
}
// editMarkup подменяет только клавиатуру сообщения на месте (текст не трогаем).
// Используется для шага подтверждения удаления.
func (b *Bot) editMarkup(chatID int64, msgID int, kb *tgbotapi.InlineKeyboardMarkup) {
if kb == nil {
return
}
if _, err := b.api.Send(tgbotapi.NewEditMessageReplyMarkup(chatID, msgID, *kb)); err != nil {
b.log.Warn("telegram edit markup failed", "chat_id", chatID, "error", err)
}
}
func (b *Bot) setPending(chatID int64, id string) {
b.mu.Lock()
b.pending[chatID] = id
+5
View File
@@ -69,6 +69,7 @@ type fakeReviewer struct {
deferred []string
canceled []string
retried []string
deleted []string
}
func (f *fakeReviewer) ReviewData(context.Context, string) (*worker.ReviewData, error) {
@@ -104,6 +105,10 @@ func (f *fakeReviewer) Retry(_ context.Context, id string) error {
f.retried = append(f.retried, id)
return nil
}
func (f *fakeReviewer) Delete(_ context.Context, id string) error {
f.deleted = append(f.deleted, id)
return nil
}
// tid — валидный lowercase-ULID (callback-data валидируется как ULID).
const tid = "01arz3ndektsv4rrffq69g5fav"
+30 -3
View File
@@ -24,15 +24,19 @@ func (b *Bot) renderCard(rd *worker.ReviewData) (string, *tgbotapi.InlineKeyboar
case store.StateLinking:
return "⏳ Раскладываю #" + id + "…", nil
case store.StateDone:
return b.renderDone(rd), b.webOnly(id)
return b.renderDone(rd), b.deletableKeyboard(id)
default:
text := fmt.Sprintf("Задача #%s — %s.", id, state)
if msg := rd.Download.ErrorMsg.String; msg != "" {
text += "\n" + msg
}
// failed/stuck — даём кнопку повтора; остальное только «в вебе».
if state == store.StateFailed || state == store.StateStuck {
switch state {
case store.StateFailed, store.StateStuck:
// failed/stuck — даём кнопку повтора.
return text, b.retryKeyboard(id)
case store.StateOrphaned, store.StateTargetMissing:
// Рассинхрон — можно подчистить остатки полным удалением.
return text, b.deletableKeyboard(id)
}
return text, b.webOnly(id)
}
@@ -164,6 +168,29 @@ func (b *Bot) retryKeyboard(id string) *tgbotapi.InlineKeyboardMarkup {
return &kb
}
// deletableKeyboard — клавиатура состояний, откуда доступно полное удаление
// (done/orphaned/target_missing): ссылка в веб (опц.) + «Удалить». Само удаление
// двухшаговое — кнопка ведёт на подтверждение (deleteConfirmKeyboard).
func (b *Bot) deletableKeyboard(id string) *tgbotapi.InlineKeyboardMarkup {
var row []tgbotapi.InlineKeyboardButton
if url := b.reviewURL(id); url != "" {
row = append(row, tgbotapi.NewInlineKeyboardButtonURL("🌐 В вебе", url))
}
row = append(row, tgbotapi.NewInlineKeyboardButtonData("🗑 Удалить", "delete:"+id))
kb := tgbotapi.NewInlineKeyboardMarkup(tgbotapi.NewInlineKeyboardRow(row...))
return &kb
}
// deleteConfirmKeyboard — шаг подтверждения удаления: необратимое снятие
// раздачи+файлов и хардлинков. Явное «Да» отделено от отмены.
func (b *Bot) deleteConfirmKeyboard(id string) *tgbotapi.InlineKeyboardMarkup {
kb := tgbotapi.NewInlineKeyboardMarkup(tgbotapi.NewInlineKeyboardRow(
tgbotapi.NewInlineKeyboardButtonData("❗ Да, удалить", "delete_confirm:"+id),
tgbotapi.NewInlineKeyboardButtonData("Отмена", "delete_cancel:"+id),
))
return &kb
}
func (b *Bot) webOnly(id string) *tgbotapi.InlineKeyboardMarkup {
url := b.reviewURL(id)
if url == "" {