Compare commits

...

7 Commits

Author SHA1 Message Date
krau
602fc251d8 fix: upgrade deps and medernize codes 2026-03-05 19:40:25 +08:00
krau
af28738235 test(api): add api tests 2026-03-05 19:30:23 +08:00
krau
3eb3b6e3c8 feat(api): implement task management API with handlers for creating, listing, retrieving, and canceling tasks
- Added Handlers struct and methods for task operations
- Implemented task progress tracking and storage
- Created server setup with middleware for logging and recovery
- Added support for Telegram file extraction and Telegraph image extraction
- Introduced webhook functionality for task status updates
- Defined request and response types for API interactions
2026-03-05 19:11:30 +08:00
krau
f377ee3ca4 chore: format code for consistency and readability 2026-03-05 17:20:45 +08:00
krau
70f7172162 docs: add rclone support to configuration guide 2026-01-31 21:17:30 +08:00
krau
091f581881 Merge branch 'main' of https://github.com/krau/SaveAny-Bot 2026-01-30 13:34:56 +08:00
Krau
8b86330f5c feat: add rclone storage backend (#191)
* fix: update StoragePath method to return specific path for single file

* feat: add Rclone storage support with configuration and file operations

* docs: add Rclone support to documentation for configuration and usage
2026-01-30 13:34:29 +08:00
35 changed files with 2885 additions and 132 deletions

3
.gitignore vendored
View File

@@ -11,4 +11,5 @@ temp/
playwright/
testplugins/
*.exe
tmp-*
tmp-*
saveany-bot

View File

@@ -35,6 +35,7 @@
- S3
- WebDAV
- Local filesystem
- Rclone (via command line)
- Telegram (re-upload to specified chats)
## 📦 Quick Start

View File

@@ -33,6 +33,7 @@
- S3
- WebDAV
- 本地磁盘
- Rclone
- Telegram (重传回指定聊天)
## 快速开始

48
api/auth.go Normal file
View File

@@ -0,0 +1,48 @@
package api
import (
"context"
"crypto/subtle"
"net/http"
"strings"
"github.com/krau/SaveAny-Bot/config"
)
// tokenContextKey 用于在 context 中存储 token
type tokenContextKey struct{}
// AuthMiddleware 返回认证中间件
func AuthMiddleware() func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
cfg := config.C().API
// 从请求头获取 token
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
WriteError(w, http.StatusUnauthorized, "unauthorized", "missing authorization header")
return
}
// 提取 Bearer token
parts := strings.SplitN(authHeader, " ", 2)
if len(parts) != 2 || strings.ToLower(parts[0]) != "bearer" {
WriteError(w, http.StatusUnauthorized, "unauthorized", "invalid authorization header format")
return
}
token := parts[1]
// 验证 token
if subtle.ConstantTimeCompare([]byte(token), []byte(cfg.Token)) != 1 {
WriteError(w, http.StatusUnauthorized, "unauthorized", "invalid token")
return
}
// 将 token 添加到 context
ctx := context.WithValue(r.Context(), tokenContextKey{}, token)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}

355
api/factory.go Normal file
View File

@@ -0,0 +1,355 @@
package api
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/krau/SaveAny-Bot/config"
"github.com/krau/SaveAny-Bot/core"
"github.com/krau/SaveAny-Bot/core/tasks/aria2dl"
"github.com/krau/SaveAny-Bot/core/tasks/batchtfile"
"github.com/krau/SaveAny-Bot/core/tasks/directlinks"
"github.com/krau/SaveAny-Bot/core/tasks/parsed"
tphtask "github.com/krau/SaveAny-Bot/core/tasks/telegraph"
"github.com/krau/SaveAny-Bot/core/tasks/tfile"
"github.com/krau/SaveAny-Bot/core/tasks/transfer"
"github.com/krau/SaveAny-Bot/core/tasks/ytdlp"
"github.com/krau/SaveAny-Bot/parsers/parsers"
"github.com/krau/SaveAny-Bot/pkg/aria2"
"github.com/krau/SaveAny-Bot/pkg/enums/tasktype"
"github.com/krau/SaveAny-Bot/pkg/parser"
"github.com/krau/SaveAny-Bot/pkg/telegraph"
"github.com/krau/SaveAny-Bot/storage"
"github.com/rs/xid"
)
// TaskFactory 任务工厂
type TaskFactory struct {
ctx context.Context
}
// NewTaskFactory 创建任务工厂
func NewTaskFactory(ctx context.Context) *TaskFactory {
return &TaskFactory{ctx: ctx}
}
// CreateTask 创建任务
func (f *TaskFactory) CreateTask(req *CreateTaskRequest) (*CreateTaskResponse, error) {
// 验证存储
stor, ok := storage.Storages[req.Storage]
if !ok {
return nil, fmt.Errorf("storage not found: %s", req.Storage)
}
taskID := xid.New().String()
createdAt := time.Now()
switch req.Type {
case tasktype.TaskTypeDirectlinks:
return f.createDirectLinksTask(taskID, createdAt, req, stor)
case tasktype.TaskTypeYtdlp:
return f.createYTDLPTask(taskID, createdAt, req, stor)
case tasktype.TaskTypeAria2:
return f.createAria2Task(taskID, createdAt, req, stor)
case tasktype.TaskTypeParseditem:
return f.createParsedTask(taskID, createdAt, req, stor)
case tasktype.TaskTypeTgfiles:
return f.createTGFilesTask(taskID, createdAt, req, stor)
case tasktype.TaskTypeTphpics:
return f.createTPHPicsTask(taskID, createdAt, req, stor)
case tasktype.TaskTypeTransfer:
return f.createTransferTask(taskID, createdAt, req)
default:
return nil, fmt.Errorf("unsupported task type: %s", req.Type)
}
}
// createDirectLinksTask 创建直链下载任务
func (f *TaskFactory) createDirectLinksTask(taskID string, createdAt time.Time, req *CreateTaskRequest, stor storage.Storage) (*CreateTaskResponse, error) {
var params DirectLinksParams
if err := json.Unmarshal(req.Params, &params); err != nil {
return nil, fmt.Errorf("invalid params: %w", err)
}
if len(params.URLs) == 0 {
return nil, fmt.Errorf("no URLs provided")
}
task := directlinks.NewTask(taskID, f.ctx, params.URLs, stor, req.Path, nil)
if err := core.AddTask(f.ctx, task); err != nil {
return nil, fmt.Errorf("failed to add task: %w", err)
}
return &CreateTaskResponse{
TaskID: taskID,
Type: tasktype.TaskTypeDirectlinks,
Status: TaskStatusQueued,
CreatedAt: createdAt,
}, nil
}
// createYTDLPTask 创建 yt-dlp 任务
func (f *TaskFactory) createYTDLPTask(taskID string, createdAt time.Time, req *CreateTaskRequest, stor storage.Storage) (*CreateTaskResponse, error) {
var params YTDLPParams
if err := json.Unmarshal(req.Params, &params); err != nil {
return nil, fmt.Errorf("invalid params: %w", err)
}
if len(params.URLs) == 0 {
return nil, fmt.Errorf("no URLs provided")
}
task := ytdlp.NewTask(taskID, f.ctx, params.URLs, params.Flags, stor, req.Path, nil)
if err := core.AddTask(f.ctx, task); err != nil {
return nil, fmt.Errorf("failed to add task: %w", err)
}
return &CreateTaskResponse{
TaskID: taskID,
Type: tasktype.TaskTypeYtdlp,
Status: TaskStatusQueued,
CreatedAt: createdAt,
}, nil
}
// createAria2Task 创建 Aria2 任务
func (f *TaskFactory) createAria2Task(taskID string, createdAt time.Time, req *CreateTaskRequest, stor storage.Storage) (*CreateTaskResponse, error) {
var params Aria2Params
if err := json.Unmarshal(req.Params, &params); err != nil {
return nil, fmt.Errorf("invalid params: %w", err)
}
if len(params.URLs) == 0 {
return nil, fmt.Errorf("no URLs provided")
}
// 检查 Aria2 是否启用
cfg := config.C().Aria2
if !cfg.Enable {
return nil, fmt.Errorf("aria2 is not enabled")
}
aria2Client, err := aria2.NewClient(cfg.Url, cfg.Secret)
if err != nil {
return nil, fmt.Errorf("failed to create aria2 client: %w", err)
}
// 添加下载任务到 Aria2
gid, err := aria2Client.AddURI(f.ctx, params.URLs, nil)
if err != nil {
return nil, fmt.Errorf("failed to add aria2 task: %w", err)
}
task := aria2dl.NewTask(taskID, f.ctx, gid, params.URLs, aria2Client, stor, req.Path, nil)
if err := core.AddTask(f.ctx, task); err != nil {
return nil, fmt.Errorf("failed to add task: %w", err)
}
return &CreateTaskResponse{
TaskID: taskID,
Type: tasktype.TaskTypeAria2,
Status: TaskStatusQueued,
CreatedAt: createdAt,
}, nil
}
// createParsedTask 创建解析任务
func (f *TaskFactory) createParsedTask(taskID string, createdAt time.Time, req *CreateTaskRequest, stor storage.Storage) (*CreateTaskResponse, error) {
var params ParsedParams
if err := json.Unmarshal(req.Params, &params); err != nil {
return nil, fmt.Errorf("invalid params: %w", err)
}
if params.URL == "" {
return nil, fmt.Errorf("no URL provided")
}
// 查找合适的解析器
var p parser.Parser
for _, parserItem := range parsers.Get() {
if parserItem.CanHandle(params.URL) {
p = parserItem
break
}
}
if p == nil {
return nil, fmt.Errorf("no parser found for URL: %s", params.URL)
}
// 解析 URL
item, err := p.Parse(f.ctx, params.URL)
if err != nil {
return nil, fmt.Errorf("failed to parse URL: %w", err)
}
task := parsed.NewTask(taskID, f.ctx, stor, req.Path, item, nil)
if err := core.AddTask(f.ctx, task); err != nil {
return nil, fmt.Errorf("failed to add task: %w", err)
}
return &CreateTaskResponse{
TaskID: taskID,
Type: tasktype.TaskTypeParseditem,
Status: TaskStatusQueued,
CreatedAt: createdAt,
}, nil
}
// createTGFilesTask 创建 Telegram 文件下载任务
func (f *TaskFactory) createTGFilesTask(taskID string, createdAt time.Time, req *CreateTaskRequest, stor storage.Storage) (*CreateTaskResponse, error) {
var params TGFilesParams
if err := json.Unmarshal(req.Params, &params); err != nil {
return nil, fmt.Errorf("invalid params: %w", err)
}
if len(params.MessageLinks) == 0 {
return nil, fmt.Errorf("no message links provided")
}
// 提取文件
files, err := ExtractFilesFromLinks(f.ctx, params.MessageLinks)
if err != nil {
return nil, fmt.Errorf("failed to extract files: %w", err)
}
if len(files) == 0 {
return nil, fmt.Errorf("no files found in provided links")
}
if len(files) == 1 {
// 单个文件任务
tfileTask, err := tfile.NewTGFileTask(taskID, f.ctx, files[0], stor, req.Path, nil)
if err != nil {
return nil, fmt.Errorf("failed to create tfile task: %w", err)
}
if err := core.AddTask(f.ctx, tfileTask); err != nil {
return nil, fmt.Errorf("failed to add task: %w", err)
}
} else {
// 批量文件任务
elems := make([]batchtfile.TaskElement, 0, len(files))
for _, file := range files {
elem, err := batchtfile.NewTaskElement(stor, req.Path, file)
if err != nil {
return nil, fmt.Errorf("failed to create task element: %w", err)
}
elems = append(elems, *elem)
}
task := batchtfile.NewBatchTGFileTask(taskID, f.ctx, elems, nil, true)
if err := core.AddTask(f.ctx, task); err != nil {
return nil, fmt.Errorf("failed to add task: %w", err)
}
}
return &CreateTaskResponse{
TaskID: taskID,
Type: tasktype.TaskTypeTgfiles,
Status: TaskStatusQueued,
CreatedAt: createdAt,
}, nil
}
// createTPHPicsTask 创建 Telegraph 图片下载任务
func (f *TaskFactory) createTPHPicsTask(taskID string, createdAt time.Time, req *CreateTaskRequest, stor storage.Storage) (*CreateTaskResponse, error) {
var params TPHPicsParams
if err := json.Unmarshal(req.Params, &params); err != nil {
return nil, fmt.Errorf("invalid params: %w", err)
}
if params.TelegraphURL == "" {
return nil, fmt.Errorf("no telegraph URL provided")
}
// 提取图片
pics, phPath, err := ExtractTelegraphImages(f.ctx, params.TelegraphURL)
if err != nil {
return nil, fmt.Errorf("failed to extract telegraph images: %w", err)
}
if len(pics) == 0 {
return nil, fmt.Errorf("no images found in telegraph page")
}
client := telegraph.NewClient()
task := tphtask.NewTask(taskID, f.ctx, phPath, pics, stor, req.Path, client, nil)
if err := core.AddTask(f.ctx, task); err != nil {
return nil, fmt.Errorf("failed to add task: %w", err)
}
return &CreateTaskResponse{
TaskID: taskID,
Type: tasktype.TaskTypeTphpics,
Status: TaskStatusQueued,
CreatedAt: createdAt,
}, nil
}
// createTransferTask 创建存储间传输任务
func (f *TaskFactory) createTransferTask(taskID string, createdAt time.Time, req *CreateTaskRequest) (*CreateTaskResponse, error) {
var params TransferParams
if err := json.Unmarshal(req.Params, &params); err != nil {
return nil, fmt.Errorf("invalid params: %w", err)
}
// 验证源存储和目标存储
sourceStor, ok := storage.Storages[params.SourceStorage]
if !ok {
return nil, fmt.Errorf("source storage not found: %s", params.SourceStorage)
}
targetStor, ok := storage.Storages[params.TargetStorage]
if !ok {
return nil, fmt.Errorf("target storage not found: %s", params.TargetStorage)
}
// 检查源存储是否可读
sourceReadable, ok := sourceStor.(storage.StorageReadable)
if !ok {
return nil, fmt.Errorf("source storage does not support reading: %s", params.SourceStorage)
}
// 检查源存储是否可列
sourceListable, ok := sourceStor.(storage.StorageListable)
if !ok {
return nil, fmt.Errorf("source storage does not support listing: %s", params.SourceStorage)
}
// 列出源文件
files, err := sourceListable.ListFiles(f.ctx, params.SourcePath)
if err != nil {
return nil, fmt.Errorf("failed to list source files: %w", err)
}
if len(files) == 0 {
return nil, fmt.Errorf("no files found at source path: %s", params.SourcePath)
}
// 创建传输元素
elems := make([]transfer.TaskElement, 0, len(files))
for _, file := range files {
elem := transfer.NewTaskElement(sourceReadable, file, targetStor, params.TargetPath)
elems = append(elems, *elem)
}
task := transfer.NewTransferTask(taskID, f.ctx, elems, nil, true)
if err := core.AddTask(f.ctx, task); err != nil {
return nil, fmt.Errorf("failed to add task: %w", err)
}
return &CreateTaskResponse{
TaskID: taskID,
Type: tasktype.TaskTypeTransfer,
Status: TaskStatusQueued,
CreatedAt: createdAt,
}, nil
}

