Skip to content

Commit f92c43b

Browse files
krauCopilot
andauthored
feat: support import files from storages to telegram (#183)
* feat: add import command and batch import functionality - Implemented the `/import` command to allow users to import files from storage to Telegram. - Added support for listing files in storage and filtering based on regex patterns. - Created a batch import task to handle multiple file uploads concurrently. - Introduced progress tracking for batch imports, providing real-time updates to users. - Enhanced storage interfaces to support file listing and reading capabilities. - Updated localization files for the new import command and its usage instructions. - Added utility functions for file size formatting and speed calculation. - Refactored Telegram storage handling to support reading from non-seekable streams. * feat: add i18n for import command * feat: implement ListFiles and OpenFile methods for WebDAV and Alist storage * Update common/i18n/locale/zh-Hans.yaml Co-authored-by: Copilot <[email protected]> * Update core/tasks/batchimport/progress.go Co-authored-by: Copilot <[email protected]> * Update core/tasks/batchimport/execute.go Co-authored-by: Copilot <[email protected]> * Update storage/alist/alist.go Co-authored-by: Copilot <[email protected]> * Update common/i18n/locale/en.yaml Co-authored-by: Copilot <[email protected]> * Update pkg/storagetypes/fileinfo.go Co-authored-by: Copilot <[email protected]> * Update common/i18n/locale/en.yaml Co-authored-by: Copilot <[email protected]> * Update core/tasks/batchimport/execute.go Co-authored-by: Copilot <[email protected]> * Update storage/webdav/webdav.go Co-authored-by: Copilot <[email protected]> * Update storage/telegram/telegram.go Co-authored-by: Copilot <[email protected]> * Update core/tasks/batchimport/execute.go Co-authored-by: Copilot <[email protected]> * Update storage/webdav/webdav.go Co-authored-by: Copilot <[email protected]> * fix: missing progress stats i18n * refactor: use strutil to parse args * chore: update generated code files for consistency --------- Co-authored-by: Copilot <[email protected]>
1 parent 3e20dc2 commit f92c43b

File tree

22 files changed

+1394
-70
lines changed

22 files changed

+1394
-70
lines changed

client/bot/handlers/import.go

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
package handlers
2+
3+
import (
4+
"fmt"
5+
"regexp"
6+
7+
"github.com/celestix/gotgproto/dispatcher"
8+
"github.com/celestix/gotgproto/ext"
9+
"github.com/charmbracelet/log"
10+
"github.com/gotd/td/tg"
11+
"github.com/krau/SaveAny-Bot/common/i18n"
12+
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
13+
"github.com/krau/SaveAny-Bot/common/utils/strutil"
14+
"github.com/krau/SaveAny-Bot/common/utils/tgutil"
15+
"github.com/krau/SaveAny-Bot/config"
16+
storconfig "github.com/krau/SaveAny-Bot/config/storage"
17+
"github.com/krau/SaveAny-Bot/core"
18+
"github.com/krau/SaveAny-Bot/core/tasks/batchimport"
19+
"github.com/krau/SaveAny-Bot/pkg/storagetypes"
20+
"github.com/krau/SaveAny-Bot/storage"
21+
"github.com/rs/xid"
22+
)
23+
24+
func handleImportCmd(ctx *ext.Context, update *ext.Update) error {
25+
logger := log.FromContext(ctx)
26+
args := strutil.ParseArgsRespectQuotes(update.EffectiveMessage.Text)
27+
28+
if len(args) < 3 {
29+
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgImportUsage, nil)), nil)
30+
return dispatcher.EndGroups
31+
}
32+
33+
storageName := args[1]
34+
dirPath := args[2]
35+
36+
userID := update.GetUserChat().GetID()
37+
38+
stor, err := storage.GetStorageByUserIDAndName(ctx, userID, storageName)
39+
if err != nil {
40+
logger.Errorf("Failed to get storage by user ID and name: %s", err)
41+
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgImportErrorStorageNotFound, map[string]any{
42+
"StorageName": storageName,
43+
"Error": err,
44+
})), nil)
45+
return dispatcher.EndGroups
46+
}
47+
48+
listable, ok := stor.(storage.StorageListable)
49+
if !ok {
50+
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgImportErrorStorageNotListable, map[string]any{
51+
"StorageName": storageName,
52+
})), nil)
53+
return dispatcher.EndGroups
54+
}
55+
56+
_, ok = stor.(storage.StorageReadable)
57+
if !ok {
58+
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgImportErrorStorageNotReadable, map[string]any{
59+
"StorageName": storageName,
60+
})), nil)
61+
return dispatcher.EndGroups
62+
}
63+
64+
telegramStorage, err := storage.GetTelegramStorageByUserID(ctx, userID)
65+
if err != nil {
66+
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgImportErrorNoTelegramStorage, map[string]any{
67+
"Error": err,
68+
})), nil)
69+
return dispatcher.EndGroups
70+
}
71+
72+
replied, err := ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgImportInfoFetchingFiles, nil)), nil)
73+
if err != nil {
74+
logger.Errorf("Failed to reply: %s", err)
75+
return dispatcher.EndGroups
76+
}
77+
78+
files, err := listable.ListFiles(ctx, dirPath)
79+
if err != nil {
80+
ctx.EditMessage(update.EffectiveChat().GetID(), &tg.MessagesEditMessageRequest{
81+
ID: replied.ID,
82+
Message: i18n.T(i18nk.BotMsgImportErrorListFilesFailed, map[string]any{"Error": err}),
83+
})
84+
return dispatcher.EndGroups
85+
}
86+
87+
var filter *regexp.Regexp
88+
if len(args) >= 5 {
89+
filter, err = regexp.Compile(args[4])
90+
if err != nil {
91+
ctx.EditMessage(update.EffectiveChat().GetID(), &tg.MessagesEditMessageRequest{
92+
ID: replied.ID,
93+
Message: i18n.T(i18nk.BotMsgImportErrorInvalidRegex, map[string]any{"Error": err}),
94+
})
95+
return dispatcher.EndGroups
96+
}
97+
}
98+
99+
filteredFiles := make([]storagetypes.FileInfo, 0)
100+
for _, file := range files {
101+
if file.IsDir {
102+
continue
103+
}
104+
if filter != nil && !filter.MatchString(file.Name) {
105+
continue
106+
}
107+
filteredFiles = append(filteredFiles, file)
108+
}
109+
110+
if len(filteredFiles) == 0 {
111+
ctx.EditMessage(update.EffectiveChat().GetID(), &tg.MessagesEditMessageRequest{
112+
ID: replied.ID,
113+
Message: i18n.T(i18nk.BotMsgImportErrorNoFilesToImport, nil),
114+
})
115+
return dispatcher.EndGroups
116+
}
117+
118+
// Get default chat_id from Telegram storage config
119+
targetChatID := int64(0)
120+
if telegramCfg := config.C().GetStorageByName(telegramStorage.Name()); telegramCfg != nil {
121+
if tgCfg, ok := telegramCfg.(*storconfig.TelegramStorageConfig); ok {
122+
targetChatID = tgCfg.ChatID
123+
}
124+
}
125+
126+
if len(args) >= 4 {
127+
parsedChatID, err := tgutil.ParseChatID(ctx, args[3])
128+
if err != nil {
129+
ctx.EditMessage(update.EffectiveChat().GetID(), &tg.MessagesEditMessageRequest{
130+
ID: replied.ID,
131+
Message: i18n.T(i18nk.BotMsgImportErrorInvalidChatId, map[string]any{"Error": err}),
132+
})
133+
return dispatcher.EndGroups
134+
}
135+
targetChatID = parsedChatID
136+
}
137+
138+
if targetChatID == 0 {
139+
ctx.EditMessage(update.EffectiveChat().GetID(), &tg.MessagesEditMessageRequest{
140+
ID: replied.ID,
141+
Message: i18n.T(i18nk.BotMsgImportErrorNoTargetChatId, nil),
142+
})
143+
return dispatcher.EndGroups
144+
}
145+
146+
elems := make([]batchimport.TaskElement, 0, len(filteredFiles))
147+
var totalSize int64
148+
for _, file := range filteredFiles {
149+
elem := batchimport.NewTaskElement(stor, file, telegramStorage, targetChatID)
150+
elems = append(elems, *elem)
151+
totalSize += file.Size
152+
}
153+
154+
taskID := xid.New().String()
155+
injectCtx := tgutil.ExtWithContext(ctx.Context, ctx)
156+
task := batchimport.NewBatchImportTask(
157+
taskID,
158+
injectCtx,
159+
elems,
160+
batchimport.NewProgressTracker(replied.ID, userID),
161+
true, // IgnoreErrors
162+
)
163+
164+
if err := core.AddTask(injectCtx, task); err != nil {
165+
ctx.EditMessage(update.EffectiveChat().GetID(), &tg.MessagesEditMessageRequest{
166+
ID: replied.ID,
167+
Message: i18n.T(i18nk.BotMsgImportErrorAddTaskFailed, map[string]any{"Error": err}),
168+
})
169+
return dispatcher.EndGroups
170+
}
171+
172+
ctx.EditMessage(update.EffectiveChat().GetID(), &tg.MessagesEditMessageRequest{
173+
ID: replied.ID,
174+
Message: i18n.T(i18nk.BotMsgImportInfoTaskAdded, map[string]any{
175+
"Count": len(elems),
176+
"SizeMB": fmt.Sprintf("%.2f", float64(totalSize)/(1024*1024)),
177+
"TaskID": taskID,
178+
}),
179+
})
180+
181+
return dispatcher.EndGroups
182+
}

