新增功能: 重名文件时选择保存策略 - 重命名,覆盖,跳过#204
Conversation
feat: implement conflict resolution strategy for file uploads
There was a problem hiding this comment.
Pull request overview
Note
Copilot was unable to run its full agentic suite in this review.
This PR adds duplicate-name handling for Telegram file downloads by prompting the user to choose a save strategy when a target filename already exists.
Changes:
- Adds conflict-strategy selection (
rename,overwrite,skip) to the add-task flow and persists the choice through callback data. - Propagates overwrite behavior into storage backends and adds skipped-file reporting to batch task messages/progress.
- Adds new i18n strings and context keys needed for the conflict prompt and result summaries.
Reviewed changes
Copilot reviewed 17 out of 17 changed files in this pull request and generated 14 comments.
Show a summary per file
| File | Description |
|---|---|
| storage/webdav/webdav.go | Updates WebDAV save logic to honor overwrite mode and refactors existence checks. |
| storage/s3/s3.go | Updates S3 save logic to skip renaming when overwrite is selected. |
| storage/rclone/rclone.go | Gates the existing rename loop behind the overwrite flag for rclone storage. |
| storage/minio/client.go | Adds overwrite-aware save behavior and a helper for object existence checks. |
| storage/local/local.go | Adds overwrite-aware local save behavior and refactors path existence checks. |
| storage/context.go | Adds context helpers for setting and reading overwrite mode. |
| storage/alist/alist.go | Adds overwrite-aware save behavior and refactors existence checks for Alist. |
| pkg/tcbdata/data.go | Extends callback payload data with conflict-strategy and selected directory path. |
| pkg/enums/ctxkey/context_key_enum.go | Registers the new overwrite-existing context key. |
| pkg/enums/ctxkey/context_key.go | Updates enum source definition for the new context key. |
| core/tasks/batchtfile/progress.go | Adds skipped-file reporting to batch progress completion output. |
| common/i18n/locale/zh-Hans.yaml | Adds Chinese strings for conflict prompts/buttons and skipped-file notices. |
| common/i18n/locale/en.yaml | Adds English strings for conflict prompts/buttons and skipped-file notices. |
| common/i18n/i18nk/keys.go | Adds i18n key constants for the new conflict-related messages. |
| client/bot/handlers/utils/shortcut/tftask.go | Adds conflict prompting, skip/overwrite handling, and skipped-file summaries in task creation helpers. |
| client/bot/handlers/utils/msgelem/storage.go | Adds inline keyboard generation for conflict-strategy selection. |
| client/bot/handlers/add_task.go | Adds conflict pre-checking in the add-task callback flow before creating TG file tasks. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| if overwrite, _ := ctx.Value(ctxkey.OverwriteExisting).(bool); !overwrite { | ||
| for i := 1; w.existsPath(ctx, candidate); i++ { | ||
| candidate = fmt.Sprintf("%s_%d%s", base, i, ext) |
| return w.existsPath(ctx, w.JoinStoragePath(storagePath)) | ||
| } | ||
|
|
||
| func (w *Webdav) existsPath(ctx context.Context, storagePath string) bool { | ||
| exists, err := w.client.Exists(ctx, storagePath) |
| if overwrite, _ := ctx.Value(ctxkey.OverwriteExisting).(bool); !overwrite { | ||
| // Unique filename | ||
| for i := 1; m.existsKey(ctx, candidate); i++ { | ||
| candidate = fmt.Sprintf("%s_%d%s", base, i, ext) |
| return m.existsKey(ctx, m.JoinStoragePath(storagePath)) | ||
| } | ||
|
|
||
| func (m *S3) existsKey(ctx context.Context, key string) bool { | ||
| return m.client.Exists(ctx, key) |
| if overwrite, _ := ctx.Value(ctxkey.OverwriteExisting).(bool); !overwrite { | ||
| for i := 1; m.existsObject(ctx, candidate); i++ { | ||
| candidate = fmt.Sprintf("%s_%d%s", base, i, ext) |
| conflicts, err := findTGFileConflicts(ctx, userID, selectedStorage, dirPath, data.Files) | ||
| if err != nil { | ||
| ctx.AnswerCallback(msgelem.AlertCallbackAnswer(queryID, err.Error())) | ||
| return dispatcher.EndGroups | ||
| } | ||
| if len(conflicts) > 0 && data.ConflictStrategy == "" { | ||
| markup, err := msgelem.BuildConflictStrategyMarkup(data) | ||
| if err != nil { | ||
| ctx.AnswerCallback(msgelem.AlertCallbackAnswer(queryID, i18n.T(i18nk.BotMsgCommonErrorBuildStorageSelectKeyboardFailed, map[string]any{ | ||
| "Error": err.Error(), | ||
| }))) | ||
| return dispatcher.EndGroups | ||
| } | ||
| ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{ | ||
| ID: update.CallbackQuery.GetMsgID(), | ||
| Message: i18n.T(i18nk.BotMsgCommonPromptSelectConflictStrategy, map[string]any{"Files": formatConflictFiles(conflicts)}), | ||
| ReplyMarkup: markup, | ||
| }) | ||
| return dispatcher.EndGroups |
| return shortcut.CreateAndAddBatchTGFileTaskWithEdit(ctx, userID, selectedStorage, dirPath, data.Files, msgID, data.ConflictStrategy) | ||
| } | ||
| return shortcut.CreateAndAddTGFileTaskWithEdit(ctx, userID, selectedStorage, dirPath, data.Files[0], msgID) | ||
| return shortcut.CreateAndAddTGFileTaskWithEdit(ctx, userID, selectedStorage, dirPath, data.Files[0], msgID, data.ConflictStrategy) |
| if len(conflicts) > 0 && data.ConflictStrategy == "" { | ||
| markup, err := msgelem.BuildConflictStrategyMarkup(data) | ||
| if err != nil { | ||
| ctx.AnswerCallback(msgelem.AlertCallbackAnswer(queryID, i18n.T(i18nk.BotMsgCommonErrorBuildStorageSelectKeyboardFailed, map[string]any{ |
| return i18n.T(i18nk.BotMsgCommonInfoBatchTasksAddedWithSkipped, map[string]any{ | ||
| "Count": count, | ||
| "Skipped": strings.Join(skipped, "\n"), | ||
| }) |
| return styling.Plain("\n\n" + i18n.T(i18nk.BotMsgCommonInfoConflictFilesSkipped, map[string]any{ | ||
| "Skipped": strings.Join(p.skippedFiles, "\n"), | ||
| })) |
|
hi, 感谢贡献. 该功能在本地已经测试过了吗? 有没有增加把某个策略设为默认的选项呢? (比如某些频道的几乎所有文件的文件名完全一样, 保存它们的时候如果要一一选择会不太方便) |
| return nil, err | ||
| } | ||
| for _, file := range afiles { | ||
| storagePath := path.Join(dirPath, albumDir, file.Name()) |
There was a problem hiding this comment.
这里用 dirPath 而不是 fileDirPath 的话逻辑正确吗
| return dispatcher.EndGroups | ||
| } | ||
|
|
||
| func effectiveUserConflictStrategy(user *database.User) string { |
There was a problem hiding this comment.
这个函数和 client/bot/handlers/utils/shortcut/tftask.go 中的 userConflictStrategy 完全一样, 建议还是复用一下
| if fileStor.Exists(ctx, storPath) { | ||
| if strategy == tcbdata.ConflictStrategyAsk { | ||
| conflicts = append(conflicts, fmt.Sprintf("[%s]:%s", fileStor.Name(), storPath)) | ||
| } | ||
| } | ||
| if strategy == tcbdata.ConflictStrategySkip && fileStor.Exists(ctx, storPath) { |
There was a problem hiding this comment.
这里的俩 fileStor.Exists 建议合并为一个, 因为对于远端存储来说 Exists 检查有网络开销
| return context.WithValue(ctx, ctxkey.OverwriteExisting, true) | ||
| } | ||
|
|
||
| func ShouldOverwrite(ctx context.Context) bool { |
There was a problem hiding this comment.
ShouldOverwrite 放在这里的话, 由于循环依赖, 也无法被内部的 storage 实现调用, 鉴于所有 storage 内部都自己做了 if overwrite, _ := ctx.Value(ctxkey.OverwriteExisting).(bool); !overwrite... 的处理, 这里的函数删了即可
| conflicts, err := findTGFileConflicts(ctx, userID, selectedStorage, dirPath, data.Files) | ||
| if err != nil { | ||
| ctx.AnswerCallback(msgelem.AlertCallbackAnswer(queryID, err.Error())) | ||
| return dispatcher.EndGroups | ||
| } | ||
| if len(conflicts) > 0 && strategy == tcbdata.ConflictStrategyAsk { |
There was a problem hiding this comment.
这里是不是应该先判断策略是否是 ask, 再做 findTGFileConflicts , 避免不必要的检查
|
@Rain-kl 不好意思前段时间忙, 有空的话可以看一下 review 吗 |
|
@krau 感谢建议, 本次提交对代码进行了一定的质量优化 add_task.go 去掉了提前冲突扫描逻辑, 只把用户选择的冲突策略传给后续任务构建流程, 避免在策略不是“每次询问”时做不必要的远端 Exists 检查 |


bot 下载文件前先检查文件名是否存在重复, 如果重复则要求用户选择保存策略
重命名: 现有策略-重命名文件末尾加_1
覆盖: 覆盖文件
跳过: 不处理