222
api/handlers.go Normal file
View File

@@ -0,0 +1,222 @@
package api
import (
"encoding/json"
"net/http"
"strings"
"github.com/krau/SaveAny-Bot/core"
"github.com/krau/SaveAny-Bot/pkg/enums/tasktype"
"github.com/krau/SaveAny-Bot/storage"
)
// Handlers 处理器结构体
type Handlers struct {
factory *TaskFactory
}
// NewHandlers 创建处理器
func NewHandlers(factory *TaskFactory) *Handlers {
return &Handlers{factory: factory}
}
// CreateTaskHandler 创建任务处理器
func (h *Handlers) CreateTaskHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
WriteError(w, http.StatusMethodNotAllowed, "method_not_allowed", "only POST method is allowed")
return
}
var req CreateTaskRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
WriteError(w, http.StatusBadRequest, "invalid_request", "failed to decode request body: "+err.Error())
return
}
// 验证请求
if req.Type == "" {
WriteError(w, http.StatusBadRequest, "invalid_request", "task type is required")
return
}
if req.Storage == "" {
WriteError(w, http.StatusBadRequest, "invalid_request", "storage is required")
return
}
// 创建任务
resp, err := h.factory.CreateTask(&req)
if err != nil {
WriteError(w, http.StatusBadRequest, "task_creation_failed", err.Error())
return
}
WriteJSON(w, http.StatusCreated, resp)
}
// ListTasksHandler 列出任务处理器
func (h *Handlers) ListTasksHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
WriteError(w, http.StatusMethodNotAllowed, "method_not_allowed", "only GET method is allowed")
return
}
tasks := GetAllTasks()
response := make([]TaskInfoResponse, 0, len(tasks))
for _, task := range tasks {
info := convertTaskProgressToResponse(task)
response = append(response, info)
}
WriteJSON(w, http.StatusOK, TasksListResponse{
Tasks: response,
Total: len(response),
})
}
// GetTaskHandler 获取单个任务处理器
func (h *Handlers) GetTaskHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
WriteError(w, http.StatusMethodNotAllowed, "method_not_allowed", "only GET method is allowed")
return
}
taskID := extractTaskIDFromPath(r.URL.Path)
if taskID == "" {
WriteError(w, http.StatusBadRequest, "invalid_request", "task ID is required")
return
}
task, ok := GetTask(taskID)
if !ok {
WriteError(w, http.StatusNotFound, "task_not_found", "task not found: "+taskID)
return
}
resp := convertTaskProgressToResponse(task)
WriteJSON(w, http.StatusOK, resp)
}
// CancelTaskHandler 取消任务处理器
func (h *Handlers) CancelTaskHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete {
WriteError(w, http.StatusMethodNotAllowed, "method_not_allowed", "only DELETE method is allowed")
return
}
taskID := extractTaskIDFromPath(r.URL.Path)
if taskID == "" {
WriteError(w, http.StatusBadRequest, "invalid_request", "task ID is required")
return
}
task, ok := GetTask(taskID)
if !ok {
WriteError(w, http.StatusNotFound, "task_not_found", "task not found: "+taskID)
return
}
// 取消任务
if err := core.CancelTask(r.Context(), taskID); err != nil {
WriteError(w, http.StatusInternalServerError, "cancel_failed", "failed to cancel task: "+err.Error())
return
}
task.UpdateStatus(TaskStatusCancelled)
WriteJSON(w, http.StatusOK, map[string]string{"message": "task cancelled successfully"})
}
// ListStoragesHandler 列出存储处理器
func (h *Handlers) ListStoragesHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
WriteError(w, http.StatusMethodNotAllowed, "method_not_allowed", "only GET method is allowed")
return
}
storages := make([]StorageInfo, 0, len(storage.Storages))
for name, stor := range storage.Storages {
storages = append(storages, StorageInfo{
Name: name,
Type: string(stor.Type()),
})
}
WriteJSON(w, http.StatusOK, StoragesResponse{Storages: storages})
}
// GetTaskTypesHandler 获取支持的任务类型
func (h *Handlers) GetTaskTypesHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
WriteError(w, http.StatusMethodNotAllowed, "method_not_allowed", "only GET method is allowed")
return
}
types := []tasktype.TaskType{
tasktype.TaskTypeDirectlinks,
tasktype.TaskTypeYtdlp,
tasktype.TaskTypeAria2,
tasktype.TaskTypeParseditem,
tasktype.TaskTypeTgfiles,
tasktype.TaskTypeTphpics,
tasktype.TaskTypeTransfer,
}
WriteJSON(w, http.StatusOK, map[string]any{
"types": types,
})
}
// HealthCheckHandler 健康检查处理器
func (h *Handlers) HealthCheckHandler(w http.ResponseWriter, r *http.Request) {
WriteJSON(w, http.StatusOK, map[string]string{
"status": "ok",
})
}
// extractTaskIDFromPath 从路径中提取任务 ID
// 路径格式: /api/v1/tasks/:id
func extractTaskIDFromPath(path string) string {
parts := strings.Split(strings.Trim(path, "/"), "/")
if len(parts) < 4 {
return ""
}
return parts[3]
}
// convertTaskProgressToResponse 将任务进度转换为响应格式
func convertTaskProgressToResponse(task *TaskProgressInfo) TaskInfoResponse {
resp := TaskInfoResponse{
TaskID: task.TaskID,
Type: tasktype.TaskType(task.Type),
Status: task.Status,
Title: task.Title,
Storage: task.Storage,
Path: task.Path,
Error: task.Error,
CreatedAt: task.CreatedAt,
UpdatedAt: task.UpdatedAt,
}
// 计算进度
if task.TotalBytes > 0 {
percent := float64(task.DownloadedBytes) * 100 / float64(task.TotalBytes)
resp.Progress = &TaskProgress{
TotalBytes: task.TotalBytes,
DownloadedBytes: task.DownloadedBytes,
Percent: percent,
}
}
return resp
}
// NotFoundHandler 404 处理器
func NotFoundHandler(w http.ResponseWriter, r *http.Request) {
WriteError(w, http.StatusNotFound, "not_found", "endpoint not found: "+r.URL.Path)
}
// MethodNotAllowedHandler 405 处理器
func MethodNotAllowedHandler(w http.ResponseWriter, r *http.Request) {
WriteError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed: "+r.Method)
}

689
api/handlers_test.go Normal file
View File