client/bot/handlers/register.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ var CommandHandlers = []DescCommandHandler{
3131
{"dl", i18nk.BotMsgCmdDl, handleDlCmd},
3232
{"aria2dl", i18nk.BotMsgCmdAria2dl, handleAria2DlCmd},
3333
{"ytdlp", i18nk.BotMsgCmdYtdlp, handleYtdlpCmd},
34+
{"import", i18nk.BotMsgCmdImport, handleImportCmd},
3435
{"task", i18nk.BotMsgCmdTask, handleTaskCmd},
3536
{"cancel", i18nk.BotMsgCmdCancel, handleCancelCmd},
3637
{"config", i18nk.BotMsgCmdConfig, handleConfigCmd},

common/i18n/i18nk/keys.go

Lines changed: 29 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

common/i18n/locale/en.yaml

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ bot:
2929
/silent - Toggle silent mode
3030
/storage - Set default storage
3131
/save [custom filename] - Save file
32+
/import <storage_name> <dir_path> [channel_id] [filter] - Import files from storage to Telegram
3233
/dir - Manage storage directories
3334
/rule - Manage rules
3435
/config - Modify configuration
@@ -52,6 +53,7 @@ bot:
5253
dl: "Download files from given links"
5354
aria2dl: "Download files using Aria2"
5455
ytdlp: "Download video/audio using yt-dlp"
56+
import: "Import files from storage to Telegram"
5557
task: "Manage task queue"
5658
cancel: "Cancel task"
5759
watch: "Watch chats (UserBot)"
@@ -294,6 +296,20 @@ bot:
294296
info_urls_select_storage: "Found {{.Count}} links, please select storage"
295297
info_downloading: "Downloading via yt-dlp..."
296298
error_download_failed: "yt-dlp download failed: {{.Error}}"
299+
import:
300+
usage: "Usage: /import <storage_name> <dir_path> [target_chat_id] [filter]\n\nExamples:\n/import local1 /downloads\n/import MyAlist /media/photos -1001234567890\n/import MyLocal /backup \".*[.]mp4$\""
301+
error_storage_not_found: "Storage '{{.StorageName}}' not found or access denied: {{.Error}}"
302+
error_storage_not_listable: "Storage '{{.StorageName}}' does not support listing files"
303+
error_storage_not_readable: "Storage '{{.StorageName}}' does not support reading files"
304+
error_no_telegram_storage: "No Telegram storage found: {{.Error}}"
305+
info_fetching_files: "Fetching file list..."
306+
error_list_files_failed: "Failed to list files: {{.Error}}"
307+
error_invalid_regex: "Invalid regular expression: {{.Error}}"
308+
error_no_files_to_import: "No files to import in directory"
309+
error_invalid_chat_id: "Invalid Chat ID: {{.Error}}"
310+
error_no_target_chat_id: "No target channel ID specified and Telegram storage has no default chat_id configured"
311+
error_add_task_failed: "Failed to add task: {{.Error}}"
312+
info_task_added: "Added {{.Count}} files to import queue\nTotal size: {{.SizeMB}} MB\nTask ID: {{.TaskID}}"
297313
cancel:
298314
usage: "Usage: /cancel <task_id>"
299315
error_cancel_failed: "Failed to cancel task: {{.Error}}"
@@ -342,6 +358,20 @@ bot:
342358
ytdlp_done: "yt-dlp download completed and transferred ({{.Count}} files)\n"
343359
downloaded_prefix: "\nDownloaded: "
344360
current_speed_prefix: "\nCurrent speed: "
361+
import_start_prefix: "Importing: "
362+
import_progress_prefix: "Import progress: "
363+
import_uploaded_prefix: "\nUploaded: "
364+
import_speed_prefix: "\nSpeed: "
365+
import_remaining_time_prefix: "\nRemaining time: "
366+
import_processing_prefix: "\nProcessing:\n"
367+
import_processing_more: "...and {{.Count}} more files\n"
368+
import_failed_prefix: "Import failed\n"
369+
import_success_prefix: "Import completed\n"
370+
import_total_files_prefix: "\nTotal files: "
371+
import_total_size_prefix: "\nTotal size: "
372+
import_elapsed_time_prefix: "\nElapsed time: "
373+
import_avg_speed_prefix: "\nAverage speed: "
374+
import_failed_files_prefix: "\nFailed files: "
345375
syncpeers:
346376
start: "Starting to sync peers..."
347377
done: "Peer sync completed, total {{.Count}} chats synced"

common/i18n/locale/zh-Hans.yaml

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ bot:
3030
/storage - 设置默认存储位置
3131
/save [自定义文件名] - 保存文件
3232
/dl <链接1> <链接2> ... - 下载给定链接的文件
33+
/import <存储名> <目录路径> [频道ID] [过滤器] - 从存储端导入文件到 Telegram
3334
/dir - 管理存储目录
3435
/rule - 管理规则
3536
/config - 修改配置
@@ -53,6 +54,7 @@ bot:
5354
dl: "下载给定链接的文件"
5455
aria2dl: "使用 Aria2 下载给定链接的文件"
5556
ytdlp: "使用 yt-dlp 下载视频/音频"
57+
import: "从存储端导入文件到 Telegram"
5658
task: "管理任务队列"
5759
cancel: "取消任务"
5860
watch: "监听聊天(UserBot)"
@@ -295,6 +297,26 @@ bot:
295297
info_urls_select_storage: "共 {{.Count}} 个链接, 请选择存储位置"
296298
info_downloading: "正在通过 yt-dlp 下载..."
297299
error_download_failed: "yt-dlp 下载失败: {{.Error}}"
300+
import:
301+
usage: |
302+
用法: /import <storage_name> <dir_path> [target_chat_id] [filter]
303+
示例:
304+
/import 本机1 /downloads
305+
/import MyAlist /media/photos -1001234567890
306+
/import MyLocal /backup ".*\.mp4$"
307+
error_storage_not_found: "存储端 '{{.StorageName}}' 不存在或您无权访问: {{.Error}}"
308+
error_storage_not_listable: "存储端 '{{.StorageName}}' 不支持列举文件功能"
309+
error_storage_not_readable: "存储端 '{{.StorageName}}' 不支持读取文件功能"
310+
error_no_telegram_storage: "未找到可用的 Telegram 存储: {{.Error}}"
311+
info_fetching_files: "正在获取文件列表..."
312+
error_list_files_failed: "获取文件列表失败: {{.Error}}"
313+
error_invalid_regex: "正则表达式无效: {{.Error}}"
314+
error_no_files_to_import: "目录中没有可导入的文件"
315+
error_invalid_chat_id: "无效的 Chat ID: {{.Error}}"
316+
error_no_target_chat_id: "未指定目标频道 ID,且 Telegram 存储未配置默认 chat_id"
317+
error_add_task_failed: "添加任务失败: {{.Error}}"
318+
info_task_added: "已添加 {{.Count}} 个文件到导入队列\n总大小: {{.SizeMB}} MB\n任务 ID: {{.TaskID}}"
319+
start_stats: "总文件数: {{.Count}}\n总大小: {{.SizeMB}} MB"
298320
cancel:
299321
usage: "用法: /cancel <task_id>"
300322
error_cancel_failed: "取消任务失败: {{.Error}}"
@@ -343,6 +365,20 @@ bot:
343365
ytdlp_done: "yt-dlp 下载完成并已转存 ({{.Count}} 个文件)\n"
344366
downloaded_prefix: "\n已下载: "
345367
current_speed_prefix: "\n当前速度: "
368+
import_start_prefix: "正在导入: "
369+
import_progress_prefix: "导入进度: "
370+
import_uploaded_prefix: "\n已上传: "
371+
import_speed_prefix: "\n速度: "
372+
import_remaining_time_prefix: "\n剩余时间: "
373+
import_processing_prefix: "\n正在处理:\n"
374+
import_processing_more: "...和其他 {{.Count}} 个文件\n"
375+
import_failed_prefix: "导入失败\n"
376+
import_success_prefix: "导入完成\n"
377+
import_total_files_prefix: "\n总文件数: "
378+
import_total_size_prefix: "\n总大小: "
379+
import_elapsed_time_prefix: "\n耗时: "
380+
import_avg_speed_prefix: "\n平均速度: "
381+
import_failed_files_prefix: "\n失败文件数: "
346382
syncpeers:
347383
start: "正在同步对话列表..."
348384
success: "对话列表同步完成, 共同步 {{.Count}} 个对话"
@@ -353,4 +389,4 @@ bot:
353389
info_adding_aria2_download: "正在添加 Aria2 下载任务..."
354390
error_adding_aria2_download: "添加 Aria2 下载任务失败: {{.Error}}"
355391
info_aria2_download_added: "Aria2 下载任务已添加, GID: {{.GID}}"
356-
info_select_storage: "请选择存储位置, 选择后将添加到 Aria2 下载队列"
392+
info_select_storage: "请选择存储位置, 选择后将添加到 Aria2 下载队列"

0 commit comments

Comments
 (0)