mirror of
https://github.com/krau/SaveAny-Bot.git
synced 2026-05-11 18:39:42 +08:00
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 <175728472+Copilot@users.noreply.github.com> * Update core/tasks/batchimport/progress.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update core/tasks/batchimport/execute.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update storage/alist/alist.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update common/i18n/locale/en.yaml Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update pkg/storagetypes/fileinfo.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update common/i18n/locale/en.yaml Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update core/tasks/batchimport/execute.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update storage/webdav/webdav.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update storage/telegram/telegram.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update core/tasks/batchimport/execute.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update storage/webdav/webdav.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * fix: missing progress stats i18n * refactor: use strutil to parse args * chore: update generated code files for consistency --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -16,6 +16,7 @@ import (
|
||||
config "github.com/krau/SaveAny-Bot/config/storage"
|
||||
"github.com/krau/SaveAny-Bot/pkg/enums/ctxkey"
|
||||
storenum "github.com/krau/SaveAny-Bot/pkg/enums/storage"
|
||||
"github.com/krau/SaveAny-Bot/pkg/storagetypes"
|
||||
)
|
||||
|
||||
type Alist struct {
|
||||
@@ -215,3 +216,156 @@ func (a *Alist) Exists(ctx context.Context, storagePath string) bool {
|
||||
func (a *Alist) CannotStream() string {
|
||||
return "Alist does not support chunked transfer encoding"
|
||||
}
|
||||
|
||||
// ListFiles implements StorageListable interface
|
||||
func (a *Alist) ListFiles(ctx context.Context, dirPath string) ([]storagetypes.FileInfo, error) {
|
||||
a.logger.Debugf("Listing files in directory: %s", dirPath)
|
||||
|
||||
reqBody := fsListRequest{
|
||||
Path: dirPath,
|
||||
Password: "",
|
||||
Page: 1,
|
||||
PerPage: 0, // 0 means all files
|
||||
Refresh: false,
|
||||
}
|
||||
|
||||
bodyBytes, err := json.Marshal(reqBody)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal request body: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, a.baseURL+"/api/fs/list", bytes.NewBuffer(bodyBytes))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
req.Header.Set("Authorization", a.token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := a.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to send request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("failed to list files: %s", resp.Status)
|
||||
}
|
||||
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response body: %w", err)
|
||||
}
|
||||
|
||||
var listResp fsListResponse
|
||||
if err := json.Unmarshal(data, &listResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal list response: %w", err)
|
||||
}
|
||||
|
||||
if listResp.Code != http.StatusOK {
|
||||
return nil, fmt.Errorf("failed to list files: %d, %s", listResp.Code, listResp.Message)
|
||||
}
|
||||
|
||||
files := make([]storagetypes.FileInfo, 0, len(listResp.Data.Content))
|
||||
for _, item := range listResp.Data.Content {
|
||||
// Parse modified time; log failures but keep zero value on error.
|
||||
var modTime time.Time
|
||||
if item.Modified != "" {
|
||||
parsedTime, err := time.Parse(time.RFC3339, item.Modified)
|
||||
if err != nil {
|
||||
a.logger.With(
|
||||
"path", path.Join(dirPath, item.Name),
|
||||
"modified_raw", item.Modified,
|
||||
).Warnf("failed to parse modified time for file")
|
||||
} else {
|
||||
modTime = parsedTime
|
||||
}
|
||||
}
|
||||
|
||||
files = append(files, storagetypes.FileInfo{
|
||||
Name: item.Name,
|
||||
Path: path.Join(dirPath, item.Name),
|
||||
Size: item.Size,
|
||||
IsDir: item.IsDir,
|
||||
ModTime: modTime,
|
||||
})
|
||||
}
|
||||
|
||||
a.logger.Debugf("Found %d files in directory %s", len(files), dirPath)
|
||||
return files, nil
|
||||
}
|
||||
|
||||
// OpenFile implements StorageReadable interface
|
||||
func (a *Alist) OpenFile(ctx context.Context, filePath string) (io.ReadCloser, int64, error) {
|
||||
a.logger.Debugf("Opening file: %s", filePath)
|
||||
|
||||
// First, get file info to get the raw_url
|
||||
reqBody := map[string]any{
|
||||
"path": filePath,
|
||||
"password": "",
|
||||
}
|
||||
|
||||
bodyBytes, err := json.Marshal(reqBody)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("failed to marshal request body: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, a.baseURL+"/api/fs/get", bytes.NewBuffer(bodyBytes))
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
req.Header.Set("Authorization", a.token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := a.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("failed to send request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, 0, fmt.Errorf("failed to get file info: %s", resp.Status)
|
||||
}
|
||||
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("failed to read response body: %w", err)
|
||||
}
|
||||
|
||||
var getResp fsGetResponse
|
||||
if err := json.Unmarshal(data, &getResp); err != nil {
|
||||
return nil, 0, fmt.Errorf("failed to unmarshal get response: %w", err)
|
||||
}
|
||||
|
||||
if getResp.Code != http.StatusOK {
|
||||
return nil, 0, fmt.Errorf("failed to get file info: %d, %s", getResp.Code, getResp.Message)
|
||||
}
|
||||
|
||||
if getResp.Data.IsDir {
|
||||
return nil, 0, fmt.Errorf("path is a directory, not a file")
|
||||
}
|
||||
|
||||
// Download the file from raw_url
|
||||
downloadURL := getResp.Data.RawURL
|
||||
if downloadURL == "" {
|
||||
// If no raw_url, construct download URL
|
||||
downloadURL = a.baseURL + "/d" + filePath
|
||||
}
|
||||
|
||||
downloadReq, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadURL, nil)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("failed to create download request: %w", err)
|
||||
}
|
||||
|
||||
downloadResp, err := a.client.Do(downloadReq)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("failed to download file: %w", err)
|
||||
}
|
||||
|
||||
if downloadResp.StatusCode != http.StatusOK {
|
||||
downloadResp.Body.Close()
|
||||
return nil, 0, fmt.Errorf("failed to download file: %s", downloadResp.Status)
|
||||
}
|
||||
|
||||
a.logger.Debugf("Opened file %s, size: %d bytes", filePath, getResp.Data.Size)
|
||||
return downloadResp.Body, getResp.Data.Size, nil
|
||||
}
|
||||
|
||||
@@ -46,4 +46,46 @@ type putResponse struct {
|
||||
type fsGetResponse struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data struct {
|
||||
Name string `json:"name"`
|
||||
Size int64 `json:"size"`
|
||||
IsDir bool `json:"is_dir"`
|
||||
Modified string `json:"modified"`
|
||||
Created string `json:"created"`
|
||||
Sign string `json:"sign"`
|
||||
Thumb string `json:"thumb"`
|
||||
Type int `json:"type"`
|
||||
RawURL string `json:"raw_url"`
|
||||
Provider string `json:"provider"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
type fsListRequest struct {
|
||||
Path string `json:"path"`
|
||||
Password string `json:"password"`
|
||||
Page int `json:"page"`
|
||||
PerPage int `json:"per_page"`
|
||||
Refresh bool `json:"refresh"`
|
||||
}
|
||||
|
||||
type fsListResponse struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data struct {
|
||||
Content []struct {
|
||||
Name string `json:"name"`
|
||||
Size int64 `json:"size"`
|
||||
IsDir bool `json:"is_dir"`
|
||||
Modified string `json:"modified"`
|
||||
Created string `json:"created"`
|
||||
Sign string `json:"sign"`
|
||||
Thumb string `json:"thumb"`
|
||||
Type int `json:"type"`
|
||||
} `json:"content"`
|
||||
Total int64 `json:"total"`
|
||||
Readme string `json:"readme"`
|
||||
Header string `json:"header"`
|
||||
Write bool `json:"write"`
|
||||
Provider string `json:"provider"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user