@@ -0,0 +1,689 @@
package api
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"github.com/krau/SaveAny-Bot/pkg/enums/tasktype"
)
// setupTestServer creates a test server with handlers
func setupTestServer(t *testing.T) (*Handlers, *TaskFactory) {
factory := NewTaskFactory(t.Context())
handlers := NewHandlers(factory)
return handlers, factory
}
// TestCreateTaskHandler tests the create task endpoint
func TestCreateTaskHandler(t *testing.T) {
handlers, _ := setupTestServer(t)
tests := []struct {
name string
method string
body any
wantStatus int
wantErr bool
}{
{
name: "Method not allowed",
method: http.MethodGet,
body: nil,
wantStatus: http.StatusMethodNotAllowed,
wantErr: true,
},
{
name: "Invalid JSON body",
method: http.MethodPost,
body: "invalid json",
wantStatus: http.StatusBadRequest,
wantErr: true,
},
{
name: "Empty request body",
method: http.MethodPost,
body: CreateTaskRequest{},
// Will fail validation for missing type
wantStatus: http.StatusBadRequest,
wantErr: true,
},
{
name: "Missing type",
method: http.MethodPost,
body: CreateTaskRequest{
Storage: "test-storage",
Path: "downloads",
},
wantStatus: http.StatusBadRequest,
wantErr: true,
},
{
name: "Missing storage",
method: http.MethodPost,
body: CreateTaskRequest{
Type: tasktype.TaskTypeDirectlinks,
Path: "downloads",
},
wantStatus: http.StatusBadRequest,
wantErr: true,
},
{
name: "Storage not found",
method: http.MethodPost,
body: CreateTaskRequest{
Type: tasktype.TaskTypeDirectlinks,
Storage: "non-existent-storage",
Path: "downloads",
Params: json.RawMessage(`{"urls":["https://example.com/file.zip"]}`),
},
wantStatus: http.StatusBadRequest,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var bodyBytes []byte
var err error
if tt.body != nil {
switch v := tt.body.(type) {
case string:
bodyBytes = []byte(v)
default:
bodyBytes, err = json.Marshal(tt.body)
if err != nil {
t.Fatalf("failed to marshal body: %v", err)
}
}
}
req := httptest.NewRequest(tt.method, "/api/v1/tasks", bytes.NewReader(bodyBytes))
if tt.body != nil {
req.Header.Set("Content-Type", "application/json")
}
rr := httptest.NewRecorder()
handlers.CreateTaskHandler(rr, req)
if rr.Code != tt.wantStatus {
t.Errorf("expected status %d, got %d", tt.wantStatus, rr.Code)
}
if tt.wantErr {
var errResp ErrorResponse
if err := json.Unmarshal(rr.Body.Bytes(), &errResp); err != nil {
t.Errorf("expected error response, got: %s", rr.Body.String())
}
}
})
}
}
// TestListTasksHandler tests the list tasks endpoint
func TestListTasksHandler(t *testing.T) {
handlers, _ := setupTestServer(t)
tests := []struct {
name string
method string
wantStatus int
}{
{
name: "Method not allowed",
method: http.MethodPost,
wantStatus: http.StatusMethodNotAllowed,
},
{
name: "Success",
method: http.MethodGet,
wantStatus: http.StatusOK,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest(tt.method, "/api/v1/tasks", nil)
rr := httptest.NewRecorder()
handlers.ListTasksHandler(rr, req)
if rr.Code != tt.wantStatus {
t.Errorf("expected status %d, got %d", tt.wantStatus, rr.Code)
}
if tt.method == http.MethodGet {
var resp TasksListResponse
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
t.Errorf("failed to unmarshal response: %v", err)
}
if resp.Tasks == nil {
t.Error("expected non-nil tasks array")
}
}
})
}
}
// TestGetTaskHandler tests the get task endpoint
func TestGetTaskHandler(t *testing.T) {
handlers, _ := setupTestServer(t)
// Register a test task
testTaskID := "test-get-task"
RegisterTask(testTaskID, "directlinks", "local", "downloads", "Test", "")
defer DeleteTask(testTaskID)
tests := []struct {
name string
method string
path string
wantStatus int
wantFound bool
}{
{
name: "Method not allowed",
method: http.MethodPost,
path: "/api/v1/tasks/test-id",
wantStatus: http.StatusMethodNotAllowed,
},
{
name: "Missing task ID",
method: http.MethodGet,
path: "/api/v1/tasks",
wantStatus: http.StatusBadRequest,
},
{
name: "Task not found",
method: http.MethodGet,
path: "/api/v1/tasks/non-existent-task",
wantStatus: http.StatusNotFound,
},
{
name: "Task found",
method: http.MethodGet,
path: "/api/v1/tasks/" + testTaskID,
wantStatus: http.StatusOK,
wantFound: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest(tt.method, tt.path, nil)
rr := httptest.NewRecorder()
handlers.GetTaskHandler(rr, req)
if rr.Code != tt.wantStatus {
t.Errorf("expected status %d, got %d", tt.wantStatus, rr.Code)
}
if tt.wantFound {
var resp TaskInfoResponse
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
t.Errorf("failed to unmarshal response: %v", err)
}
if resp.TaskID != testTaskID {
t.Errorf("expected task ID %s, got %s", testTaskID, resp.TaskID)
}
}
})
}
}
// TestCancelTaskHandler tests the cancel task endpoint
func TestCancelTaskHandler(t *testing.T) {
handlers, _ := setupTestServer(t)
// Register a test task
testTaskID := "test-cancel-task"
RegisterTask(testTaskID, "directlinks", "local", "downloads", "Test", "")
defer DeleteTask(testTaskID)
tests := []struct {
name string
method string
path string
wantStatus int
skipCore bool // Skip if core is not initialized
}{
{
name: "Method not allowed",
method: http.MethodGet,
path: "/api/v1/tasks/test-id",
wantStatus: http.StatusMethodNotAllowed,
},
{
name: "Missing task ID",
method: http.MethodDelete,
path: "/api/v1/tasks",
wantStatus: http.StatusBadRequest,
},
{
name: "Task not found",
method: http.MethodDelete,
path: "/api/v1/tasks/non-existent-task",
wantStatus: http.StatusNotFound,
},
{
name: "Cancel task",
method: http.MethodDelete,
path: "/api/v1/tasks/" + testTaskID,
wantStatus: http.StatusOK,
skipCore: true, // Requires initialized core task queue
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.skipCore {
t.Skip("Skipping test: requires initialized core")
}
req := httptest.NewRequest(tt.method, tt.path, nil)
rr := httptest.NewRecorder()
handlers.CancelTaskHandler(rr, req)
if rr.Code != tt.wantStatus {
t.Errorf("expected status %d, got %d", tt.wantStatus, rr.Code)
}
})
}
}
// TestListStoragesHandler tests the list storages endpoint
func TestListStoragesHandler(t *testing.T) {
handlers, _ := setupTestServer(t)
tests := []struct {
name string
method string
wantStatus int
}{
{
name: "Method not allowed",
method: http.MethodPost,
wantStatus: http.StatusMethodNotAllowed,
},
{
name: "Success",
method: http.MethodGet,
wantStatus: http.StatusOK,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest(tt.method, "/api/v1/storages", nil)
rr := httptest.NewRecorder()
handlers.ListStoragesHandler(rr, req)
if rr.Code != tt.wantStatus {
t.Errorf("expected status %d, got %d", tt.wantStatus, rr.Code)
}
if tt.method == http.MethodGet {
var resp StoragesResponse
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
t.Errorf("failed to unmarshal response: %v", err)
}
if resp.Storages == nil {
t.Error("expected non-nil storages array")
}
}
})
}
}
// TestConcurrentProgressStore tests concurrent access to progress store
func TestConcurrentProgressStore(t *testing.T) {
// Clear store before test
t.Cleanup(func() {
tasks := GetAllTasks()
for _, task := range tasks {
if strings.HasPrefix(task.TaskID, "concurrent-test-") {
DeleteTask(task.TaskID)
}
}
})
var wg sync.WaitGroup
numGoroutines := 100
// Concurrent registrations
for i := range numGoroutines {
wg.Add(1)
go func(id int) {
defer wg.Done()
taskID := fmt.Sprintf("concurrent-test-%d", id)
RegisterTask(taskID, "directlinks", "local", "downloads", "Test", "")
}(i)
}
// Concurrent reads
for i := range numGoroutines {
wg.Add(1)
go func(id int) {
defer wg.Done()
taskID := fmt.Sprintf("concurrent-test-%d", id)
GetTask(taskID)
}(i)
}
// Concurrent updates
for i := range numGoroutines {
wg.Add(1)
go func(id int) {
defer wg.Done()
taskID := fmt.Sprintf("concurrent-test-%d", id)
info, ok := GetTask(taskID)
if ok {
info.UpdateStatus(TaskStatusRunning)
}
}(i)
}
wg.Wait()
// Verify all tasks exist
for i := range numGoroutines {
taskID := fmt.Sprintf("concurrent-test-%d", i)
if _, ok := GetTask(taskID); !ok {
t.Errorf("task %s not found after concurrent operations", taskID)
}
}
}
// TestProgressTrackerConcurrentUpdates tests concurrent progress updates
func TestProgressTrackerConcurrentUpdates(t *testing.T) {
tracker := NewProgressTracker("concurrent-progress", "directlinks", "local", "downloads", "Test", "")
tracker.OnStart(10000, 10)
var wg sync.WaitGroup
numGoroutines := 50
updatesPerGoroutine := 100
// Concurrent progress updates
for i := range numGoroutines {
wg.Add(1)
go func(id int) {
defer wg.Done()
for j := range updatesPerGoroutine {
tracker.OnProgress(int64(id*updatesPerGoroutine+j), j)
}
}(i)
}
wg.Wait()
info := tracker.GetInfo()
if info.Status != TaskStatusRunning {
t.Errorf("expected status Running after concurrent updates, got %s", info.Status)
}
// Note: Due to race conditions in the simple implementation,
// we can't reliably check exact values without proper synchronization
}
// TestTaskFactoryValidation tests TaskFactory parameter validation
func TestTaskFactoryValidation(t *testing.T) {
factory := NewTaskFactory(context.Background())
tests := []struct {
name string
request *CreateTaskRequest
wantErr bool
errMsg string
}{
{
name: "Storage not found",
request: &CreateTaskRequest{
Type: tasktype.TaskTypeDirectlinks,
Storage: "non-existent",
Path: "downloads",
Params: json.RawMessage(`{"urls":["https://example.com/file.zip"]}`),
},
wantErr: true,
errMsg: "storage not found",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := factory.CreateTask(tt.request)
if (err != nil) != tt.wantErr {
t.Errorf("CreateTask() error = %v, wantErr %v", err, tt.wantErr)
return
}
if err != nil && tt.errMsg != "" {
if !strings.Contains(strings.ToLower(err.Error()), tt.errMsg) {
t.Errorf("error message %q does not contain %q", err.Error(), tt.errMsg)
}
}
})
}
}
// TestEdgeCases tests various edge cases
func TestEdgeCases(t *testing.T) {
tests := []struct {
name string
fn func(t *testing.T)
}{
{
name: "Empty request body",
fn: func(t *testing.T) {
handlers, _ := setupTestServer(t)
req := httptest.NewRequest(http.MethodPost, "/api/v1/tasks", nil)
rr := httptest.NewRecorder()
handlers.CreateTaskHandler(rr, req)
if rr.Code != http.StatusBadRequest {
t.Errorf("expected %d, got %d", http.StatusBadRequest, rr.Code)
}
},
},
{
name: "Very long task ID in path",
fn: func(t *testing.T) {
handlers, _ := setupTestServer(t)
longID := strings.Repeat("a", 1000)
req := httptest.NewRequest(http.MethodGet, "/api/v1/tasks/"+longID, nil)
rr := httptest.NewRecorder()
handlers.GetTaskHandler(rr, req)
if rr.Code != http.StatusNotFound {
t.Errorf("expected %d, got %d", http.StatusNotFound, rr.Code)
}
},
},
{
name: "Path with special characters",
fn: func(t *testing.T) {
path := "/api/v1/tasks/test%20id/with/slashes"
got := extractTaskIDFromPath(path)
expected := "test%20id"
if got != expected {
t.Errorf("expected %q, got %q", expected, got)
}
},
},
{
name: "Double slashes in path",
fn: func(t *testing.T) {
path := "/api/v1/tasks//task-id"
got := extractTaskIDFromPath(path)
expected := ""
if got != expected {
t.Errorf("expected %q, got %q", expected, got)
}
},
},
{
name: "Progress tracker with empty webhook",
fn: func(t *testing.T) {
tracker := NewProgressTracker("test", "type", "storage", "path", "title", "")
info := tracker.GetInfo()
if info.Webhook != "" {
t.Error("expected empty webhook")
}
},
},
}
for _, tt := range tests {
t.Run(tt.name, tt.fn)
}
}
// TestHealthCheckHandler tests the health check endpoint
func TestHealthCheckHandler(t *testing.T) {
handlers, _ := setupTestServer(t)
req := httptest.NewRequest(http.MethodGet, "/health", nil)
rr := httptest.NewRecorder()
handlers.HealthCheckHandler(rr, req)
if rr.Code != http.StatusOK {
t.Errorf("expected status %d, got %d", http.StatusOK, rr.Code)
}
var resp map[string]string
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to unmarshal response: %v", err)
}
if resp["status"] != "ok" {
t.Errorf("expected status 'ok', got %q", resp["status"])
}
}
// TestGetTaskTypesHandler tests the task types endpoint
func TestGetTaskTypesHandler(t *testing.T) {
handlers, _ := setupTestServer(t)
tests := []struct {
name string
method string
wantStatus int
}{
{
name: "Method not allowed",
method: http.MethodPost,
wantStatus: http.StatusMethodNotAllowed,
},
{
name: "Success",
method: http.MethodGet,
wantStatus: http.StatusOK,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest(tt.method, "/api/v1/task-types", nil)
rr := httptest.NewRecorder()
handlers.GetTaskTypesHandler(rr, req)
if rr.Code != tt.wantStatus {
t.Errorf("expected status %d, got %d", tt.wantStatus, rr.Code)
}
if tt.method == http.MethodGet {
var resp map[string]any
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
t.Errorf("failed to unmarshal response: %v", err)
}
if _, ok := resp["types"]; !ok {
t.Error("expected 'types' field in response")
}
}
})
}
}
// TestNotFoundHandler tests the 404 handler
func TestNotFoundHandler(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/non-existent-path", nil)
rr := httptest.NewRecorder()
NotFoundHandler(rr, req)
if rr.Code != http.StatusNotFound {
t.Errorf("expected status %d, got %d", http.StatusNotFound, rr.Code)
}
var resp ErrorResponse
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to unmarshal response: %v", err)
}
if resp.Error != "not_found" {
t.Errorf("expected error 'not_found', got %q", resp.Error)
}
}
// TestMethodNotAllowedHandler tests the 405 handler
func TestMethodNotAllowedHandler(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/api/v1/tasks", nil)
rr := httptest.NewRecorder()
MethodNotAllowedHandler(rr, req)
if rr.Code != http.StatusMethodNotAllowed {
t.Errorf("expected status %d, got %d", http.StatusMethodNotAllowed, rr.Code)
}
var resp ErrorResponse
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to unmarshal response: %v", err)
}
if resp.Error != "method_not_allowed" {
t.Errorf("expected error 'method_not_allowed', got %q", resp.Error)
}
}
// TestTaskProgressInfoTimeUpdate tests that timestamps are updated correctly
func TestTaskProgressInfoTimeUpdate(t *testing.T) {
info := RegisterTask("time-test", "directlinks", "local", "downloads", "Test", "")
defer DeleteTask("time-test")
originalTime := info.UpdatedAt
time.Sleep(10 * time.Millisecond) // Ensure time difference
info.UpdateStatus(TaskStatusRunning)
if !info.UpdatedAt.After(originalTime) {
t.Error("expected UpdatedAt to be updated")
}
}
// TestWebhookPayloadWithNilCompletedAt tests webhook payload with nil completed_at
func TestWebhookPayloadWithNilCompletedAt(t *testing.T) {
payload := WebhookPayload{
TaskID: "test-id",
Type: "directlinks",
Status: TaskStatusRunning,
Storage: "local",
Path: "downloads/file.zip",
CompletedAt: nil,
Error: "",
}
data, err := json.Marshal(payload)
if err != nil {
t.Fatalf("failed to marshal: %v", err)
}
var decoded map[string]any
if err := json.Unmarshal(data, &decoded); err != nil {
t.Fatalf("failed to unmarshal: %v", err)
}
// completed_at should be omitted when nil
if _, ok := decoded["completed_at"]; ok {
t.Error("expected completed_at to be omitted when nil")
}
}

150
api/progress.go Normal file
View File

@@ -0,0 +1,150 @@
package api
import (
"sync"
"sync/atomic"
"time"
)
// TaskProgressInfo 存储任务的进度信息
type TaskProgressInfo struct {
TaskID string
Type string
Status TaskStatus
Title string
TotalBytes int64
DownloadedBytes int64
TotalFiles int
DownloadedFiles int
Storage string
Path string
Error string
CreatedAt time.Time
UpdatedAt time.Time
Webhook string
}
// progressStore 存储所有 API 任务的进度信息
type progressStore struct {
mu sync.RWMutex
tasks map[string]*TaskProgressInfo
}
var store = &progressStore{
tasks: make(map[string]*TaskProgressInfo),
}
// RegisterTask 注册一个新的 API 任务
func RegisterTask(taskID, taskType, storage, path, title, webhook string) *TaskProgressInfo {
info := &TaskProgressInfo{
TaskID: taskID,
Type: taskType,
Status: TaskStatusQueued,
Title: title,
Storage: storage,
Path: path,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
Webhook: webhook,
}
store.mu.Lock()
store.tasks[taskID] = info
store.mu.Unlock()
return info
}
// GetTask 获取任务进度信息
func GetTask(taskID string) (*TaskProgressInfo, bool) {
store.mu.RLock()
defer store.mu.RUnlock()
info, ok := store.tasks[taskID]
return info, ok
}
// GetAllTasks 获取所有任务
func GetAllTasks() []*TaskProgressInfo {
store.mu.RLock()
defer store.mu.RUnlock()
tasks := make([]*TaskProgressInfo, 0, len(store.tasks))
for _, info := range store.tasks {
tasks = append(tasks, info)
}
return tasks
}
// DeleteTask 删除任务记录
func DeleteTask(taskID string) {
store.mu.Lock()
defer store.mu.Unlock()
delete(store.tasks, taskID)
}
// UpdateStatus 更新任务状态
func (t *TaskProgressInfo) UpdateStatus(status TaskStatus) {
t.Status = status
t.UpdatedAt = time.Now()
}
// SetError 设置错误信息
func (t *TaskProgressInfo) SetError(err string) {
t.Error = err
t.Status = TaskStatusFailed
t.UpdatedAt = time.Now()
}
// ProgressTracker 用于 API 任务的进度追踪
type ProgressTracker struct {
info *TaskProgressInfo
}
// NewProgressTracker 创建新的进度追踪器
func NewProgressTracker(taskID, taskType, storage, path, title, webhook string) *ProgressTracker {
info := RegisterTask(taskID, taskType, storage, path, title, webhook)
return &ProgressTracker{info: info}
}
// OnStart 任务开始
func (p *ProgressTracker) OnStart(totalBytes int64, totalFiles int) {
p.info.Status = TaskStatusRunning
p.info.TotalBytes = totalBytes
p.info.TotalFiles = totalFiles
p.info.UpdatedAt = time.Now()
}
// OnProgress 进度更新
func (p *ProgressTracker) OnProgress(downloadedBytes int64, downloadedFiles int) {
atomic.StoreInt64(&p.info.DownloadedBytes, downloadedBytes)
p.info.DownloadedFiles = downloadedFiles
p.info.UpdatedAt = time.Now()
}
// OnDone 任务完成
func (p *ProgressTracker) OnDone(err error) {
if err != nil {
p.info.Status = TaskStatusFailed
p.info.Error = err.Error()
} else {
p.info.Status = TaskStatusCompleted
}
p.info.UpdatedAt = time.Now()
}
// GetInfo 获取任务信息
func (p *ProgressTracker) GetInfo() *TaskProgressInfo {
return p.info
}
// UpdateProgressBytes 更新下载字节数
func (p *ProgressTracker) UpdateProgressBytes(bytes int64) {
atomic.StoreInt64(&p.info.DownloadedBytes, bytes)
p.info.UpdatedAt = time.Now()
}
// UpdateProgressFiles 更新下载文件数
func (p *ProgressTracker) UpdateProgressFiles(files int) {
p.info.DownloadedFiles = files
p.info.UpdatedAt = time.Now()
}

