Единое окно: полное пользовательское удаление загрузки (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