163
api/server.go Normal file
View File

@@ -0,0 +1,163 @@
package api
import (
"context"
"fmt"
"net/http"
"time"
"github.com/charmbracelet/log"
"github.com/krau/SaveAny-Bot/config"
)
// Server API 服务器
type Server struct {
httpServer *http.Server
factory *TaskFactory
}
// NewServer 创建新的 API 服务器
func NewServer(ctx context.Context) *Server {
cfg := config.C().API
factory := NewTaskFactory(ctx)
handlers := NewHandlers(factory)
// 设置路由
mux := http.NewServeMux()
// 健康检查
mux.HandleFunc("/health", handlers.HealthCheckHandler)
// API v1 路由
mux.HandleFunc("/api/v1/tasks", handlers.CreateTaskHandler)
mux.HandleFunc("/api/v1/tasks/", func(w http.ResponseWriter, r *http.Request) {
// 根据方法和路径分发
switch r.Method {
case http.MethodGet:
if r.URL.Path == "/api/v1/tasks" {
handlers.ListTasksHandler(w, r)
} else {
handlers.GetTaskHandler(w, r)
}
case http.MethodDelete:
handlers.CancelTaskHandler(w, r)
default:
MethodNotAllowedHandler(w, r)
}
})
mux.HandleFunc("/api/v1/storages", handlers.ListStoragesHandler)
mux.HandleFunc("/api/v1/task-types", handlers.GetTaskTypesHandler)
// 404 处理
mux.HandleFunc("/", NotFoundHandler)
// 应用中间件
var handler http.Handler = mux
// 添加认证中间件
token := cfg.Token
if token == "" {
log.FromContext(ctx).Warn("API server is enabled but no token is set, this is insecure!")
}
if token != "" {
handler = AuthMiddleware()(handler)
}
// 添加日志中间件
handler = loggingMiddleware(handler)
// 添加恢复中间件
handler = recoveryMiddleware(handler)
return &Server{
httpServer: &http.Server{
Addr: fmt.Sprintf("%s:%d", cfg.Host, cfg.Port),
Handler: handler,
ReadTimeout: 30 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 120 * time.Second,
},
factory: factory,
}
}
// Start 启动服务器
func (s *Server) Start(ctx context.Context) error {
logger := log.FromContext(ctx).With("module", "api")
logger.Infof("Starting API server on %s", s.httpServer.Addr)
// 在 goroutine 中启动服务器
go func() {
if err := s.httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
logger.Errorf("API server error: %v", err)
}
}()
// 监听 context 取消
go func() {
<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := s.httpServer.Shutdown(shutdownCtx); err != nil {
logger.Errorf("API server shutdown error: %v", err)
}
}()
return nil
}
// loggingMiddleware 日志中间件
func loggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
// 包装 ResponseWriter 以获取状态码
wrapped := &responseWriter{ResponseWriter: w, statusCode: http.StatusOK}
next.ServeHTTP(wrapped, r)
log.Infof("%s %s %d %s", r.Method, r.URL.Path, wrapped.statusCode, time.Since(start))
})
}
// recoveryMiddleware 恢复中间件
func recoveryMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if err := recover(); err != nil {
log.Errorf("Panic recovered: %v", err)
WriteError(w, http.StatusInternalServerError, "internal_error", "internal server error")
}
}()
next.ServeHTTP(w, r)
})
}
// responseWriter 包装 http.ResponseWriter 以捕获状态码
type responseWriter struct {
http.ResponseWriter
statusCode int
}
func (rw *responseWriter) WriteHeader(code int) {
rw.statusCode = code
rw.ResponseWriter.WriteHeader(code)
}
// Start 初始化并启动 API 服务器
func Start(ctx context.Context) error {
cfg := config.C().API
if !cfg.Enable {
return nil
}
if cfg.Token == "" {
log.FromContext(ctx).Warn("API server is enabled but no token is set, this is insecure!")
}
server := NewServer(ctx)
return server.Start(ctx)
}

272
api/tgfiles.go Normal file
View File

@@ -0,0 +1,272 @@
package api
import (
"context"
"fmt"
"net/url"
"strconv"
"strings"
"github.com/celestix/gotgproto/ext"
"github.com/charmbracelet/log"
"github.com/gotd/td/tg"
"github.com/krau/SaveAny-Bot/client/bot"
userclient "github.com/krau/SaveAny-Bot/client/user"
"github.com/krau/SaveAny-Bot/common/utils/tgutil"
"github.com/krau/SaveAny-Bot/pkg/tfile"
)
// MessageContext 保存消息和获取它所用的 context
type MessageContext struct {
Message *tg.Message
Client *ext.Context
}
// getClientContext 获取可用的客户端上下文
// 优先使用 Bot失败后回退到 Userbot
func getClientContext() (*ext.Context, error) {
// 首先尝试获取 Bot context
if botCtx := bot.ExtContext(); botCtx != nil {
return botCtx, nil
}
// 回退到 Userbot
if uc := userclient.GetCtx(); uc != nil {
return uc, nil
}
return nil, fmt.Errorf("no client available (bot and userbot are not initialized)")
}
// resolveChatID 解析聊天 ID
func resolveChatID(_ context.Context, idOrUsername string) (int64, error) {
// 如果是数字 ID
if id, err := strconv.ParseInt(idOrUsername, 10, 64); err == nil {
// 私有频道 ID 需要加上 -100 前缀
if id > 0 {
return -1000000000000 - id, nil
}
return id, nil
}
// 获取可用的客户端上下文
clientCtx, err := getClientContext()
if err != nil {
return 0, err
}
// 使用 tgutil 的 ParseChatID
return tgutil.ParseChatID(clientCtx, idOrUsername)
}
// ParseMessageLink 解析 Telegram 消息链接
// 支持格式:
// - https://t.me/username/123
// - https://t.me/c/123456789/123
// - https://t.me/c/123456789/111/456 (topic id)
// - https://t.me/username/123?comment=2 (评论)
func ParseMessageLink(ctx context.Context, link string) (int64, int, error) {
u, err := url.Parse(link)
if err != nil {
return 0, 0, fmt.Errorf("invalid URL: %w", err)
}
paths := strings.Split(strings.TrimPrefix(u.Path, "/"), "/")
if cmt := u.Query().Get("comment"); cmt != "" {
// 频道评论的消息链接
if len(paths) < 1 {
return 0, 0, fmt.Errorf("invalid message link format: %s", link)
}
// 简化处理:返回错误,提示不支持评论链接
return 0, 0, fmt.Errorf("comment links are not supported")
}
switch len(paths) {
case 2: // https://t.me/username/123
chatID, err := resolveChatID(ctx, paths[0])
if err != nil {
return 0, 0, fmt.Errorf("failed to resolve chat ID: %w", err)
}
msgID, err := strconv.Atoi(paths[1])
if err != nil {
return 0, 0, fmt.Errorf("failed to parse message ID: %w", err)
}
return chatID, msgID, nil
case 3:
// https://t.me/c/123456789/123
// https://t.me/username/123/456 , 123: topic id
chatPart, msgPart := paths[1], paths[2]
if paths[0] != "c" {
chatPart = paths[0]
}
chatID, err := resolveChatID(ctx, chatPart)
if err != nil {
return 0, 0, fmt.Errorf("failed to resolve chat ID: %w", err)
}
msgID, err := strconv.Atoi(msgPart)
if err != nil {
return 0, 0, fmt.Errorf("failed to parse message ID: %w", err)
}
return chatID, msgID, nil
case 4:
// https://t.me/c/123456789/111/456 111: topic id
if paths[0] != "c" {
return 0, 0, fmt.Errorf("invalid message link format: %s", link)
}
chatID, err := resolveChatID(ctx, paths[1])
if err != nil {
return 0, 0, fmt.Errorf("failed to resolve chat ID: %w", err)
}
msgID, err := strconv.Atoi(paths[3])
if err != nil {
return 0, 0, fmt.Errorf("failed to parse message ID: %w", err)
}
return chatID, msgID, nil
}
return 0, 0, fmt.Errorf("invalid message link format: %s", link)
}
// getMessageWithContext 通过 ID 获取消息,返回消息和使用的 context
// 确保消息获取和后续文件创建使用同一个 context
func getMessageWithContext(_ context.Context, chatID int64, msgID int) (*MessageContext, error) {
// 首先尝试使用 Bot
if botCtx := bot.ExtContext(); botCtx != nil {
msg, err := tgutil.GetMessageByID(botCtx, chatID, msgID)
if err == nil {
return &MessageContext{Message: msg, Client: botCtx}, nil
}
}
// 回退到 Userbot
uc := userclient.GetCtx()
if uc == nil {
return nil, fmt.Errorf("userbot not initialized and bot cannot access this message")
}
msg, err := tgutil.GetMessageByID(uc, chatID, msgID)
if err != nil {
return nil, err
}
return &MessageContext{Message: msg, Client: uc}, nil
}
// getGroupedMessagesWithContext 获取媒体组消息,返回消息列表和使用的 context
// 确保消息获取和后续文件创建使用同一个 context
func getGroupedMessagesWithContext(ctx *MessageContext, chatID int64) ([]*tg.Message, error) {
msg := ctx.Message
clientCtx := ctx.Client
groupID, ok := msg.GetGroupedID()
if !ok || groupID == 0 {
return []*tg.Message{msg}, nil
}
// 使用获取原始消息的同一个 client 获取媒体组
msgs, err := tgutil.GetGroupedMessages(clientCtx, chatID, msg)
if err != nil || len(msgs) == 0 {
// 如果获取失败,至少返回原始消息
return []*tg.Message{msg}, nil
}
return msgs, nil
}
// ExtractFilesFromLinks 从消息链接中提取文件
// 每个文件的处理流程:解析链接 -> 获取消息 -> 获取媒体组 -> 创建文件对象
// 对于单个文件,全程使用同一个 client context不会交叉
func ExtractFilesFromLinks(ctx context.Context, links []string) ([]tfile.TGFileMessage, error) {
logger := log.FromContext(ctx)
var files []tfile.TGFileMessage
for _, link := range links {
link = strings.TrimSpace(link)
if link == "" {
continue
}
// 验证链接格式
if !isValidMessageLink(link) {
logger.Errorf("Invalid message link format: %s", link)
continue
}
chatID, msgID, err := ParseMessageLink(ctx, link)
if err != nil {
logger.Errorf("Failed to parse message link %s: %v", link, err)
continue
}
// 解析链接 URL 检查是否有 single 参数
u, _ := url.Parse(link)
single := u != nil && u.Query().Has("single")
// 获取消息和使用的 contextBot 优先,失败回退 Userbot
msgCtx, err := getMessageWithContext(ctx, chatID, msgID)
if err != nil {
logger.Errorf("Failed to get message %d from chat %d: %v", msgID, chatID, err)
continue
}
msg := msgCtx.Message
clientCtx := msgCtx.Client
if msg.Media == nil {
logger.Warnf("Message %d has no media", msgID)
continue
}
media, ok := msg.GetMedia()
if !ok {
logger.Warnf("Failed to get media from message %d", msgID)
continue
}
// 检查是否是媒体组
groupID, isGroup := msg.GetGroupedID()
if isGroup && groupID != 0 && !single {
// 使用同一个 client context 获取媒体组
groupMsgs, err := getGroupedMessagesWithContext(msgCtx, chatID)
if err != nil {
logger.Errorf("Failed to get grouped messages: %v", err)
} else {
for _, gmsg := range groupMsgs {
if gmsg.Media == nil {
continue
}
gmedia, ok := gmsg.GetMedia()
if !ok {
continue
}
// 使用获取消息时使用的同一个 client context 创建文件
file, err := tfile.FromMediaMessage(gmedia, clientCtx.Raw, gmsg)
if err != nil {
logger.Errorf("Failed to create file from media: %v", err)
continue
}
files = append(files, file)
}
continue
}
}
// 单个文件 - 使用获取消息时使用的同一个 client context 创建文件
file, err := tfile.FromMediaMessage(media, clientCtx.Raw, msg)
if err != nil {
logger.Errorf("Failed to create file from media: %v", err)
continue
}
files = append(files, file)
}
if len(files) == 0 {
return nil, fmt.Errorf("no files found in provided links")
}
return files, nil
}
// isValidMessageLink 检查是否是有效的 Telegram 消息链接
func isValidMessageLink(link string) bool {
return strings.HasPrefix(link, "https://t.me/") || strings.HasPrefix(link, "http://t.me/")
}

80
api/tphpics.go Normal file
View File

@@ -0,0 +1,80 @@
package api
import (
"context"
"fmt"
"net/url"
"strings"
"github.com/charmbracelet/log"
"github.com/krau/SaveAny-Bot/common/utils/tphutil"
"github.com/krau/SaveAny-Bot/pkg/telegraph"
)
// ExtractTelegraphImages 从 Telegraph URL 提取图片
func ExtractTelegraphImages(ctx context.Context, pageURL string) ([]string, string, error) {
logger := log.FromContext(ctx)
// 验证 URL 格式
if !isValidTelegraphURL(pageURL) {
return nil, "", fmt.Errorf("invalid telegraph URL format: %s", pageURL)
}
// 解析 URL 获取页面路径
pagepath, err := parseTelegraphPath(pageURL)
if err != nil {
return nil, "", err
}
logger.Debugf("Fetching telegraph page: %s", pagepath)
client := telegraph.NewClient()
page, err := client.GetPage(ctx, pagepath)
if err != nil {
return nil, "", fmt.Errorf("failed to get telegraph page: %w", err)
}
var imgs []string
for _, elem := range page.Content {
imgs = append(imgs, tphutil.GetNodeImages(elem)...)
}
if len(imgs) == 0 {
return nil, "", fmt.Errorf("no images found in telegraph page")
}
return imgs, pagepath, nil
}
// parseTelegraphPath 解析 Telegraph URL 获取页面路径
func parseTelegraphPath(pageURL string) (string, error) {
u, err := url.Parse(pageURL)
if err != nil {
return "", fmt.Errorf("invalid telegraph URL: %w", err)
}
if !strings.HasSuffix(u.Host, "telegra.ph") && !strings.HasSuffix(u.Host, "telegraph.co") {
return "", fmt.Errorf("invalid telegraph URL host: %s", u.Host)
}
paths := strings.Split(strings.TrimPrefix(u.Path, "/"), "/")
if len(paths) == 0 || paths[0] == "" {
return "", fmt.Errorf("invalid telegraph URL path: %s", u.Path)
}
pagepath := paths[len(paths)-1]
pagepath, err = url.PathUnescape(pagepath)
if err != nil {
return "", fmt.Errorf("failed to unescape telegraph path: %w", err)
}
return strings.TrimSpace(pagepath), nil
}
// isValidTelegraphURL 检查是否是有效的 Telegraph URL
func isValidTelegraphURL(url string) bool {
return strings.HasPrefix(url, "https://telegra.ph/") ||
strings.HasPrefix(url, "http://telegra.ph/") ||
strings.HasPrefix(url, "https://telegraph.co/") ||
strings.HasPrefix(url, "http://telegraph.co/")
}

161
api/types.go Normal file
View File

@@ -0,0 +1,161 @@
package api
import (
"encoding/json"
"net/http"
"time"
"github.com/krau/SaveAny-Bot/pkg/enums/tasktype"
)
// TaskStatus 表示任务状态
type TaskStatus string
const (
TaskStatusQueued TaskStatus = "queued"
TaskStatusRunning TaskStatus = "running"
TaskStatusCompleted TaskStatus = "completed"
TaskStatusFailed TaskStatus = "failed"
TaskStatusCancelled TaskStatus = "cancelled"
)
// CreateTaskRequest 创建任务请求
type CreateTaskRequest struct {
Type tasktype.TaskType `json:"type"`
Storage string `json:"storage"`
Path string `json:"path"`
Webhook string `json:"webhook,omitempty"`
Params json.RawMessage `json:"params"`
}
// CreateTaskResponse 创建任务响应
type CreateTaskResponse struct {
TaskID string `json:"task_id"`
Type tasktype.TaskType `json:"type"`
Status TaskStatus `json:"status"`
CreatedAt time.Time `json:"created_at"`
}
// TaskProgress 任务进度
type TaskProgress struct {
TotalBytes int64 `json:"total_bytes,omitempty"`
DownloadedBytes int64 `json:"downloaded_bytes,omitempty"`
Percent float64 `json:"percent,omitempty"`
SpeedMBPS float64 `json:"speed_mbps,omitempty"`
}
// TaskInfoResponse 任务信息响应
type TaskInfoResponse struct {
TaskID string `json:"task_id"`
Type tasktype.TaskType `json:"type"`
Status TaskStatus `json:"status"`
Title string `json:"title"`
Progress *TaskProgress `json:"progress,omitempty"`
Storage string `json:"storage"`
Path string `json:"path"`
Error string `json:"error,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// TasksListResponse 任务列表响应
type TasksListResponse struct {
Tasks []TaskInfoResponse `json:"tasks"`
Total int `json:"total"`
}
// StoragesResponse 存储列表响应
type StoragesResponse struct {
Storages []StorageInfo `json:"storages"`
}
// StorageInfo 存储信息
type StorageInfo struct {
Name string `json:"name"`
Type string `json:"type"`
}
// WebhookPayload Webhook 回调负载
type WebhookPayload struct {
TaskID string `json:"task_id"`
Type string `json:"type"`
Status TaskStatus `json:"status"`
Storage string `json:"storage"`
Path string `json:"path"`
CompletedAt *time.Time `json:"completed_at,omitempty"`
Error string `json:"error,omitempty"`
}
// ErrorResponse 错误响应
type ErrorResponse struct {
Error string `json:"error"`
Message string `json:"message,omitempty"`
}
// APIError API 错误
type APIError struct {
StatusCode int
ErrorCode string
Message string
}
func (e *APIError) Error() string {
return e.Message
}
// WriteJSON 写入 JSON 响应
func WriteJSON(w http.ResponseWriter, statusCode int, data any) error {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(statusCode)
return json.NewEncoder(w).Encode(data)
}
// WriteError 写入错误响应
func WriteError(w http.ResponseWriter, statusCode int, errCode, message string) error {
return WriteJSON(w, statusCode, ErrorResponse{
Error: errCode,
Message: message,
})
}
// Task 参数结构体
// DirectLinksParams directlinks 任务参数
type DirectLinksParams struct {
URLs []string `json:"urls"`
}
// YTDLPParams ytdlp 任务参数
type YTDLPParams struct {
URLs []string `json:"urls"`
Flags []string `json:"flags,omitempty"`
}
// Aria2Params aria2 任务参数
type Aria2Params struct {
URLs []string `json:"urls"`
Options map[string]string `json:"options,omitempty"`
}
// ParsedParams parsed 任务参数
type ParsedParams struct {
URL string `json:"url"`
}
// TransferParams transfer 任务参数
type TransferParams struct {
SourceStorage string `json:"source_storage"`
SourcePath string `json:"source_path"`
TargetStorage string `json:"target_storage"`
TargetPath string `json:"target_path"`
}
// TGFilesParams tgfiles 任务参数
type TGFilesParams struct {
MessageLinks []string `json:"message_links"`
}
// TPHPicsParams tphpics 任务参数
type TPHPicsParams struct {
TelegraphURL string `json:"telegraph_url"`
}

130
api/webhook.go Normal file
View File

@@ -0,0 +1,130 @@
package api
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"time"
"github.com/charmbracelet/log"
)
// webhookClient Webhook 客户端
var webhookClient = &http.Client{
Timeout: 30 * time.Second,
}
// SendWebhook 发送 Webhook 回调
func SendWebhook(ctx context.Context, payload *WebhookPayload) {
if payload == nil || payload.TaskID == "" {
return
}
// 获取任务信息以获取 webhook URL
info, ok := GetTask(payload.TaskID)
if !ok || info.Webhook == "" {
return
}
webhookURL := info.Webhook
// 异步发送 webhook
go func() {
logger := log.FromContext(ctx).With("task_id", payload.TaskID)
payloadBytes, err := json.Marshal(payload)
if err != nil {
logger.Errorf("Failed to marshal webhook payload: %v", err)
return
}
// 重试 3 次
for i := range 3 {
req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, webhookURL, bytes.NewBuffer(payloadBytes))
if err != nil {
logger.Errorf("Failed to create webhook request: %v", err)
return
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", "SaveAny-Bot/1.0")
resp, err := webhookClient.Do(req)
if err != nil {
logger.Warnf("Webhook request failed (attempt %d/3): %v", i+1, err)
time.Sleep(time.Second * time.Duration(i+1))
continue
}
resp.Body.Close()
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
logger.Debugf("Webhook sent successfully: %s", webhookURL)
return
}
logger.Warnf("Webhook returned non-2xx status (attempt %d/3): %d", i+1, resp.StatusCode)
time.Sleep(time.Second * time.Duration(i+1))
}
logger.Errorf("Failed to send webhook after 3 attempts")
}()
}
// CreateWebhookPayload 创建 Webhook 负载
func CreateWebhookPayload(taskID string, taskType string, status TaskStatus, storage, path string, err error) *WebhookPayload {
payload := &WebhookPayload{
TaskID: taskID,
Type: taskType,
Status: status,
Storage: storage,
Path: path,
}
if status == TaskStatusCompleted || status == TaskStatusFailed {
now := time.Now()
payload.CompletedAt = &now
}
if err != nil {
payload.Error = err.Error()
}
return payload
}
// WrapTaskWithWebhook 包装任务执行,添加 webhook 回调
func WrapTaskWithWebhook(ctx context.Context, taskID string, fn func() error) error {
info, ok := GetTask(taskID)
if !ok {
return fmt.Errorf("task not found: %s", taskID)
}
err := fn()
// 确定任务状态
status := TaskStatusCompleted
if err != nil {
if err == context.Canceled {
status = TaskStatusCancelled
} else {
status = TaskStatusFailed
}
}
// 更新任务状态
if err != nil {
info.SetError(err.Error())
} else {
info.UpdateStatus(TaskStatusCompleted)
}
// 发送 webhook
if info.Webhook != "" {
payload := CreateWebhookPayload(taskID, info.Type, status, info.Storage, info.Path, err)
SendWebhook(ctx, payload)
}
return err
}

View File

@@ -10,6 +10,7 @@ import (
"slices"
"github.com/charmbracelet/log"
"github.com/krau/SaveAny-Bot/api"
"github.com/krau/SaveAny-Bot/client/bot"
userclient "github.com/krau/SaveAny-Bot/client/user"
"github.com/krau/SaveAny-Bot/common/cache"
@@ -76,6 +77,9 @@ func initAll(ctx context.Context, cmd *cobra.Command) (<-chan struct{}, error) {
logger.Fatal("User login failed", "error", err)
}
}
if err := api.Start(ctx); err != nil {
logger.Error("Failed to start API server", "error", err)
}
return bot.Init(ctx), nil
}

View File

@@ -42,7 +42,7 @@ func FormatSize(bytes int64) string {
MB = KB * 1024
GB = MB * 1024
)
switch {
case bytes >= GB:
return fmt.Sprintf("%.2f GB", float64(bytes)/float64(GB))

View File

@@ -29,6 +29,17 @@ secret = ""
# 转存完成后删除 Aria2 下载的本地文件
remove_after_transfer = true
# HTTP API 配置
[api]
# 启用 HTTP API
enable = false
# 监听地址
host = "0.0.0.0"
# 监听端口
port = 8080
# 认证 Token (必需)
token = ""
# 存储列表
[[storages]]
# 标识名, 需要唯一

View File

@@ -16,6 +16,7 @@ var storageFactories = map[storenum.StorageType]func(cfg *BaseConfig) (StorageCo
storenum.Minio: createStorageConfig(&MinioStorageConfig{}),
storenum.S3: createStorageConfig(&S3StorageConfig{}),
storenum.Telegram: createStorageConfig(&TelegramStorageConfig{}),
storenum.Rclone: createStorageConfig(&RcloneStorageConfig{}),
}
func createStorageConfig(configType StorageConfig) func(cfg *BaseConfig) (StorageConfig, error) {

33
config/storage/rclone.go Normal file
View File

@@ -0,0 +1,33 @@
package storage
import (
"fmt"
storenum "github.com/krau/SaveAny-Bot/pkg/enums/storage"
)
type RcloneStorageConfig struct {
BaseConfig
// The name of the remote as defined in rclone config
Remote string `toml:"remote" mapstructure:"remote" json:"remote"`
BasePath string `toml:"base_path" mapstructure:"base_path" json:"base_path"`
// The path to the rclone config file, if not using the default
ConfigPath string `toml:"config_path" mapstructure:"config_path" json:"config_path"`
// Additional flags to pass to rclone commands
Flags []string `toml:"flags" mapstructure:"flags" json:"flags"`
}
func (r *RcloneStorageConfig) Validate() error {
if r.Remote == "" {
return fmt.Errorf("remote is required for rclone storage")
}
return nil
}
func (r *RcloneStorageConfig) GetType() storenum.StorageType {
return storenum.Rclone
}
func (r *RcloneStorageConfig) GetName() string {
return r.Name
}

View File

@@ -24,6 +24,7 @@ type Config struct {
Stream bool `toml:"stream" mapstructure:"stream" json:"stream"`
Proxy string `toml:"proxy" mapstructure:"proxy" json:"proxy"`
Aria2 aria2Config `toml:"aria2" mapstructure:"aria2" json:"aria2"`
API apiConfig `toml:"api" mapstructure:"api" json:"api"`
Cache cacheConfig `toml:"cache" mapstructure:"cache" json:"cache"`
Users []userConfig `toml:"users" mapstructure:"users" json:"users"`
@@ -42,6 +43,13 @@ type aria2Config struct {
KeepFile bool `toml:"keep_file" mapstructure:"keep_file" json:"keep_file"`
}
type apiConfig struct {
Enable bool `toml:"enable" mapstructure:"enable" json:"enable"`
Host string `toml:"host" mapstructure:"host" json:"host"`
Port int `toml:"port" mapstructure:"port" json:"port"`
Token string `toml:"token" mapstructure:"token" json:"token"`
}
var cfg = &Config{}
func C() Config {
@@ -115,6 +123,12 @@ func Init(ctx context.Context, configFile ...string) error {
// 数据库
"db.path": "data/saveany.db",
"db.session": "data/session.db",
// API
"api.enable": false,
"api.host": "0.0.0.0",
"api.port": 8080,
"api.token": "",
}
for key, value := range defaultConfigs {

View File

@@ -126,6 +126,7 @@ Each storage endpoint requires at least the following fields:
- `alist`: Alist
- `webdav`: WebDAV
- `s3`: aws S3 and other S3 compatible services
- `rclone`: Uses rclone to implement uploads
- `telegram`: Upload to Telegram
Example, this is a configuration that includes local storage and webdav storage:

View File

@@ -80,4 +80,60 @@ chat_id = "123456789" # Telegram chat ID, the bot will send files to this chat
force_file = false # Force sending as file, default is false
skip_large = false # Skip large files, default is false. If enabled, files exceeding Telegram's limit will not be uploaded.
spilt_size_mb = 2000 # Split size in MB, default is 2000 MB (2 GB). Files larger than this will be split into multiple parts (zip format). Ignored when skip_large is true.
```
## Rclone
`type=rclone`
Supports multiple cloud storage services through the [rclone](https://rclone.org/) command-line tool. You need to install rclone and configure remote storage first.
```toml
# Remote name configured in rclone, can be any remote defined in rclone.conf
remote = "mydrive"
# Base path in the remote storage, all files will be stored under this path
base_path = "/telegram"
# Path to rclone config file, optional, leave empty to use default path (~/.config/rclone/rclone.conf)
config_path = ""
# Additional flags to pass to rclone commands, optional
flags = ["--transfers", "4", "--checkers", "8"]
```
### Configuring rclone Remote
First, you need to configure an rclone remote. Run `rclone config` for interactive configuration, or directly edit the `rclone.conf` file.
rclone supports many cloud storage services, including but not limited to:
- Google Drive
- Dropbox
- OneDrive
- Amazon S3 and compatible services
- SFTP
- FTP
- For more services, please refer to the [rclone official documentation](https://rclone.org/overview/)
### Usage Examples
After configuring Google Drive, you can configure the storage like this:
```toml
[[storages]]
name = "GoogleDrive"
type = "rclone"
enable = true
remote = "gdrive"
base_path = "/SaveAnyBot"
```
If using a custom rclone config file:
```toml
[[storages]]
name = "MyRemote"
type = "rclone"
enable = true
remote = "myremote"
base_path = "/backup"
config_path = "/path/to/rclone.conf"
flags = ["--progress"]
```

View File

@@ -29,6 +29,7 @@ title: 介绍
- S3
- WebDAV
- 本地磁盘
- Rclone (通过命令行调用)
- Telegram (重传回指定聊天)
## [贡献者](https://github.com/krau/SaveAny-Bot/graphs/contributors)

View File

@@ -124,6 +124,7 @@ remove_after_transfer = true
- `alist`: Alist
- `webdav`: WebDAV
- `s3`: aws S3 及其他兼容 S3 的服务
- `rclone`: 调用 rclone 实现上传
- `telegram`: 上传到 Telegram
示例, 这是一个包含本地存储和 webdav 存储的配置:

View File

@@ -86,4 +86,60 @@ skip_large = false
# 超过该大小的文件将被分割成多个部分上传.(使用 zip 格式)
# 当 skip_large 启用时, 该选项无效.
spilt_size_mb = 2000
```
## Rclone
`type=rclone`
通过 [rclone](https://rclone.org/) 命令行工具支持多种云存储服务. 需要先安装 rclone 并配置好远程存储.
```toml
# rclone 配置的远程名称, 可以是任何在 rclone.conf 中配置的远程
remote = "mydrive"
# 在远程存储中的基础路径, 所有文件将存储在此路径下
base_path = "/telegram"
# rclone 配置文件的路径, 可选, 留空使用默认路径 (~/.config/rclone/rclone.conf)
config_path = ""
# 传递给 rclone 命令的额外参数, 可选
flags = ["--transfers", "4", "--checkers", "8"]
```
### 配置 rclone 远程
首先需要配置 rclone 远程, 运行 `rclone config` 命令进行交互式配置, 或直接编辑 `rclone.conf` 文件.
rclone 支持多种云存储服务, 包括但不限于:
- Google Drive
- Dropbox
- OneDrive
- Amazon S3 及兼容服务
- SFTP
- FTP
- 更多服务请参考 [rclone 官方文档](https://rclone.org/overview/)
### 使用示例
配置 Google Drive 后, 可以这样配置存储:
```toml
[[storages]]
name = "GoogleDrive"
type = "rclone"
enable = true
remote = "gdrive"
base_path = "/SaveAnyBot"
```
如果使用自定义的 rclone 配置文件:
```toml
[[storages]]
name = "MyRemote"
type = "rclone"
enable = true
remote = "myremote"
base_path = "/backup"
config_path = "/path/to/rclone.conf"
flags = ["--progress"]
```

67
go.mod
View File

@@ -1,50 +1,49 @@
module github.com/krau/SaveAny-Bot
go 1.24.2
go 1.25.0
require (
github.com/blang/semver v3.5.1+incompatible
github.com/celestix/gotgproto v1.0.0-beta22
github.com/cenkalti/backoff/v4 v4.3.0
github.com/charmbracelet/bubbles v0.21.0
github.com/charmbracelet/bubbles v1.0.0
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/lipgloss v1.1.0
github.com/charmbracelet/log v0.4.2
github.com/dustin/go-humanize v1.0.1
github.com/gabriel-vasile/mimetype v1.4.12
github.com/gabriel-vasile/mimetype v1.4.13
github.com/goccy/go-yaml v1.19.2
github.com/gotd/contrib v0.21.1
github.com/gotd/td v0.137.0
github.com/gotd/td v0.140.0
github.com/johannesboyne/gofakes3 v0.0.0-20250916175020-ebf3e50324d3
github.com/krau/ffmpeg-go v0.6.0
github.com/lrstanley/go-ytdlp v1.2.7
github.com/lrstanley/go-ytdlp v1.3.2
github.com/minio/minio-go/v7 v7.0.98
github.com/playwright-community/playwright-go v0.5200.1
github.com/playwright-community/playwright-go v0.5700.1
github.com/rs/xid v1.6.0
github.com/spf13/cobra v1.10.2
github.com/spf13/viper v1.21.0
github.com/unvgo/ghselfupdate v1.0.1
github.com/yapingcat/gomedia v0.0.0-20240906162731-17feea57090c
golang.org/x/net v0.49.0
golang.org/x/term v0.39.0
golang.org/x/net v0.51.0
golang.org/x/term v0.40.0
golang.org/x/time v0.14.0
)
require (
github.com/AnimeKaizoku/cacher v1.0.3 // indirect
github.com/ProtonMail/go-crypto v1.3.0 // indirect
github.com/ProtonMail/go-crypto v1.4.0 // indirect
github.com/aws/smithy-go v1.24.0 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/charmbracelet/colorprofile v0.4.1 // indirect
github.com/charmbracelet/colorprofile v0.4.2 // indirect
github.com/charmbracelet/harmonica v0.2.0 // indirect
github.com/charmbracelet/x/ansi v0.11.4 // indirect
github.com/charmbracelet/x/cellbuf v0.0.14 // indirect
github.com/charmbracelet/x/ansi v0.11.6 // indirect
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/clipperhouse/displaywidth v0.7.0 // indirect
github.com/clipperhouse/stringish v0.1.1 // indirect
github.com/clipperhouse/uax29/v2 v2.3.0 // indirect
github.com/cloudflare/circl v1.6.1 // indirect
github.com/clipperhouse/displaywidth v0.11.0 // indirect
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/cloudflare/circl v1.6.3 // indirect
github.com/coder/websocket v1.8.14 // indirect
github.com/deckarep/golang-set/v2 v2.8.0 // indirect
github.com/dlclark/regexp2 v1.11.5 // indirect
@@ -64,7 +63,7 @@ require (
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/google/go-github/v30 v30.1.0 // indirect
github.com/google/go-querystring v1.2.0 // indirect
github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 // indirect
github.com/google/pprof v0.0.0-20260302011040-a15ffb7f9dcc // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gotd/ige v0.2.2 // indirect
github.com/gotd/neo v0.1.5 // indirect
@@ -77,7 +76,7 @@ require (
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.19 // indirect
github.com/mattn/go-runewidth v0.0.20 // indirect
github.com/minio/crc64nvme v1.1.1 // indirect
github.com/minio/md5-simd v1.1.2 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
@@ -85,7 +84,7 @@ require (
github.com/muesli/termenv v0.16.0 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/ncruces/julianday v1.0.0 // indirect
github.com/ogen-go/ogen v1.18.0 // indirect
github.com/ogen-go/ogen v1.20.1 // indirect
github.com/philhofer/fwd v1.2.0 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
@@ -96,34 +95,34 @@ require (
github.com/tetratelabs/wazero v1.11.0 // indirect
github.com/tinylib/msgp v1.6.3 // indirect
github.com/ulikunitz/xz v0.5.15 // indirect
go.opentelemetry.io/otel v1.39.0 // indirect
go.opentelemetry.io/otel/metric v1.39.0 // indirect
go.opentelemetry.io/otel/trace v1.39.0 // indirect
go.opentelemetry.io/otel v1.41.0 // indirect
go.opentelemetry.io/otel/metric v1.41.0 // indirect
go.opentelemetry.io/otel/trace v1.41.0 // indirect
go.shabbyrobe.org/gocovmerge v0.0.0-20230507111327-fa4f82cfbf4d // indirect
go.uber.org/atomic v1.11.0 // indirect
go.uber.org/zap v1.27.1 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/crypto v0.47.0 // indirect
golang.org/x/mod v0.32.0 // indirect
golang.org/x/tools v0.41.0 // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/mod v0.33.0 // indirect
golang.org/x/tools v0.42.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
modernc.org/libc v1.67.6 // indirect
modernc.org/libc v1.69.0 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.44.1 // indirect
modernc.org/sqlite v1.46.1 // indirect
rsc.io/qr v0.2.0 // indirect
)
require (
github.com/dgraph-io/ristretto/v2 v2.3.0
github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3
github.com/dgraph-io/ristretto/v2 v2.4.0
github.com/dop251/goja v0.0.0-20260226184354-913bd86fb70c
github.com/duke-git/lancet/v2 v2.3.8
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/glebarez/sqlite v1.11.0
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/klauspost/compress v1.18.3 // indirect
github.com/klauspost/compress v1.18.4 // indirect
github.com/mitchellh/mapstructure v1.5.0
github.com/ncruces/go-sqlite3 v0.30.4
github.com/ncruces/go-sqlite3 v0.30.5
github.com/ncruces/go-sqlite3/gormlite v0.30.2
github.com/nicksnyder/go-i18n/v2 v2.6.1
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
@@ -134,9 +133,9 @@ require (
github.com/subosito/gotenv v1.6.0 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/exp v0.0.0-20260112195511-716be5621a96 // indirect
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa // indirect
golang.org/x/sync v0.19.0
golang.org/x/sys v0.40.0 // indirect
golang.org/x/text v0.33.0
golang.org/x/sys v0.41.0 // indirect
golang.org/x/text v0.34.0
gorm.io/gorm v1.31.1
)

144
go.sum
View File

@@ -4,8 +4,8 @@ github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0=
github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ=
github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw=
github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE=
github.com/ProtonMail/go-crypto v1.4.0 h1:Zq/pbM3F5DFgJiMouxEdSVY44MVoQNEKp5d5QxIQceQ=
github.com/ProtonMail/go-crypto v1.4.0/go.mod h1:e1OaTyu5SYVrO9gKOEhTc+5UcXtTUa+P3uLudwcgPqo=
github.com/aws/aws-sdk-go-v2 v1.36.3 h1:mJoei2CxPutQVxaATCzDUjcZEjVRdpsiiXi2o38yqWM=
github.com/aws/aws-sdk-go-v2 v1.36.3/go.mod h1:LLXuLpgzEbD766Z5ECcRmi8AzSwfZItDtmABVkRLGzg=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.10 h1:zAybnyUQXIZ5mok5Jqwlf58/TFE7uvd3IAsa1aF9cXs=
@@ -44,32 +44,30 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UF
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cevatbarisyilmaz/ara v0.0.4 h1:SGH10hXpBJhhTlObuZzTuFn1rrdmjQImITXnZVPSodc=
github.com/cevatbarisyilmaz/ara v0.0.4/go.mod h1:BfFOxnUd6Mj6xmcvRxHN3Sr21Z1T3U2MYkYOmoQe4Ts=
github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs=
github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg=
github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
github.com/charmbracelet/colorprofile v0.4.2 h1:BdSNuMjRbotnxHSfxy+PCSa4xAmz7szw70ktAtWRYrY=
github.com/charmbracelet/colorprofile v0.4.2/go.mod h1:0rTi81QpwDElInthtrQ6Ni7cG0sDtwAd4C4le060fT8=
github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ=
github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
github.com/charmbracelet/log v0.4.2 h1:hYt8Qj6a8yLnvR+h7MwsJv/XvmBJXiueUcI3cIxsyig=
github.com/charmbracelet/log v0.4.2/go.mod h1:qifHGX/tc7eluv2R6pWIpyHDDrrb/AG71Pf2ysQu5nw=
github.com/charmbracelet/x/ansi v0.11.4 h1:6G65PLu6HjmE858CnTUQY1LXT3ZUWwfvqEROLF8vqHI=
github.com/charmbracelet/x/ansi v0.11.4/go.mod h1:/5AZ+UfWExW3int5H5ugnsG/PWjNcSQcwYsHBlPFQN4=
github.com/charmbracelet/x/cellbuf v0.0.14 h1:iUEMryGyFTelKW3THW4+FfPgi4fkmKnnaLOXuc+/Kj4=
github.com/charmbracelet/x/cellbuf v0.0.14/go.mod h1:P447lJl49ywBbil/KjCk2HexGh4tEY9LH0/1QrZZ9rA=
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
github.com/clipperhouse/displaywidth v0.7.0 h1:QNv1GYsnLX9QBrcWUtMlogpTXuM5FVnBwKWp1O5NwmE=
github.com/clipperhouse/displaywidth v0.7.0/go.mod h1:R+kHuzaYWFkTm7xoMmK1lFydbci4X2CicfbGstSGg0o=
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4=
github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0=
github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs=
github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
@@ -78,14 +76,14 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/deckarep/golang-set/v2 v2.8.0 h1:swm0rlPCmdWn9mESxKOjWk8hXSqoxOp+ZlfuyaAdFlQ=
github.com/deckarep/golang-set/v2 v2.8.0/go.mod h1:VAky9rY/yGXJOLEDv3OMci+7wtDpOF4IN+y82NBOac4=
github.com/dgraph-io/ristretto/v2 v2.3.0 h1:qTQ38m7oIyd4GAed/QkUZyPFNMnvVWyazGXRwvOt5zk=
github.com/dgraph-io/ristretto/v2 v2.3.0/go.mod h1:gpoRV3VzrEY1a9dWAYV6T1U7YzfgttXdd/ZzL1s9OZM=
github.com/dgraph-io/ristretto/v2 v2.4.0 h1:I/w09yLjhdcVD2QV192UJcq8dPBaAJb9pOuMyNy0XlU=
github.com/dgraph-io/ristretto/v2 v2.4.0/go.mod h1:0KsrXtXvnv0EqnzyowllbVJB8yBonswa2lTCK2gGo9E=
github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da h1:aIftn67I1fkbMa512G+w+Pxci9hJPB8oMnkcP3iZF38=
github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw=
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3 h1:bVp3yUzvSAJzu9GqID+Z96P+eu5TKnIMJSV4QaZMauM=
github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4=
github.com/dop251/goja v0.0.0-20260226184354-913bd86fb70c h1:hIlkLbQ+tYoUqlG42LnxwGcohL5jaGqD8mGeJWavm8A=
github.com/dop251/goja v0.0.0-20260226184354-913bd86fb70c/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4=
github.com/duke-git/lancet/v2 v2.3.8 h1:dlkqn6Nj2LRWFuObNxttkMHxrFeaV6T26JR8jbEVbPg=
github.com/duke-git/lancet/v2 v2.3.8/go.mod h1:zGa2R4xswg6EG9I6WnyubDbFO/+A/RROxIbXcwryTsc=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
@@ -98,8 +96,8 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk=
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ=
@@ -143,8 +141,8 @@ github.com/google/go-github/v30 v30.1.0/go.mod h1:n8jBpHl45a/rlBUtRJMOG4GhNADUQF
github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck=
github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0=
github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU=
github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 h1:z2ogiKUYzX5Is6zr/vP9vJGqPwcdqsWjOt+V8J7+bTc=
github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
github.com/google/pprof v0.0.0-20260302011040-a15ffb7f9dcc h1:VBbFa1lDYWEeV5FZKUiYKYT0VxCp9twUmmaq9eb8sXw=
github.com/google/pprof v0.0.0-20260302011040-a15ffb7f9dcc/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gotd/contrib v0.21.1 h1:NSF+0YEnosQ34QEo2o4s6MA5YFDAor1LVvLhN1L3H1M=
@@ -153,8 +151,8 @@ github.com/gotd/ige v0.2.2 h1:XQ9dJZwBfDnOGSTxKXBGP4gMud3Qku2ekScRjDWWfEk=
github.com/gotd/ige v0.2.2/go.mod h1:tuCRb+Y5Y3eNTo3ypIfNpQ4MFjrnONiL2jN2AKZXmb0=
github.com/gotd/neo v0.1.5 h1:oj0iQfMbGClP8xI59x7fE/uHoTJD7NZH9oV1WNuPukQ=
github.com/gotd/neo v0.1.5/go.mod h1:9A2a4bn9zL6FADufBdt7tZt+WMhvZoc5gWXihOPoiBQ=
github.com/gotd/td v0.137.0 h1:Mhf9oiRxio40vFcbkft1Cs6jrwV8MMbtGRtW9LAPOhY=
github.com/gotd/td v0.137.0/go.mod h1:t0MC7iCm4MkzkGjcZ5NAraStsdBLF3yJlSXhXB8JqdI=
github.com/gotd/td v0.140.0 h1:trNBzTnhNtNwHsFp5qwKnNxQRAZJ6/BRE+uH3Lojauk=
github.com/gotd/td v0.140.0/go.mod h1:0ZkRxG7N+5ooG7/zdRXcnGautGPM6IKmyPQvdsAeF20=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/inconshreveable/go-update v0.0.0-20160112193335-8152e7eb6ccf h1:WfD7VjIE6z8dIvMsI4/s+1qr5EL+zoIGev1BQj1eoJ8=
@@ -167,8 +165,8 @@ github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/johannesboyne/gofakes3 v0.0.0-20250916175020-ebf3e50324d3 h1:2713fQZ560HxoNVgfJH41GKzjMjIG+DW4hH6nYXfXW8=
github.com/johannesboyne/gofakes3 v0.0.0-20250916175020-ebf3e50324d3/go.mod h1:S4S9jGBVlLri0OeqrSSbCGG5vsI6he06UJyuz1WT1EE=
github.com/klauspost/compress v1.18.3 h1:9PJRvfbmTabkOX8moIpXPbMMbYN60bWImDDU7L+/6zw=
github.com/klauspost/compress v1.18.3/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
@@ -180,8 +178,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/krau/ffmpeg-go v0.6.0 h1:F4HWvOrKXQsfLsFTOnUfP0HY6WISJqOrsAFGSIzkKto=
github.com/krau/ffmpeg-go v0.6.0/go.mod h1:sa7/bWHB6fO9j4lhmxnWQ1U07o+dE1leFjhctotxU7A=
github.com/lrstanley/go-ytdlp v1.2.7 h1:YNDvKkd0OCJSZLZePZvJwcirBCfL8Yw3eCwrTCE5w7Q=
github.com/lrstanley/go-ytdlp v1.2.7/go.mod h1:38IL64XM6gULrWtKTiR0+TTNCVbxesNSbTyaFG2CGTI=
github.com/lrstanley/go-ytdlp v1.3.2 h1:ktOav5X8+ZByuaQPFUF3uiPxofw0L5MoQtck6iIkWhI=
github.com/lrstanley/go-ytdlp v1.3.2/go.mod h1:VgjnTrvkTf+23JuySjyPq1iQ8ijSovBtTPpXH5XrLtI=
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
@@ -190,16 +188,14 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/mattn/go-runewidth v0.0.20 h1:WcT52H91ZUAwy8+HUkdM3THM6gXqXuLJi9O3rjcQQaQ=
github.com/mattn/go-runewidth v0.0.20/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/minio/crc64nvme v1.1.1 h1:8dwx/Pz49suywbO+auHCBpCtlW1OfpcLN7wYgVR6wAI=
github.com/minio/crc64nvme v1.1.1/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg=
github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=
github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM=
github.com/minio/minio-go/v7 v7.0.98 h1:MeAVKjLVz+XJ28zFcuYyImNSAh8Mq725uNW4beRisi0=
github.com/minio/minio-go/v7 v7.0.98/go.mod h1:cY0Y+W7yozf0mdIclrttzo1Iiu7mEf9y7nk2uXqMOvM=
github.com/mitchellh/go-ps v1.0.0 h1:i6ampVEEF4wQFF+bkYfwYgY+F/uYJDktmvLPf7qIgjc=
github.com/mitchellh/go-ps v1.0.0/go.mod h1:J4lOc8z8yJs6vUwklHw2XEIiT4z4C40KtWVN3nvg8Pg=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
@@ -208,8 +204,8 @@ github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELU
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/ncruces/go-sqlite3 v0.30.4 h1:j9hEoOL7f9ZoXl8uqXVniaq1VNwlWAXihZbTvhqPPjA=
github.com/ncruces/go-sqlite3 v0.30.4/go.mod h1:7WR20VSC5IZusKhUdiR9y1NsUqnZgqIYCmKKoMEYg68=
github.com/ncruces/go-sqlite3 v0.30.5 h1:6usmTQ6khriL8oWilkAZSJM/AIpAlVL2zFrlcpDldCE=
github.com/ncruces/go-sqlite3 v0.30.5/go.mod h1:0I0JFflTKzfs3Ogfv8erP7CCoV/Z8uxigVDNOR0AQ5E=
github.com/ncruces/go-sqlite3/gormlite v0.30.2 h1:FZ8mic14xTatssTkHCrelh9nPeFdXuzgMoNGkfuFbBU=
github.com/ncruces/go-sqlite3/gormlite v0.30.2/go.mod h1:W9WLBbqrrOIh2dqFZkeC/xKALG2LDIHY91jowahOdtI=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
@@ -218,16 +214,16 @@ github.com/ncruces/julianday v1.0.0 h1:fH0OKwa7NWvniGQtxdJRxAgkBMolni2BjDHaWTxqt
github.com/ncruces/julianday v1.0.0/go.mod h1:Dusn2KvZrrovOMJuOt0TNXL6tB7U2E8kvza5fFc9G7g=
github.com/nicksnyder/go-i18n/v2 v2.6.1 h1:JDEJraFsQE17Dut9HFDHzCoAWGEQJom5s0TRd17NIEQ=
github.com/nicksnyder/go-i18n/v2 v2.6.1/go.mod h1:Vee0/9RD3Quc/NmwEjzzD7VTZ+Ir7QbXocrkhOzmUKA=
github.com/ogen-go/ogen v1.18.0 h1:6RQ7lFBjOeNaUWu4getfqIh4GJbEY4hqKuzDtec/g60=
github.com/ogen-go/ogen v1.18.0/go.mod h1:dHFr2Wf6cA7tSxMI+zPC21UR5hAlDw8ZYUkK3PziURY=
github.com/ogen-go/ogen v1.20.1 h1:AFpIeI2rS37TNIMRQTHhAkThICQpa1p+Pceu7HP7xsA=
github.com/ogen-go/ogen v1.20.1/go.mod h1:eXQeqzIfw9qUjXdpqNtkX+XCvhlWNymqU1bm7S7y8iU=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/playwright-community/playwright-go v0.5200.1 h1:Sm2oOuhqt0M5Y4kUi/Qh9w4cyyi3ZIWTBeGKImc2UVo=
github.com/playwright-community/playwright-go v0.5200.1/go.mod h1:UnnyQZaqUOO5ywAZu60+N4EiWReUqX1MQBBA3Oofvf8=
github.com/playwright-community/playwright-go v0.5700.1 h1:PNFb1byWqrTT720rEO0JL88C6Ju0EmUnR5deFLvtP/U=
github.com/playwright-community/playwright-go v0.5700.1/go.mod h1:MlSn1dZrx8rszbCxY6x3qK89ZesJUYVx21B2JnkoNF0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
@@ -282,12 +278,12 @@ go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo=
go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=
go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
go.opentelemetry.io/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c=
go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE=
go.opentelemetry.io/otel/metric v1.41.0 h1:rFnDcs4gRzBcsO9tS8LCpgR0dxg4aaxWlJxCno7JlTQ=
go.opentelemetry.io/otel/metric v1.41.0/go.mod h1:xPvCwd9pU0VN8tPZYzDZV/BMj9CM9vs00GuBjeKhJps=
go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0=
go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis=
go.shabbyrobe.org/gocovmerge v0.0.0-20230507111327-fa4f82cfbf4d h1:Ns9kd1Rwzw7t0BR8XMphenji4SmIoNZPn8zhYmaVKP8=
go.shabbyrobe.org/gocovmerge v0.0.0-20230507111327-fa4f82cfbf4d/go.mod h1:92Uoe3l++MlthCm+koNi0tcUCX3anayogF0Pa/sp24k=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
@@ -303,22 +299,22 @@ go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
golang.org/x/exp v0.0.0-20260112195511-716be5621a96 h1:Z/6YuSHTLOHfNFdb8zVZomZr7cqNgTJvA8+Qz75D8gU=
golang.org/x/exp v0.0.0-20260112195511-716be5621a96/go.mod h1:nzimsREAkjBCIEFtHiYkrJyT+2uy9YZJB7H1k68CXZU=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0=
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -336,31 +332,31 @@ golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY=
golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww=
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
@@ -378,18 +374,18 @@ gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc=
modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM=
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
modernc.org/ccgo/v4 v4.31.0 h1:/bsaxqdgX3gy/0DboxcvWrc3NpzH+6wpFfI/ZaA/hrg=
modernc.org/ccgo/v4 v4.31.0/go.mod h1:jKe8kPBjIN/VdGTVqARTQ8N1gAziBmiISY8j5HoKwjg=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE=
modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.67.6 h1:eVOQvpModVLKOdT+LvBPjdQqfrZq+pC39BygcT+E7OI=
modernc.org/libc v1.67.6/go.mod h1:JAhxUVlolfYDErnwiqaLvUqc8nfb2r6S6slAgZOnaiE=
modernc.org/libc v1.69.0 h1:YQJ5QMSReTgQ3QFmI0dudfjXIjCcYTUxcH8/9P9f0D8=
modernc.org/libc v1.69.0/go.mod h1:YfLLduUEbodNV2xLU5JOnRHBTAHVHsVW3bVYGw0ZCV4=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
@@ -398,8 +394,8 @@ modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.44.1 h1:qybx/rNpfQipX/t47OxbHmkkJuv2JWifCMH8SVUiDas=
modernc.org/sqlite v1.44.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=
modernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU=
modernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=

View File

@@ -4,6 +4,6 @@ package storage
// StorageType
/* ENUM(
local, webdav, alist, minio, telegram, s3
local, webdav, alist, minio, telegram, s3, rclone
) */
type StorageType string

View File

@@ -24,6 +24,8 @@ const (
Telegram StorageType = "telegram"
// S3 is a StorageType of type s3.
S3 StorageType = "s3"
// Rclone is a StorageType of type rclone.
Rclone StorageType = "rclone"
)
var ErrInvalidStorageType = fmt.Errorf("not a valid StorageType, try [%s]", strings.Join(_StorageTypeNames, ", "))
@@ -35,6 +37,7 @@ var _StorageTypeNames = []string{
string(Minio),
string(Telegram),
string(S3),
string(Rclone),
}
// StorageTypeNames returns a list of possible string values of StorageType.
@@ -53,6 +56,7 @@ func StorageTypeValues() []StorageType {
Minio,
Telegram,
S3,
Rclone,
}
}
@@ -75,6 +79,7 @@ var _StorageTypeValue = map[string]StorageType{
"minio": Minio,
"telegram": Telegram,
"s3": S3,
"rclone": Rclone,
}
// ParseStorageType attempts to convert a string to a StorageType.

View File

@@ -1,5 +1,6 @@
package tasktype
//go:generate go-enum --values --names --flag --nocase
// ENUM(tgfiles,tphpics,parseditem,directlinks,aria2,ytdlp,transfer)
//
//go:generate go-enum --values --names --flag --nocase
type TaskType string

View File

@@ -82,17 +82,13 @@ func TestConcurrencySafety(t *testing.T) {
var wg sync.WaitGroup
n := 1000
// producers
wg.Add(1)
go func() {
defer wg.Done()
wg.Go(func() {
for i := range n {
q.Add(newTask(fmt.Sprintf("p%d", i)))
}
}()
})
// consumers
wg.Add(1)
go func() {
defer wg.Done()
wg.Go(func() {
count := 0
for count < n {
_, err := q.Get()
@@ -101,6 +97,6 @@ func TestConcurrencySafety(t *testing.T) {
}
count++
}
}()
})
wg.Wait()
}

View File

@@ -87,12 +87,12 @@ func (l *Local) Exists(ctx context.Context, storagePath string) bool {
// ListFiles implements StorageListable interface
func (l *Local) ListFiles(ctx context.Context, dirPath string) ([]storagetypes.FileInfo, error) {
absPath := l.JoinStoragePath(dirPath)
entries, err := os.ReadDir(absPath)
if err != nil {
return nil, fmt.Errorf("failed to read directory %s: %w", absPath, err)
}
files := make([]storagetypes.FileInfo, 0, len(entries))
for _, entry := range entries {
info, err := entry.Info()
@@ -100,7 +100,7 @@ func (l *Local) ListFiles(ctx context.Context, dirPath string) ([]storagetypes.F
l.logger.Warnf("Failed to get file info for %s: %v", entry.Name(), err)
continue
}
filePath := filepath.Join(dirPath, entry.Name())
files = append(files, storagetypes.FileInfo{
Name: entry.Name(),
@@ -110,24 +110,24 @@ func (l *Local) ListFiles(ctx context.Context, dirPath string) ([]storagetypes.F
ModTime: info.ModTime(),
})
}
return files, nil
}
// OpenFile implements StorageReadable interface
func (l *Local) OpenFile(ctx context.Context, filePath string) (io.ReadCloser, int64, error) {
absPath := l.JoinStoragePath(filePath)
file, err := os.Open(absPath)
if err != nil {
return nil, 0, fmt.Errorf("failed to open file %s: %w", absPath, err)
}
stat, err := file.Stat()
if err != nil {
file.Close()
return nil, 0, fmt.Errorf("failed to stat file %s: %w", absPath, err)
}
return file, stat.Size(), nil
}

14
storage/rclone/errs.go Normal file
View File

@@ -0,0 +1,14 @@
package rclone
import "errors"
var (
ErrRcloneNotFound = errors.New("rclone: rclone command not found in PATH")
ErrRemoteNotFound = errors.New("rclone: remote not found")
ErrFailedToSaveFile = errors.New("rclone: failed to save file")
ErrFailedToListFiles = errors.New("rclone: failed to list files")
ErrFailedToOpenFile = errors.New("rclone: failed to open file")
ErrFailedToCheckFile = errors.New("rclone: failed to check file exists")
ErrFailedToCreateDir = errors.New("rclone: failed to create directory")
ErrCommandFailed = errors.New("rclone: command execution failed")
)

289
storage/rclone/rclone.go Normal file
View File

@@ -0,0 +1,289 @@
package rclone
import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"os/exec"
"path"
"strings"
"time"
"github.com/charmbracelet/log"
config "github.com/krau/SaveAny-Bot/config/storage"
storenum "github.com/krau/SaveAny-Bot/pkg/enums/storage"
"github.com/krau/SaveAny-Bot/pkg/storagetypes"
"github.com/rs/xid"
)
type Rclone struct {
config config.RcloneStorageConfig
logger *log.Logger
}
func (r *Rclone) Init(ctx context.Context, cfg config.StorageConfig) error {
rcloneConfig, ok := cfg.(*config.RcloneStorageConfig)
if !ok {
return fmt.Errorf("failed to cast rclone config")
}
if err := rcloneConfig.Validate(); err != nil {
return err
}
r.config = *rcloneConfig
r.logger = log.FromContext(ctx).WithPrefix(fmt.Sprintf("rclone[%s]", r.config.Name))
// 检查 rclone 是否安装
if _, err := exec.LookPath("rclone"); err != nil {
return ErrRcloneNotFound
}
args := r.buildBaseArgs()
args = append(args, "listremotes")
cmd := exec.CommandContext(ctx, "rclone", args...)
output, err := cmd.Output()
if err != nil {
r.logger.Errorf("Failed to list remotes: %v", err)
return fmt.Errorf("failed to verify rclone: %w", err)
}
remoteName := strings.TrimSuffix(r.config.Remote, ":")
if !strings.HasSuffix(r.config.Remote, ":") {
remoteName = r.config.Remote
}
found := false
scanner := bufio.NewScanner(bytes.NewReader(output))
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
line = strings.TrimSuffix(line, ":")
if line == remoteName {
found = true
break
}
}
if !found {
r.logger.Errorf("Remote %s not found in rclone config", r.config.Remote)
return ErrRemoteNotFound
}
r.logger.Infof("Initialized rclone storage with remote: %s", r.config.Remote)
return nil
}
func (r *Rclone) Type() storenum.StorageType {
return storenum.Rclone
}
func (r *Rclone) Name() string {
return r.config.Name
}
func (r *Rclone) buildBaseArgs() []string {
var args []string
if r.config.ConfigPath != "" {
args = append(args, "--config", r.config.ConfigPath)
}
args = append(args, r.config.Flags...)
return args
}
func (r *Rclone) getRemotePath(storagePath string) string {
remote := r.config.Remote
if !strings.HasSuffix(remote, ":") {
remote += ":"
}
basePath := strings.TrimPrefix(r.config.BasePath, "/")
fullPath := path.Join(basePath, storagePath)
return remote + fullPath
}
func (r *Rclone) Save(ctx context.Context, reader io.Reader, storagePath string) error {
r.logger.Infof("Saving file to %s", storagePath)
ext := path.Ext(storagePath)
base := strings.TrimSuffix(storagePath, ext)
candidate := storagePath
for i := 1; r.Exists(ctx, candidate); i++ {
candidate = fmt.Sprintf("%s_%d%s", base, i, ext)
if i > 100 {
r.logger.Errorf("Too many attempts to find a unique filename for %s", storagePath)
candidate = fmt.Sprintf("%s_%s%s", base, xid.New().String(), ext)
break
}
}
remotePath := r.getRemotePath(candidate)
r.logger.Debugf("Remote path: %s", remotePath)
// Use rclone rcat to read from stdin and upload
args := r.buildBaseArgs()
args = append(args, "rcat", remotePath)
cmd := exec.CommandContext(ctx, "rclone", args...)
cmd.Stdin = reader
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
r.logger.Errorf("Failed to save file: %v, stderr: %s", err, stderr.String())
return fmt.Errorf("%w: %s", ErrFailedToSaveFile, stderr.String())
}
r.logger.Infof("Successfully saved file to %s", candidate)
return nil
}
func (r *Rclone) Exists(ctx context.Context, storagePath string) bool {
remotePath := r.getRemotePath(storagePath)
args := r.buildBaseArgs()
args = append(args, "lsf", remotePath)
cmd := exec.CommandContext(ctx, "rclone", args...)
err := cmd.Run()
return err == nil
}
// lsjsonItem represents a single entry in the output of `rclone lsjson`
type lsjsonItem struct {
Path string `json:"Path"`
Name string `json:"Name"`
Size int64 `json:"Size"`
MimeType string `json:"MimeType"`
ModTime string `json:"ModTime"`
IsDir bool `json:"IsDir"`
}
// ListFiles implements storage.StorageListable
func (r *Rclone) ListFiles(ctx context.Context, dirPath string) ([]storagetypes.FileInfo, error) {
r.logger.Infof("Listing files in %s", dirPath)
remotePath := r.getRemotePath(dirPath)
args := r.buildBaseArgs()
args = append(args, "lsjson", remotePath)
cmd := exec.CommandContext(ctx, "rclone", args...)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
r.logger.Errorf("Failed to list files: %v, stderr: %s", err, stderr.String())
return nil, fmt.Errorf("%w: %s", ErrFailedToListFiles, stderr.String())
}
var items []lsjsonItem
if err := json.Unmarshal(stdout.Bytes(), &items); err != nil {
r.logger.Errorf("Failed to parse lsjson output: %v", err)
return nil, fmt.Errorf("failed to parse lsjson output: %w", err)
}
files := make([]storagetypes.FileInfo, 0, len(items))
for _, item := range items {
var modTime time.Time
if item.ModTime != "" {
parsedTime, err := time.Parse(time.RFC3339Nano, item.ModTime)
if err != nil {
r.logger.Warnf("Failed to parse mod time %q for %s: %v", item.ModTime, item.Name, err)
} 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,
})
}
r.logger.Debugf("Found %d files/directories in %s", len(files), dirPath)
return files, nil
}
// OpenFile implements storage.StorageReadable
func (r *Rclone) OpenFile(ctx context.Context, filePath string) (io.ReadCloser, int64, error) {
r.logger.Infof("Opening file %s", filePath)
remotePath := r.getRemotePath(filePath)
size, err := r.getFileSize(ctx, remotePath)
if err != nil {
r.logger.Errorf("Failed to get file size: %v", err)
return nil, 0, fmt.Errorf("%w: %v", ErrFailedToOpenFile, err)
}
args := r.buildBaseArgs()
args = append(args, "cat", remotePath)
cmd := exec.CommandContext(ctx, "rclone", args...)
stdout, err := cmd.StdoutPipe()
if err != nil {
return nil, 0, fmt.Errorf("failed to create stdout pipe: %w", err)
}
if err := cmd.Start(); err != nil {
return nil, 0, fmt.Errorf("failed to start rclone cat: %w", err)
}
reader := &rcloneCatReader{
reader: stdout,
cmd: cmd,
logger: r.logger,
}
r.logger.Debugf("Opened file %s (size: %d bytes)", filePath, size)
return reader, size, nil
}
func (r *Rclone) getFileSize(ctx context.Context, remotePath string) (int64, error) {
args := r.buildBaseArgs()
args = append(args, "lsjson", remotePath)
cmd := exec.CommandContext(ctx, "rclone", args...)
var stdout bytes.Buffer
cmd.Stdout = &stdout
if err := cmd.Run(); err != nil {
return 0, err
}
var items []lsjsonItem
if err := json.Unmarshal(stdout.Bytes(), &items); err != nil {
return 0, err
}
if len(items) > 0 {
return items[0].Size, nil
}
return 0, nil
}
type rcloneCatReader struct {
reader io.ReadCloser
cmd *exec.Cmd
logger *log.Logger
}
func (r *rcloneCatReader) Read(p []byte) (n int, err error) {
return r.reader.Read(p)
}
func (r *rcloneCatReader) Close() error {
if err := r.reader.Close(); err != nil {
r.logger.Warnf("Failed to close reader: %v", err)
}
if err := r.cmd.Wait(); err != nil {
r.logger.Warnf("rclone cat process exited with error: %v", err)
}
return nil
}

View File

@@ -11,6 +11,7 @@ import (
"github.com/krau/SaveAny-Bot/storage/alist"
"github.com/krau/SaveAny-Bot/storage/local"
"github.com/krau/SaveAny-Bot/storage/minio"
"github.com/krau/SaveAny-Bot/storage/rclone"
"github.com/krau/SaveAny-Bot/storage/s3"
"github.com/krau/SaveAny-Bot/storage/telegram"
"github.com/krau/SaveAny-Bot/storage/webdav"
@@ -53,6 +54,7 @@ var storageConstructors = map[storenum.StorageType]StorageConstructor{
storenum.Minio: func() Storage { return new(minio.Minio) },
storenum.S3: func() Storage { return new(s3.S3) },
storenum.Telegram: func() Storage { return new(telegram.Telegram) },
storenum.Rclone: func() Storage { return new(rclone.Rclone) },
}
// NewStorage creates a new storage instance based on the provided config and initializes it

View File

@@ -41,15 +41,15 @@ type Response struct {
}
type Propstat struct {
Prop Prop `xml:"prop"`
Prop Prop `xml:"prop"`
Status string `xml:"status"`
}
type Prop struct {
ResourceType ResourceType `xml:"resourcetype"`
GetContentLength int64 `xml:"getcontentlength"`
GetLastModified string `xml:"getlastmodified"`
DisplayName string `xml:"displayname"`
ResourceType ResourceType `xml:"resourcetype"`
GetContentLength int64 `xml:"getcontentlength"`
GetLastModified string `xml:"getlastmodified"`
DisplayName string `xml:"displayname"`
}
type ResourceType struct {