Compare commits

..

33 Commits

Author SHA1 Message Date
krau
f14c4367f8 feat: cancel download telegraph task 2025-03-22 12:08:19 +08:00
krau
3e3a320672 feat: download telegraph images , close #5 2025-03-22 11:52:43 +08:00
krau
19efab0665 feat: implement GenFileNameFromMessage function for improved file naming 2025-03-22 09:33:50 +08:00
krau
635f00ac71 fix: reorganize cache destination path handling in processPendingTask function 2025-03-21 23:28:14 +08:00
krau
2d2becccf6 refactor: update storage interface to use io.Reader for Save method and remove stream implementations 2025-03-21 23:05:09 +08:00
krau
ed0837a89b refactor: replace logger usage with common.Log for consistent logging 2025-03-21 21:07:53 +08:00
krau
65fee89e14 feat: refactor storage configuration to use dedicated storage package and add new storage types
BREAKING CHANGE: remove deprecated config
2025-03-21 20:52:41 +08:00
krau
8e180006f0 chore: update dependencies to latest versions 2025-03-16 21:55:52 +08:00
krau
721c9666eb refactor: streamline storage configuration loading and remove redundant code 2025-03-11 22:24:52 +08:00
krau
6f35401181 docs: update links in README_EN.md for consistency 2025-03-11 21:46:04 +08:00
Krau
72ae2ce079 Merge pull request #35 from ysicing/main
feat: add Minio storage support
2025-03-11 21:41:43 +08:00
ysicing
495ad3ea5c feat: add Minio storage support
Signed-off-by: ysicing <i@ysicing.me>
2025-03-11 21:29:35 +08:00
krau
3def9df4b4 docs: update alist faq 2025-03-03 10:59:08 +08:00
krau
790a32d297 fix(alist): do not upload file as task to prevent alist cache full file 2025-03-03 10:58:03 +08:00
krau
f7779224ef docs: update example link 2025-03-01 15:54:21 +08:00
krau
7d899ae088 ci: Is anyone really using Windows ARM? 2025-03-01 14:01:10 +08:00
krau
7e67bdb7e2 fix: update executable compression condition for Windows ARM64 in build-release workflow 2025-03-01 13:55:42 +08:00
krau
0071780ff4 typo: deploy 2025-03-01 13:44:04 +08:00
krau
0a95431468 feat: add name to build release workflow 2025-03-01 13:39:08 +08:00
krau
34525c5b11 feat: add docs 2025-03-01 13:37:09 +08:00
krau
6ac6d79fb6 feat: update docker-compose.yml to use host network mode for accessing host services 2025-03-01 12:31:20 +08:00
krau
f21a82ad43 chore: clean up README.md by removing unnecessary demo video section 2025-03-01 12:29:43 +08:00
Krau
73f6647f8d Merge pull request #33 from krau/dev-stream
impl webdav stream mode & progress callback for stream mode
2025-03-01 12:24:46 +08:00
krau
6fbb4609f9 feat: show progress for stream mode 2025-03-01 12:22:50 +08:00
krau
802c908384 feat: refactor webdav client and implement custom upload stream handling 2025-03-01 12:06:55 +08:00
Krau
5d403056d0 Merge pull request #32 from krau/dev-stream
feat: add stream upload support and related configurations
2025-02-28 12:17:10 +08:00
krau
8e2dd37155 feat: add stream upload support and related configurations 2025-02-28 11:09:24 +08:00
krau
9c7ed833fd ci: add upx support 2025-02-28 09:45:34 +08:00
Krau
f9d601bd8a Merge pull request #30 from krau/dev
feat: cancel task
2025-02-27 22:34:58 +08:00
krau
152f473131 fix: delete done task 2025-02-27 22:25:10 +08:00
krau
7015081a84 feat: add context cancellation handling in saveFileWithRetry function 2025-02-27 22:07:41 +08:00
krau
be6444cf96 feat: implement task cancellation feature and update task handling 2025-02-27 22:02:16 +08:00
krau
98ba7c50e7 refactor: remove unused StoragePath initialization in AddToQueue function 2025-02-27 21:32:14 +08:00
57 changed files with 1474 additions and 731 deletions

View File

@@ -1,3 +1,5 @@
name: Build Release
on: on:
push: push:
tags: tags:
@@ -36,6 +38,9 @@ jobs:
matrix: matrix:
goos: [linux, darwin, windows] goos: [linux, darwin, windows]
goarch: [amd64, arm64] goarch: [amd64, arm64]
exclude:
- goos: windows
goarch: arm64
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
@@ -53,6 +58,7 @@ jobs:
goos: ${{ matrix.goos }} goos: ${{ matrix.goos }}
goarch: ${{ matrix.goarch }} goarch: ${{ matrix.goarch }}
github_token: ${{ secrets.GITHUB_TOKEN }} github_token: ${{ secrets.GITHUB_TOKEN }}
executable_compression: upx
extra_files: | extra_files: |
LICENSE LICENSE
README.md README.md

22
.github/workflows/docs.yml vendored Normal file
View File

@@ -0,0 +1,22 @@
name: Deploy Docs
on:
push:
branches:
- main
paths:
- "docs/**"
workflow_dispatch:
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.11"
- uses: actions/cache@v4
with:
key: ${{ github.ref }}
path: .cache
- run: pip install mkdocs-material
- run: cd docs && mkdocs gh-deploy --force

View File

@@ -1,6 +1,5 @@
<div align="center"> <div align="center">
# <img src="docs/logo.jpg" width="45" align="center"> Save Any Bot # <img src="docs/logo.jpg" width="45" align="center"> Save Any Bot
**简体中文** | [English](README_EN.md) **简体中文** | [English](README_EN.md)
@@ -9,15 +8,6 @@
> _就像 PikPak Bot 一样_ > _就像 PikPak Bot 一样_
</div
Demo Video:
<div align="center">
[SaveAny-Bot 演示视频 The Demo of SaveAny-Bot.webm](https://github.com/user-attachments/assets/a0de2453-a4d1-4a12-81fb-9d84856dce09)
</div> </div>
## 部署 ## 部署

View File

@@ -10,21 +10,13 @@ Save Telegram files to various storage endpoints.
</div> </div>
Demo Video:
<div align="center">
[SaveAny-Bot Demo Video.webm](https://github.com/user-attachments/assets/a0de2453-a4d1-4a12-81fb-9d84856dce09)
</div>
## Deployment ## Deployment
### Deploy from Binary ### Deploy from Binary
Download the binary file for your platform from the [Release](https://github.com/krau/SaveAny-Bot/releases) page. Download the binary file for your platform from the [Release](https://github.com/krau/SaveAny-Bot/releases) page.
Create a `config.toml` file in the extracted directory, refer to [config.toml.example](https://github.com/krau/SaveAny-Bot/blob/main/config.example.toml) for configuration. Create a `config.toml` file in the extracted directory, refer to [config.example.toml](https://github.com/krau/SaveAny-Bot/blob/main/config.example.toml) for configuration.
Run: Run:
@@ -62,7 +54,7 @@ systemctl enable --now saveany-bot
#### Docker Compose #### Docker Compose
Download [docker-compose.yml](https://github.com/krau/SaveAny-Bot/blob/main/docker-compose.yml) file and create a `config.toml` file in the same directory, refer to [config.toml.example](https://github.com/krau/SaveAny-Bot/blob/main/config.example.toml) for configuration. Download [docker-compose.yml](https://github.com/krau/SaveAny-Bot/blob/main/docker-compose.yml) file and create a `config.toml` file in the same directory, refer to [config.example.toml](https://github.com/krau/SaveAny-Bot/blob/main/config.example.toml) for configuration.
Run: Run:

View File

@@ -11,8 +11,8 @@ import (
"github.com/glebarez/sqlite" "github.com/glebarez/sqlite"
"github.com/gotd/td/telegram/dcs" "github.com/gotd/td/telegram/dcs"
"github.com/gotd/td/tg" "github.com/gotd/td/tg"
"github.com/krau/SaveAny-Bot/common"
"github.com/krau/SaveAny-Bot/config" "github.com/krau/SaveAny-Bot/config"
"github.com/krau/SaveAny-Bot/logger"
"golang.org/x/net/proxy" "golang.org/x/net/proxy"
) )
@@ -27,7 +27,8 @@ func newProxyDialer(proxyUrl string) (proxy.Dialer, error) {
} }
func Init() { func Init() {
logger.L.Info("初始化 Telegram 客户端...") InitTelegraphClient()
common.Log.Info("初始化 Telegram 客户端...")
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel() defer cancel()
resultChan := make(chan struct { resultChan := make(chan struct {
@@ -87,15 +88,15 @@ func Init() {
select { select {
case <-ctx.Done(): case <-ctx.Done():
logger.L.Fatal("初始化客户端失败: 超时") common.Log.Fatal("初始化客户端失败: 超时")
os.Exit(1) os.Exit(1)
case result := <-resultChan: case result := <-resultChan:
if result.err != nil { if result.err != nil {
logger.L.Fatalf("初始化客户端失败: %s", result.err) common.Log.Fatalf("初始化客户端失败: %s", result.err)
os.Exit(1) os.Exit(1)
} }
Client = result.client Client = result.client
RegisterHandlers(Client.Dispatcher) RegisterHandlers(Client.Dispatcher)
logger.L.Info("客户端初始化完成") common.Log.Info("客户端初始化完成")
} }
} }

View File

@@ -13,9 +13,9 @@ import (
"github.com/gotd/td/telegram/message/entity" "github.com/gotd/td/telegram/message/entity"
"github.com/gotd/td/telegram/message/styling" "github.com/gotd/td/telegram/message/styling"
"github.com/gotd/td/tg" "github.com/gotd/td/tg"
"github.com/krau/SaveAny-Bot/common"
"github.com/krau/SaveAny-Bot/config" "github.com/krau/SaveAny-Bot/config"
"github.com/krau/SaveAny-Bot/dao" "github.com/krau/SaveAny-Bot/dao"
"github.com/krau/SaveAny-Bot/logger"
"github.com/krau/SaveAny-Bot/queue" "github.com/krau/SaveAny-Bot/queue"
"github.com/krau/SaveAny-Bot/types" "github.com/krau/SaveAny-Bot/types"
"gorm.io/gorm" "gorm.io/gorm"
@@ -33,11 +33,11 @@ func AddToQueue(ctx *ext.Context, update *ext.Update) error {
return dispatcher.EndGroups return dispatcher.EndGroups
} }
args := strings.Split(string(update.CallbackQuery.Data), " ") args := strings.Split(string(update.CallbackQuery.Data), " ")
addToDir := args[0] == "add_to_dir" addToDir := args[0] == "add_to_dir" // 已经选择了路径
cbDataId, _ := strconv.Atoi(args[1]) cbDataId, _ := strconv.Atoi(args[1])
cbData, err := dao.GetCallbackData(uint(cbDataId)) cbData, err := dao.GetCallbackData(uint(cbDataId))
if err != nil { if err != nil {
logger.L.Errorf("获取回调数据失败: %s", err) common.Log.Errorf("获取回调数据失败: %s", err)
ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{ ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{
QueryID: update.CallbackQuery.QueryID, QueryID: update.CallbackQuery.QueryID,
Alert: true, Alert: true,
@@ -56,7 +56,7 @@ func AddToQueue(ctx *ext.Context, update *ext.Update) error {
user, err := dao.GetUserByChatID(update.CallbackQuery.UserID) user, err := dao.GetUserByChatID(update.CallbackQuery.UserID)
if err != nil { if err != nil {
logger.L.Errorf("获取用户失败: %s", err) common.Log.Errorf("获取用户失败: %s", err)
ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{ ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{
QueryID: update.CallbackQuery.QueryID, QueryID: update.CallbackQuery.QueryID,
Alert: true, Alert: true,
@@ -69,7 +69,7 @@ func AddToQueue(ctx *ext.Context, update *ext.Update) error {
if !addToDir { if !addToDir {
dirs, err := dao.GetDirsByUserIDAndStorageName(user.ID, storageName) dirs, err := dao.GetDirsByUserIDAndStorageName(user.ID, storageName)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
logger.L.Errorf("获取路径失败: %s", err) common.Log.Errorf("获取路径失败: %s", err)
ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{ ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{
QueryID: update.CallbackQuery.QueryID, QueryID: update.CallbackQuery.QueryID,
Alert: true, Alert: true,
@@ -81,7 +81,7 @@ func AddToQueue(ctx *ext.Context, update *ext.Update) error {
if len(dirs) != 0 { if len(dirs) != 0 {
markup, err := getSelectDirMarkup(fileChatID, fileMessageID, storageName, dirs) markup, err := getSelectDirMarkup(fileChatID, fileMessageID, storageName, dirs)
if err != nil { if err != nil {
logger.L.Errorf("获取路径失败: %s", err) common.Log.Errorf("获取路径失败: %s", err)
ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{ ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{
QueryID: update.CallbackQuery.QueryID, QueryID: update.CallbackQuery.QueryID,
Alert: true, Alert: true,
@@ -96,16 +96,16 @@ func AddToQueue(ctx *ext.Context, update *ext.Update) error {
ReplyMarkup: markup, ReplyMarkup: markup,
}) })
if err != nil { if err != nil {
logger.L.Errorf("编辑消息失败: %s", err) common.Log.Errorf("编辑消息失败: %s", err)
} }
return dispatcher.EndGroups return dispatcher.EndGroups
} }
} }
logger.L.Tracef("Got add to queue: chatID: %d, messageID: %d, storage: %s", fileChatID, fileMessageID, storageName) common.Log.Tracef("Got add to queue: chatID: %d, messageID: %d, storage: %s", fileChatID, fileMessageID, storageName)
record, err := dao.GetReceivedFileByChatAndMessageID(int64(fileChatID), fileMessageID) record, err := dao.GetReceivedFileByChatAndMessageID(int64(fileChatID), fileMessageID)
if err != nil { if err != nil {
logger.L.Errorf("获取记录失败: %s", err) common.Log.Errorf("获取记录失败: %s", err)
ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{ ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{
QueryID: update.CallbackQuery.QueryID, QueryID: update.CallbackQuery.QueryID,
Alert: true, Alert: true,
@@ -117,7 +117,7 @@ func AddToQueue(ctx *ext.Context, update *ext.Update) error {
if update.CallbackQuery.MsgID != record.ReplyMessageID { if update.CallbackQuery.MsgID != record.ReplyMessageID {
record.ReplyMessageID = update.CallbackQuery.MsgID record.ReplyMessageID = update.CallbackQuery.MsgID
if err := dao.SaveReceivedFile(record); err != nil { if err := dao.SaveReceivedFile(record); err != nil {
logger.L.Errorf("更新接收的文件失败: %s", err) common.Log.Errorf("更新接收的文件失败: %s", err)
} }
} }
@@ -125,7 +125,7 @@ func AddToQueue(ctx *ext.Context, update *ext.Update) error {
if addToDir && dirId != 0 { if addToDir && dirId != 0 {
dir, err = dao.GetDirByID(dirId) dir, err = dao.GetDirByID(dirId)
if err != nil { if err != nil {
logger.L.Errorf("获取路径失败: %s", err) common.Log.Errorf("获取路径失败: %s", err)
ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{ ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{
QueryID: update.CallbackQuery.QueryID, QueryID: update.CallbackQuery.QueryID,
Alert: true, Alert: true,
@@ -136,35 +136,53 @@ func AddToQueue(ctx *ext.Context, update *ext.Update) error {
} }
} }
file, err := FileFromMessage(ctx, record.ChatID, record.MessageID, record.FileName) var task types.Task
if err != nil { if record.IsTelegraph {
logger.L.Errorf("获取消息中的文件失败: %s", err) task = types.Task{
ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{ Ctx: ctx,
QueryID: update.CallbackQuery.QueryID, Status: types.Pending,
Alert: true, IsTelegraph: true,
Message: fmt.Sprintf("获取消息中的文件失败: %s", err), TelegraphURL: record.TelegraphURL,
CacheTime: 5, StorageName: storageName,
}) FileChatID: record.ChatID,
return dispatcher.EndGroups FileMessageID: record.MessageID,
ReplyMessageID: record.ReplyMessageID,
ReplyChatID: record.ReplyChatID,
UserID: update.GetUserChat().GetID(),
}
if dir != nil {
task.StoragePath = path.Join(dir.Path, record.FileName)
}
} else {
file, err := FileFromMessage(ctx, record.ChatID, record.MessageID, record.FileName)
if err != nil {
common.Log.Errorf("获取消息中的文件失败: %s", err)
ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{
QueryID: update.CallbackQuery.QueryID,
Alert: true,
Message: fmt.Sprintf("获取消息中的文件失败: %s", err),
CacheTime: 5,
})
return dispatcher.EndGroups
}
task = types.Task{
Ctx: ctx,
Status: types.Pending,
File: file,
StorageName: storageName,
FileChatID: record.ChatID,
ReplyMessageID: record.ReplyMessageID,
FileMessageID: record.MessageID,
ReplyChatID: record.ReplyChatID,
UserID: update.GetUserChat().GetID(),
}
if dir != nil {
task.StoragePath = path.Join(dir.Path, file.FileName)
}
} }
task := types.Task{ queue.AddTask(&task)
Ctx: ctx,
Status: types.Pending,
File: file,
StorageName: storageName,
StoragePath: path.Join(),
FileChatID: record.ChatID,
ReplyMessageID: record.ReplyMessageID,
FileMessageID: record.MessageID,
ReplyChatID: record.ReplyChatID,
UserID: update.GetUserChat().GetID(),
}
if dir != nil {
task.StoragePath = path.Join(dir.Path, file.FileName)
}
queue.AddTask(task)
entityBuilder := entity.Builder{} entityBuilder := entity.Builder{}
var entities []tg.MessageEntityClass var entities []tg.MessageEntityClass
@@ -175,7 +193,7 @@ func AddToQueue(ctx *ext.Context, update *ext.Update) error {
styling.Plain("\n当前排队任务数: "), styling.Plain("\n当前排队任务数: "),
styling.Bold(strconv.Itoa(queue.Len())), styling.Bold(strconv.Itoa(queue.Len())),
); err != nil { ); err != nil {
logger.L.Errorf("Failed to build entity: %s", err) common.Log.Errorf("Failed to build entity: %s", err)
} else { } else {
text, entities = entityBuilder.Complete() text, entities = entityBuilder.Complete()
} }

27
bot/handle_cancel_task.go Normal file
View File

@@ -0,0 +1,27 @@
package bot
import (
"strings"
"github.com/celestix/gotgproto/dispatcher"
"github.com/celestix/gotgproto/ext"
"github.com/gotd/td/tg"
"github.com/krau/SaveAny-Bot/queue"
)
func cancelTask(ctx *ext.Context, update *ext.Update) error {
key := strings.Split(string(update.CallbackQuery.Data), " ")[1]
ok := queue.CancelTask(key)
if ok {
ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{
QueryID: update.CallbackQuery.QueryID,
Message: "任务已取消",
})
return dispatcher.EndGroups
}
ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{
QueryID: update.CallbackQuery.QueryID,
Message: "任务取消失败",
})
return dispatcher.EndGroups
}

View File

@@ -69,7 +69,7 @@ func (c *ConversationState) SetData(key string, value interface{}) {
// func handleConversationState(ctx *ext.Context, update *ext.Update, state *ConversationState) error { // func handleConversationState(ctx *ext.Context, update *ext.Update, state *ConversationState) error {
// switch state.conversationType { // switch state.conversationType {
// default: // default:
// logger.L.Errorf("Unknown conversation type: %s", state.conversationType) // common.Log.Errorf("Unknown conversation type: %s", state.conversationType)
// } // }
// return dispatcher.EndGroups // return dispatcher.EndGroups
// } // }

View File

@@ -6,8 +6,8 @@ import (
"github.com/celestix/gotgproto/dispatcher" "github.com/celestix/gotgproto/dispatcher"
"github.com/celestix/gotgproto/ext" "github.com/celestix/gotgproto/ext"
"github.com/gotd/td/telegram/message/styling" "github.com/gotd/td/telegram/message/styling"
"github.com/krau/SaveAny-Bot/common"
"github.com/krau/SaveAny-Bot/dao" "github.com/krau/SaveAny-Bot/dao"
"github.com/krau/SaveAny-Bot/logger"
"github.com/krau/SaveAny-Bot/storage" "github.com/krau/SaveAny-Bot/storage"
) )
@@ -16,7 +16,7 @@ func dirCmd(ctx *ext.Context, update *ext.Update) error {
if len(args) < 3 { if len(args) < 3 {
dirs, err := dao.GetUserDirsByChatID(update.GetUserChat().GetID()) dirs, err := dao.GetUserDirsByChatID(update.GetUserChat().GetID())
if err != nil { if err != nil {
logger.L.Errorf("获取用户路径失败: %s", err) common.Log.Errorf("获取用户路径失败: %s", err)
ctx.Reply(update, ext.ReplyTextString("获取用户路径失败"), nil) ctx.Reply(update, ext.ReplyTextString("获取用户路径失败"), nil)
return dispatcher.EndGroups return dispatcher.EndGroups
} }
@@ -47,7 +47,7 @@ func dirCmd(ctx *ext.Context, update *ext.Update) error {
} }
user, err := dao.GetUserByChatID(update.GetUserChat().GetID()) user, err := dao.GetUserByChatID(update.GetUserChat().GetID())
if err != nil { if err != nil {
logger.L.Errorf("获取用户失败: %s", err) common.Log.Errorf("获取用户失败: %s", err)
ctx.Reply(update, ext.ReplyTextString("获取用户失败"), nil) ctx.Reply(update, ext.ReplyTextString("获取用户失败"), nil)
return dispatcher.EndGroups return dispatcher.EndGroups
} }
@@ -69,7 +69,7 @@ func addDir(ctx *ext.Context, update *ext.Update, user *dao.User, storageName, p
} }
if err := dao.CreateDirForUser(user.ID, storageName, path); err != nil { if err := dao.CreateDirForUser(user.ID, storageName, path); err != nil {
logger.L.Errorf("创建路径失败: %s", err) common.Log.Errorf("创建路径失败: %s", err)
ctx.Reply(update, ext.ReplyTextString("创建路径失败"), nil) ctx.Reply(update, ext.ReplyTextString("创建路径失败"), nil)
return dispatcher.EndGroups return dispatcher.EndGroups
} }
@@ -79,7 +79,7 @@ func addDir(ctx *ext.Context, update *ext.Update, user *dao.User, storageName, p
func delDir(ctx *ext.Context, update *ext.Update, user *dao.User, storageName, path string) error { func delDir(ctx *ext.Context, update *ext.Update, user *dao.User, storageName, path string) error {
if err := dao.DeleteDirForUser(user.ID, storageName, path); err != nil { if err := dao.DeleteDirForUser(user.ID, storageName, path); err != nil {
logger.L.Errorf("删除路径失败: %s", err) common.Log.Errorf("删除路径失败: %s", err)
ctx.Reply(update, ext.ReplyTextString("删除路径失败"), nil) ctx.Reply(update, ext.ReplyTextString("删除路径失败"), nil)
return dispatcher.EndGroups return dispatcher.EndGroups
} }

View File

@@ -6,14 +6,14 @@ import (
"github.com/celestix/gotgproto/dispatcher" "github.com/celestix/gotgproto/dispatcher"
"github.com/celestix/gotgproto/ext" "github.com/celestix/gotgproto/ext"
"github.com/gotd/td/tg" "github.com/gotd/td/tg"
"github.com/krau/SaveAny-Bot/common"
"github.com/krau/SaveAny-Bot/dao" "github.com/krau/SaveAny-Bot/dao"
"github.com/krau/SaveAny-Bot/logger"
"github.com/krau/SaveAny-Bot/storage" "github.com/krau/SaveAny-Bot/storage"
"github.com/krau/SaveAny-Bot/types" "github.com/krau/SaveAny-Bot/types"
) )
func handleFileMessage(ctx *ext.Context, update *ext.Update) error { func handleFileMessage(ctx *ext.Context, update *ext.Update) error {
logger.L.Trace("Got media: ", update.EffectiveMessage.Media.TypeName()) common.Log.Trace("Got media: ", update.EffectiveMessage.Media.TypeName())
supported, err := supportedMediaFilter(update.EffectiveMessage.Message) supported, err := supportedMediaFilter(update.EffectiveMessage.Message)
if err != nil { if err != nil {
return err return err
@@ -24,7 +24,7 @@ func handleFileMessage(ctx *ext.Context, update *ext.Update) error {
user, err := dao.GetUserByChatID(update.GetUserChat().GetID()) user, err := dao.GetUserByChatID(update.GetUserChat().GetID())
if err != nil { if err != nil {
logger.L.Errorf("获取用户失败: %s", err) common.Log.Errorf("获取用户失败: %s", err)
ctx.Reply(update, ext.ReplyTextString("获取用户失败"), nil) ctx.Reply(update, ext.ReplyTextString("获取用户失败"), nil)
return dispatcher.EndGroups return dispatcher.EndGroups
} }
@@ -36,18 +36,18 @@ func handleFileMessage(ctx *ext.Context, update *ext.Update) error {
msg, err := ctx.Reply(update, ext.ReplyTextString("正在获取文件信息..."), nil) msg, err := ctx.Reply(update, ext.ReplyTextString("正在获取文件信息..."), nil)
if err != nil { if err != nil {
logger.L.Errorf("回复失败: %s", err) common.Log.Errorf("回复失败: %s", err)
return dispatcher.EndGroups return dispatcher.EndGroups
} }
media := update.EffectiveMessage.Media media := update.EffectiveMessage.Media
file, err := FileFromMedia(media, "") file, err := FileFromMedia(media, "")
if err != nil { if err != nil {
logger.L.Errorf("获取文件失败: %s", err) common.Log.Errorf("获取文件失败: %s", err)
ctx.Reply(update, ext.ReplyTextString(fmt.Sprintf("获取文件失败: %s", err)), nil) ctx.Reply(update, ext.ReplyTextString(fmt.Sprintf("获取文件失败: %s", err)), nil)
return dispatcher.EndGroups return dispatcher.EndGroups
} }
if file.FileName == "" { if file.FileName == "" {
file.FileName = fmt.Sprintf("%d_%d_%s", update.EffectiveChat().GetID(), update.EffectiveMessage.ID, file.Hash()) file.FileName = GenFileNameFromMessage(*update.EffectiveMessage.Message, file)
} }
if err := dao.SaveReceivedFile(&dao.ReceivedFile{ if err := dao.SaveReceivedFile(&dao.ReceivedFile{
@@ -58,18 +58,18 @@ func handleFileMessage(ctx *ext.Context, update *ext.Update) error {
ReplyMessageID: msg.ID, ReplyMessageID: msg.ID,
ReplyChatID: update.GetUserChat().GetID(), ReplyChatID: update.GetUserChat().GetID(),
}); err != nil { }); err != nil {
logger.L.Errorf("添加接收的文件失败: %s", err) common.Log.Errorf("添加接收的文件失败: %s", err)
if _, err := ctx.EditMessage(update.EffectiveChat().GetID(), &tg.MessagesEditMessageRequest{ if _, err := ctx.EditMessage(update.EffectiveChat().GetID(), &tg.MessagesEditMessageRequest{
Message: fmt.Sprintf("添加接收的文件失败: %s", err), Message: fmt.Sprintf("添加接收的文件失败: %s", err),
ID: msg.ID, ID: msg.ID,
}); err != nil { }); err != nil {
logger.L.Errorf("编辑消息失败: %s", err) common.Log.Errorf("编辑消息失败: %s", err)
} }
return dispatcher.EndGroups return dispatcher.EndGroups
} }
if !user.Silent || user.DefaultStorage == "" { if !user.Silent || user.DefaultStorage == "" {
return ProvideSelectMessage(ctx, update, file, update.EffectiveChat().GetID(), update.EffectiveMessage.ID, msg.ID) return ProvideSelectMessage(ctx, update, file.FileName, update.EffectiveChat().GetID(), update.EffectiveMessage.ID, msg.ID)
} }
return HandleSilentAddTask(ctx, update, user, &types.Task{ return HandleSilentAddTask(ctx, update, user, &types.Task{
Ctx: ctx, Ctx: ctx,

View File

@@ -1,7 +1,6 @@
package bot package bot
import ( import (
"fmt"
"regexp" "regexp"
"strconv" "strconv"
"strings" "strings"
@@ -9,8 +8,8 @@ import (
"github.com/celestix/gotgproto/dispatcher" "github.com/celestix/gotgproto/dispatcher"
"github.com/celestix/gotgproto/ext" "github.com/celestix/gotgproto/ext"
"github.com/gotd/td/tg" "github.com/gotd/td/tg"
"github.com/krau/SaveAny-Bot/common"
"github.com/krau/SaveAny-Bot/dao" "github.com/krau/SaveAny-Bot/dao"
"github.com/krau/SaveAny-Bot/logger"
"github.com/krau/SaveAny-Bot/storage" "github.com/krau/SaveAny-Bot/storage"
"github.com/krau/SaveAny-Bot/types" "github.com/krau/SaveAny-Bot/types"
) )
@@ -21,7 +20,7 @@ var (
) )
func handleLinkMessage(ctx *ext.Context, update *ext.Update) error { func handleLinkMessage(ctx *ext.Context, update *ext.Update) error {
logger.L.Trace("Got link message") common.Log.Trace("Got link message")
link := linkRegex.FindString(update.EffectiveMessage.Text) link := linkRegex.FindString(update.EffectiveMessage.Text)
if link == "" { if link == "" {
return dispatcher.ContinueGroups return dispatcher.ContinueGroups
@@ -32,25 +31,25 @@ func handleLinkMessage(ctx *ext.Context, update *ext.Update) error {
} }
messageID, err := strconv.Atoi(strSlice[2]) messageID, err := strconv.Atoi(strSlice[2])
if err != nil { if err != nil {
logger.L.Errorf("解析消息 ID 失败: %s", err) common.Log.Errorf("解析消息 ID 失败: %s", err)
ctx.Reply(update, ext.ReplyTextString("无法解析消息 ID"), nil) ctx.Reply(update, ext.ReplyTextString("无法解析消息 ID"), nil)
return dispatcher.EndGroups return dispatcher.EndGroups
} }
chatUsername := strSlice[1] chatUsername := strSlice[1]
linkChat, err := ctx.ResolveUsername(chatUsername) linkChat, err := ctx.ResolveUsername(chatUsername)
if err != nil { if err != nil {
logger.L.Errorf("解析 Chat ID 失败: %s", err) common.Log.Errorf("解析 Chat ID 失败: %s", err)
ctx.Reply(update, ext.ReplyTextString("无法解析 Chat ID"), nil) ctx.Reply(update, ext.ReplyTextString("无法解析 Chat ID"), nil)
return dispatcher.EndGroups return dispatcher.EndGroups
} }
if linkChat == nil { if linkChat == nil {
logger.L.Errorf("无法找到聊天: %s", chatUsername) common.Log.Errorf("无法找到聊天: %s", chatUsername)
ctx.Reply(update, ext.ReplyTextString("无法找到聊天"), nil) ctx.Reply(update, ext.ReplyTextString("无法找到聊天"), nil)
return dispatcher.EndGroups return dispatcher.EndGroups
} }
user, err := dao.GetUserByChatID(update.GetUserChat().GetID()) user, err := dao.GetUserByChatID(update.GetUserChat().GetID())
if err != nil { if err != nil {
logger.L.Errorf("获取用户失败: %s", err) common.Log.Errorf("获取用户失败: %s", err)
ctx.Reply(update, ext.ReplyTextString("获取用户失败"), nil) ctx.Reply(update, ext.ReplyTextString("获取用户失败"), nil)
return dispatcher.EndGroups return dispatcher.EndGroups
} }
@@ -62,20 +61,18 @@ func handleLinkMessage(ctx *ext.Context, update *ext.Update) error {
} }
replied, err := ctx.Reply(update, ext.ReplyTextString("正在获取文件..."), nil) replied, err := ctx.Reply(update, ext.ReplyTextString("正在获取文件..."), nil)
if err != nil { if err != nil {
logger.L.Errorf("回复失败: %s", err) common.Log.Errorf("回复失败: %s", err)
return dispatcher.EndGroups return dispatcher.EndGroups
} }
file, err := FileFromMessage(ctx, linkChat.GetID(), messageID, "") file, err := FileFromMessage(ctx, linkChat.GetID(), messageID, "")
if err != nil { if err != nil {
logger.L.Errorf("获取文件失败: %s", err) common.Log.Errorf("获取文件失败: %s", err)
ctx.Reply(update, ext.ReplyTextString("获取文件失败: "+err.Error()), nil) ctx.Reply(update, ext.ReplyTextString("获取文件失败: "+err.Error()), nil)
return dispatcher.EndGroups return dispatcher.EndGroups
} }
// TODO: Better file name
if file.FileName == "" { if file.FileName == "" {
logger.L.Warnf("文件名为空,使用生成的名称") file.FileName = GenFileNameFromMessage(*update.EffectiveMessage.Message, file)
file.FileName = fmt.Sprintf("%d_%d_%s", linkChat.GetID(), messageID, file.Hash())
} }
receivedFile := &dao.ReceivedFile{ receivedFile := &dao.ReceivedFile{
@@ -87,7 +84,7 @@ func handleLinkMessage(ctx *ext.Context, update *ext.Update) error {
ReplyChatID: update.GetUserChat().GetID(), ReplyChatID: update.GetUserChat().GetID(),
} }
if err := dao.SaveReceivedFile(receivedFile); err != nil { if err := dao.SaveReceivedFile(receivedFile); err != nil {
logger.L.Errorf("保存接收的文件失败: %s", err) common.Log.Errorf("保存接收的文件失败: %s", err)
ctx.EditMessage(update.EffectiveChat().GetID(), &tg.MessagesEditMessageRequest{ ctx.EditMessage(update.EffectiveChat().GetID(), &tg.MessagesEditMessageRequest{
Message: "无法保存文件: " + err.Error(), Message: "无法保存文件: " + err.Error(),
ID: replied.ID, ID: replied.ID,
@@ -95,7 +92,7 @@ func handleLinkMessage(ctx *ext.Context, update *ext.Update) error {
return dispatcher.EndGroups return dispatcher.EndGroups
} }
if !user.Silent || user.DefaultStorage == "" { if !user.Silent || user.DefaultStorage == "" {
return ProvideSelectMessage(ctx, update, file, linkChat.GetID(), messageID, replied.ID) return ProvideSelectMessage(ctx, update, file.FileName, linkChat.GetID(), messageID, replied.ID)
} }
return HandleSilentAddTask(ctx, update, user, &types.Task{ return HandleSilentAddTask(ctx, update, user, &types.Task{
Ctx: ctx, Ctx: ctx,

View File

@@ -7,8 +7,8 @@ import (
"github.com/celestix/gotgproto/dispatcher" "github.com/celestix/gotgproto/dispatcher"
"github.com/celestix/gotgproto/ext" "github.com/celestix/gotgproto/ext"
"github.com/gotd/td/tg" "github.com/gotd/td/tg"
"github.com/krau/SaveAny-Bot/common"
"github.com/krau/SaveAny-Bot/dao" "github.com/krau/SaveAny-Bot/dao"
"github.com/krau/SaveAny-Bot/logger"
"github.com/krau/SaveAny-Bot/storage" "github.com/krau/SaveAny-Bot/storage"
"github.com/krau/SaveAny-Bot/types" "github.com/krau/SaveAny-Bot/types"
) )
@@ -32,7 +32,7 @@ func saveCmd(ctx *ext.Context, update *ext.Update) error {
user, err := dao.GetUserByChatID(update.GetUserChat().GetID()) user, err := dao.GetUserByChatID(update.GetUserChat().GetID())
if err != nil { if err != nil {
logger.L.Errorf("获取用户失败: %s", err) common.Log.Errorf("获取用户失败: %s", err)
ctx.Reply(update, ext.ReplyTextString("获取用户失败"), nil) ctx.Reply(update, ext.ReplyTextString("获取用户失败"), nil)
return dispatcher.EndGroups return dispatcher.EndGroups
} }
@@ -46,7 +46,7 @@ func saveCmd(ctx *ext.Context, update *ext.Update) error {
msg, err := GetTGMessage(ctx, update.EffectiveChat().GetID(), replyToMsgID) msg, err := GetTGMessage(ctx, update.EffectiveChat().GetID(), replyToMsgID)
if err != nil { if err != nil {
logger.L.Errorf("获取消息失败: %s", err) common.Log.Errorf("获取消息失败: %s", err)
ctx.Reply(update, ext.ReplyTextString("无法获取消息"), nil) ctx.Reply(update, ext.ReplyTextString("无法获取消息"), nil)
return dispatcher.EndGroups return dispatcher.EndGroups
} }
@@ -59,7 +59,7 @@ func saveCmd(ctx *ext.Context, update *ext.Update) error {
replied, err := ctx.Reply(update, ext.ReplyTextString("正在获取文件信息..."), nil) replied, err := ctx.Reply(update, ext.ReplyTextString("正在获取文件信息..."), nil)
if err != nil { if err != nil {
logger.L.Errorf("回复失败: %s", err) common.Log.Errorf("回复失败: %s", err)
return dispatcher.EndGroups return dispatcher.EndGroups
} }
@@ -68,7 +68,7 @@ func saveCmd(ctx *ext.Context, update *ext.Update) error {
file, err := FileFromMessage(ctx, update.EffectiveChat().GetID(), msg.ID, customFileName) file, err := FileFromMessage(ctx, update.EffectiveChat().GetID(), msg.ID, customFileName)
if err != nil { if err != nil {
logger.L.Errorf("获取文件失败: %s", err) common.Log.Errorf("获取文件失败: %s", err)
ctx.EditMessage(update.EffectiveChat().GetID(), &tg.MessagesEditMessageRequest{ ctx.EditMessage(update.EffectiveChat().GetID(), &tg.MessagesEditMessageRequest{
Message: fmt.Sprintf("获取文件失败: %s", err), Message: fmt.Sprintf("获取文件失败: %s", err),
ID: replied.ID, ID: replied.ID,
@@ -76,9 +76,8 @@ func saveCmd(ctx *ext.Context, update *ext.Update) error {
return dispatcher.EndGroups return dispatcher.EndGroups
} }
// TODO: better file name
if file.FileName == "" { if file.FileName == "" {
file.FileName = fmt.Sprintf("%d_%d_%s", update.EffectiveChat().GetID(), replyToMsgID, file.Hash()) file.FileName = GenFileNameFromMessage(*msg, file)
} }
receivedFile := &dao.ReceivedFile{ receivedFile := &dao.ReceivedFile{
Processing: false, Processing: false,
@@ -90,17 +89,17 @@ func saveCmd(ctx *ext.Context, update *ext.Update) error {
} }
if err := dao.SaveReceivedFile(receivedFile); err != nil { if err := dao.SaveReceivedFile(receivedFile); err != nil {
logger.L.Errorf("保存接收的文件失败: %s", err) common.Log.Errorf("保存接收的文件失败: %s", err)
if _, err := ctx.EditMessage(update.EffectiveChat().GetID(), &tg.MessagesEditMessageRequest{ if _, err := ctx.EditMessage(update.EffectiveChat().GetID(), &tg.MessagesEditMessageRequest{
Message: fmt.Sprintf("保存接收的文件失败: %s", err), Message: fmt.Sprintf("保存接收的文件失败: %s", err),
ID: replied.ID, ID: replied.ID,
}); err != nil { }); err != nil {
logger.L.Errorf("编辑消息失败: %s", err) common.Log.Errorf("编辑消息失败: %s", err)
} }
return dispatcher.EndGroups return dispatcher.EndGroups
} }
if !user.Silent || user.DefaultStorage == "" { if !user.Silent || user.DefaultStorage == "" {
return ProvideSelectMessage(ctx, update, file, update.EffectiveChat().GetID(), msg.ID, replied.ID) return ProvideSelectMessage(ctx, update, file.FileName, update.EffectiveChat().GetID(), msg.ID, replied.ID)
} }
return HandleSilentAddTask(ctx, update, user, &types.Task{ return HandleSilentAddTask(ctx, update, user, &types.Task{
Ctx: ctx, Ctx: ctx,

View File

@@ -5,14 +5,14 @@ import (
"github.com/celestix/gotgproto/dispatcher" "github.com/celestix/gotgproto/dispatcher"
"github.com/celestix/gotgproto/ext" "github.com/celestix/gotgproto/ext"
"github.com/krau/SaveAny-Bot/common"
"github.com/krau/SaveAny-Bot/dao" "github.com/krau/SaveAny-Bot/dao"
"github.com/krau/SaveAny-Bot/logger"
) )
func silent(ctx *ext.Context, update *ext.Update) error { func silent(ctx *ext.Context, update *ext.Update) error {
user, err := dao.GetUserByChatID(update.GetUserChat().GetID()) user, err := dao.GetUserByChatID(update.GetUserChat().GetID())
if err != nil { if err != nil {
logger.L.Errorf("获取用户失败: %s", err) common.Log.Errorf("获取用户失败: %s", err)
return dispatcher.EndGroups return dispatcher.EndGroups
} }
if !user.Silent && user.DefaultStorage == "" { if !user.Silent && user.DefaultStorage == "" {
@@ -21,7 +21,7 @@ func silent(ctx *ext.Context, update *ext.Update) error {
} }
user.Silent = !user.Silent user.Silent = !user.Silent
if err := dao.UpdateUser(user); err != nil { if err := dao.UpdateUser(user); err != nil {
logger.L.Errorf("更新用户失败: %s", err) common.Log.Errorf("更新用户失败: %s", err)
ctx.Reply(update, ext.ReplyTextString("更新用户失败"), nil) ctx.Reply(update, ext.ReplyTextString("更新用户失败"), nil)
return dispatcher.EndGroups return dispatcher.EndGroups
} }

View File

@@ -3,13 +3,13 @@ package bot
import ( import (
"github.com/celestix/gotgproto/dispatcher" "github.com/celestix/gotgproto/dispatcher"
"github.com/celestix/gotgproto/ext" "github.com/celestix/gotgproto/ext"
"github.com/krau/SaveAny-Bot/common"
"github.com/krau/SaveAny-Bot/dao" "github.com/krau/SaveAny-Bot/dao"
"github.com/krau/SaveAny-Bot/logger"
) )
func start(ctx *ext.Context, update *ext.Update) error { func start(ctx *ext.Context, update *ext.Update) error {
if err := dao.CreateUser(update.GetUserChat().GetID()); err != nil { if err := dao.CreateUser(update.GetUserChat().GetID()); err != nil {
logger.L.Errorf("创建用户失败: %s", err) common.Log.Errorf("创建用户失败: %s", err)
return dispatcher.EndGroups return dispatcher.EndGroups
} }
return help(ctx, update) return help(ctx, update)

View File

@@ -8,8 +8,8 @@ import (
"github.com/celestix/gotgproto/dispatcher" "github.com/celestix/gotgproto/dispatcher"
"github.com/celestix/gotgproto/ext" "github.com/celestix/gotgproto/ext"
"github.com/gotd/td/tg" "github.com/gotd/td/tg"
"github.com/krau/SaveAny-Bot/common"
"github.com/krau/SaveAny-Bot/dao" "github.com/krau/SaveAny-Bot/dao"
"github.com/krau/SaveAny-Bot/logger"
"github.com/krau/SaveAny-Bot/storage" "github.com/krau/SaveAny-Bot/storage"
) )
@@ -22,7 +22,7 @@ func storageCmd(ctx *ext.Context, update *ext.Update) error {
} }
markup, err := getSetDefaultStorageMarkup(userChatID, storages) markup, err := getSetDefaultStorageMarkup(userChatID, storages)
if err != nil { if err != nil {
logger.L.Errorf("Failed to get markup: %s", err) common.Log.Errorf("Failed to get markup: %s", err)
ctx.Reply(update, ext.ReplyTextString("获取存储位置失败"), nil) ctx.Reply(update, ext.ReplyTextString("获取存储位置失败"), nil)
return dispatcher.EndGroups return dispatcher.EndGroups
} }
@@ -47,7 +47,7 @@ func setDefaultStorage(ctx *ext.Context, update *ext.Update) error {
cbDataId, _ := strconv.Atoi(args[2]) cbDataId, _ := strconv.Atoi(args[2])
storageName, err := dao.GetCallbackData(uint(cbDataId)) storageName, err := dao.GetCallbackData(uint(cbDataId))
if err != nil { if err != nil {
logger.L.Errorf("获取回调数据失败: %s", err) common.Log.Errorf("获取回调数据失败: %s", err)
ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{ ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{
QueryID: update.CallbackQuery.QueryID, QueryID: update.CallbackQuery.QueryID,
Alert: true, Alert: true,
@@ -60,7 +60,7 @@ func setDefaultStorage(ctx *ext.Context, update *ext.Update) error {
selectedStorage, err := storage.GetStorageByName(storageName) selectedStorage, err := storage.GetStorageByName(storageName)
if err != nil { if err != nil {
logger.L.Errorf("获取指定存储失败: %s", err) common.Log.Errorf("获取指定存储失败: %s", err)
ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{ ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{
QueryID: update.CallbackQuery.QueryID, QueryID: update.CallbackQuery.QueryID,
Alert: true, Alert: true,
@@ -71,7 +71,7 @@ func setDefaultStorage(ctx *ext.Context, update *ext.Update) error {
} }
user, err := dao.GetUserByChatID(int64(userID)) user, err := dao.GetUserByChatID(int64(userID))
if err != nil { if err != nil {
logger.L.Errorf("Failed to get user: %s", err) common.Log.Errorf("Failed to get user: %s", err)
ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{ ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{
QueryID: update.CallbackQuery.QueryID, QueryID: update.CallbackQuery.QueryID,
Alert: true, Alert: true,
@@ -82,7 +82,7 @@ func setDefaultStorage(ctx *ext.Context, update *ext.Update) error {
} }
user.DefaultStorage = storageName user.DefaultStorage = storageName
if err := dao.UpdateUser(user); err != nil { if err := dao.UpdateUser(user); err != nil {
logger.L.Errorf("Failed to update user: %s", err) common.Log.Errorf("Failed to update user: %s", err)
ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{ ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{
QueryID: update.CallbackQuery.QueryID, QueryID: update.CallbackQuery.QueryID,
Alert: true, Alert: true,

114
bot/handle_telegraph.go Normal file
View File

@@ -0,0 +1,114 @@
package bot
import (
"fmt"
"net/http"
"net/url"
"regexp"
"strings"
"time"
"github.com/celestix/gotgproto/dispatcher"
"github.com/celestix/gotgproto/ext"
"github.com/celestix/telegraph-go/v2"
"github.com/gotd/td/tg"
"github.com/krau/SaveAny-Bot/common"
"github.com/krau/SaveAny-Bot/config"
"github.com/krau/SaveAny-Bot/dao"
"github.com/krau/SaveAny-Bot/storage"
"github.com/krau/SaveAny-Bot/types"
)
var (
TelegraphClient *telegraph.TelegraphClient
TelegraphUrlRegexString = `https://telegra.ph/.*`
TelegraphUrlRegex = regexp.MustCompile(TelegraphUrlRegexString)
)
func InitTelegraphClient() {
var httpClient *http.Client
if config.Cfg.Telegram.Proxy.Enable {
proxyUrl, err := url.Parse(config.Cfg.Telegram.Proxy.URL)
if err != nil {
fmt.Println("Error parsing proxy URL:", err)
return
}
proxy := http.ProxyURL(proxyUrl)
httpClient = &http.Client{
Transport: &http.Transport{
Proxy: proxy,
},
Timeout: 30 * time.Second,
}
} else {
httpClient = &http.Client{
Timeout: 30 * time.Second,
}
}
TelegraphClient = telegraph.GetTelegraphClient(&telegraph.ClientOpt{HttpClient: httpClient})
}
func handleTelegraph(ctx *ext.Context, update *ext.Update) error {
common.Log.Trace("Got telegraph link")
tgphUrl := TelegraphUrlRegex.FindString(update.EffectiveMessage.Text)
if tgphUrl == "" {
return dispatcher.ContinueGroups
}
replied, err := ctx.Reply(update, ext.ReplyTextString("正在获取文件..."), nil)
if err != nil {
common.Log.Errorf("回复失败: %s", err)
return dispatcher.EndGroups
}
user, err := dao.GetUserByChatID(update.GetUserChat().GetID())
if err != nil {
common.Log.Errorf("获取用户失败: %s", err)
ctx.Reply(update, ext.ReplyTextString("获取用户失败"), nil)
return dispatcher.EndGroups
}
storages := storage.GetUserStorages(user.ChatID)
if len(storages) == 0 {
ctx.Reply(update, ext.ReplyTextString("无可用的存储"), nil)
return dispatcher.EndGroups
}
tgphPath := strings.Split(tgphUrl, "/")[len(strings.Split(tgphUrl, "/"))-1]
fileName, err := url.PathUnescape(tgphPath)
if err != nil {
common.Log.Errorf("解析 Telegraph 路径失败: %s", err)
fileName = tgphPath
}
record := &dao.ReceivedFile{
Processing: false,
FileName: fileName,
ChatID: update.EffectiveChat().GetID(),
MessageID: update.EffectiveMessage.GetID(),
ReplyMessageID: replied.ID,
ReplyChatID: update.EffectiveChat().GetID(),
IsTelegraph: true,
TelegraphURL: tgphUrl,
}
if err := dao.SaveReceivedFile(record); err != nil {
common.Log.Errorf("保存接收的文件失败: %s", err)
ctx.EditMessage(update.EffectiveChat().GetID(), &tg.MessagesEditMessageRequest{
Message: "无法保存文件: " + err.Error(),
ID: replied.ID,
})
return dispatcher.EndGroups
}
if !user.Silent || user.DefaultStorage == "" {
return ProvideSelectMessage(ctx, update, fileName, update.EffectiveChat().GetID(), update.EffectiveMessage.GetID(), replied.ID)
}
return HandleSilentAddTask(ctx, update, user, &types.Task{
Ctx: ctx,
Status: types.Pending,
StorageName: user.DefaultStorage,
UserID: user.ChatID,
ReplyMessageID: replied.ID,
ReplyChatID: update.GetUserChat().GetID(),
IsTelegraph: true,
TelegraphURL: tgphUrl,
})
}

View File

@@ -4,7 +4,7 @@ import (
"github.com/celestix/gotgproto/dispatcher" "github.com/celestix/gotgproto/dispatcher"
"github.com/celestix/gotgproto/dispatcher/handlers" "github.com/celestix/gotgproto/dispatcher/handlers"
"github.com/celestix/gotgproto/dispatcher/handlers/filters" "github.com/celestix/gotgproto/dispatcher/handlers/filters"
"github.com/krau/SaveAny-Bot/logger" "github.com/krau/SaveAny-Bot/common"
) )
func RegisterHandlers(dispatcher dispatcher.Dispatcher) { func RegisterHandlers(dispatcher dispatcher.Dispatcher) {
@@ -17,10 +17,16 @@ func RegisterHandlers(dispatcher dispatcher.Dispatcher) {
dispatcher.AddHandler(handlers.NewCommand("dir", dirCmd)) dispatcher.AddHandler(handlers.NewCommand("dir", dirCmd))
linkRegexFilter, err := filters.Message.Regex(linkRegexString) linkRegexFilter, err := filters.Message.Regex(linkRegexString)
if err != nil { if err != nil {
logger.L.Panicf("创建正则表达式过滤器失败: %s", err) common.Log.Panicf("创建正则表达式过滤器失败: %s", err)
} }
dispatcher.AddHandler(handlers.NewMessage(linkRegexFilter, handleLinkMessage)) dispatcher.AddHandler(handlers.NewMessage(linkRegexFilter, handleLinkMessage))
telegraphUrlRegexFilter, err := filters.Message.Regex(TelegraphUrlRegexString)
if err != nil {
common.Log.Panicf("创建 Telegraph URL 正则表达式过滤器失败: %s", err)
}
dispatcher.AddHandler(handlers.NewMessage(telegraphUrlRegexFilter, handleTelegraph))
dispatcher.AddHandler(handlers.NewCallbackQuery(filters.CallbackQuery.Prefix("add"), AddToQueue)) dispatcher.AddHandler(handlers.NewCallbackQuery(filters.CallbackQuery.Prefix("add"), AddToQueue))
dispatcher.AddHandler(handlers.NewCallbackQuery(filters.CallbackQuery.Prefix("set_default"), setDefaultStorage)) dispatcher.AddHandler(handlers.NewCallbackQuery(filters.CallbackQuery.Prefix("set_default"), setDefaultStorage))
dispatcher.AddHandler(handlers.NewCallbackQuery(filters.CallbackQuery.Prefix("cancel"), cancelTask))
dispatcher.AddHandler(handlers.NewMessage(filters.Message.Media, handleFileMessage)) dispatcher.AddHandler(handlers.NewMessage(filters.Message.Media, handleFileMessage))
} }

View File

@@ -3,6 +3,8 @@ package bot
import ( import (
"errors" "errors"
"fmt" "fmt"
"strconv"
"strings"
"time" "time"
"github.com/celestix/gotgproto/dispatcher" "github.com/celestix/gotgproto/dispatcher"
@@ -12,7 +14,6 @@ import (
"github.com/gotd/td/tg" "github.com/gotd/td/tg"
"github.com/krau/SaveAny-Bot/common" "github.com/krau/SaveAny-Bot/common"
"github.com/krau/SaveAny-Bot/dao" "github.com/krau/SaveAny-Bot/dao"
"github.com/krau/SaveAny-Bot/logger"
"github.com/krau/SaveAny-Bot/queue" "github.com/krau/SaveAny-Bot/queue"
"github.com/krau/SaveAny-Bot/storage" "github.com/krau/SaveAny-Bot/storage"
"github.com/krau/SaveAny-Bot/types" "github.com/krau/SaveAny-Bot/types"
@@ -178,7 +179,7 @@ func FileFromMedia(media tg.MessageMediaClass, customFileName string) (*types.Fi
func FileFromMessage(ctx *ext.Context, chatID int64, messageID int, customFileName string) (*types.File, error) { func FileFromMessage(ctx *ext.Context, chatID int64, messageID int, customFileName string) (*types.File, error) {
key := fmt.Sprintf("file:%d:%d", chatID, messageID) key := fmt.Sprintf("file:%d:%d", chatID, messageID)
logger.L.Debugf("Getting file: %s", key) common.Log.Debugf("Getting file: %s", key)
var cachedFile types.File var cachedFile types.File
err := common.Cache.Get(key, &cachedFile) err := common.Cache.Get(key, &cachedFile)
if err == nil { if err == nil {
@@ -193,13 +194,19 @@ func FileFromMessage(ctx *ext.Context, chatID int64, messageID int, customFileNa
return nil, err return nil, err
} }
if err := common.Cache.Set(key, file, 3600); err != nil { if err := common.Cache.Set(key, file, 3600); err != nil {
logger.L.Errorf("Failed to cache file: %s", err) common.Log.Errorf("Failed to cache file: %s", err)
} }
return file, nil return file, nil
} }
func GetTGMessage(ctx *ext.Context, chatId int64, messageID int) (*tg.Message, error) { func GetTGMessage(ctx *ext.Context, chatId int64, messageID int) (*tg.Message, error) {
logger.L.Debugf("Fetching message: %d", messageID) key := fmt.Sprintf("message:%d:%d", chatId, messageID)
common.Log.Debugf("Fetching message: %d", messageID)
var cachedMessage tg.Message
err := common.Cache.Get(key, &cachedMessage)
if err == nil {
return &cachedMessage, nil
}
messages, err := ctx.GetMessages(chatId, []tg.InputMessageClass{&tg.InputMessageID{ID: messageID}}) messages, err := ctx.GetMessages(chatId, []tg.InputMessageClass{&tg.InputMessageID{ID: messageID}})
if err != nil { if err != nil {
return nil, err return nil, err
@@ -212,32 +219,35 @@ func GetTGMessage(ctx *ext.Context, chatId int64, messageID int) (*tg.Message, e
if !ok { if !ok {
return nil, fmt.Errorf("unexpected message type: %T", msg) return nil, fmt.Errorf("unexpected message type: %T", msg)
} }
if err := common.Cache.Set(key, tgMessage, 3600); err != nil {
common.Log.Errorf("Failed to cache message: %s", err)
}
return tgMessage, nil return tgMessage, nil
} }
func ProvideSelectMessage(ctx *ext.Context, update *ext.Update, file *types.File, chatID int64, fileMsgID, toEditMsgID int) error { func ProvideSelectMessage(ctx *ext.Context, update *ext.Update, fileName string, chatID int64, fileMsgID, toEditMsgID int) error {
entityBuilder := entity.Builder{} entityBuilder := entity.Builder{}
var entities []tg.MessageEntityClass var entities []tg.MessageEntityClass
text := fmt.Sprintf("文件名: %s\n请选择存储位置", file.FileName) text := fmt.Sprintf("文件名: %s\n请选择存储位置", fileName)
if err := styling.Perform(&entityBuilder, if err := styling.Perform(&entityBuilder,
styling.Plain("文件名: "), styling.Plain("文件名: "),
styling.Code(file.FileName), styling.Code(fileName),
styling.Plain("\n请选择存储位置"), styling.Plain("\n请选择存储位置"),
); err != nil { ); err != nil {
logger.L.Errorf("Failed to build entity: %s", err) common.Log.Errorf("Failed to build entity: %s", err)
} else { } else {
text, entities = entityBuilder.Complete() text, entities = entityBuilder.Complete()
} }
markup, err := getSelectStorageMarkup(update.GetUserChat().GetID(), int(chatID), fileMsgID) markup, err := getSelectStorageMarkup(update.GetUserChat().GetID(), int(chatID), fileMsgID)
if errors.Is(err, ErrNoStorages) { if errors.Is(err, ErrNoStorages) {
logger.L.Errorf("Failed to get select storage markup: %s", err) common.Log.Errorf("Failed to get select storage markup: %s", err)
ctx.EditMessage(update.EffectiveChat().GetID(), &tg.MessagesEditMessageRequest{ ctx.EditMessage(update.EffectiveChat().GetID(), &tg.MessagesEditMessageRequest{
Message: "无可用存储", Message: "无可用存储",
ID: toEditMsgID, ID: toEditMsgID,
}) })
return dispatcher.EndGroups return dispatcher.EndGroups
} else if err != nil { } else if err != nil {
logger.L.Errorf("Failed to get select storage markup: %s", err) common.Log.Errorf("Failed to get select storage markup: %s", err)
ctx.EditMessage(update.EffectiveChat().GetID(), &tg.MessagesEditMessageRequest{ ctx.EditMessage(update.EffectiveChat().GetID(), &tg.MessagesEditMessageRequest{
Message: "无法获取存储", Message: "无法获取存储",
ID: toEditMsgID, ID: toEditMsgID,
@@ -251,7 +261,7 @@ func ProvideSelectMessage(ctx *ext.Context, update *ext.Update, file *types.File
ID: toEditMsgID, ID: toEditMsgID,
}) })
if err != nil { if err != nil {
logger.L.Errorf("Failed to reply: %s", err) common.Log.Errorf("Failed to reply: %s", err)
} }
return dispatcher.EndGroups return dispatcher.EndGroups
} }
@@ -264,10 +274,26 @@ func HandleSilentAddTask(ctx *ext.Context, update *ext.Update, user *dao.User, t
}) })
return dispatcher.EndGroups return dispatcher.EndGroups
} }
queue.AddTask(*task) queue.AddTask(task)
ctx.EditMessage(update.EffectiveChat().GetID(), &tg.MessagesEditMessageRequest{ ctx.EditMessage(update.EffectiveChat().GetID(), &tg.MessagesEditMessageRequest{
Message: fmt.Sprintf("已添加到队列: %s\n当前排队任务数: %d", task.FileName(), queue.Len()), Message: fmt.Sprintf("已添加到队列: %s\n当前排队任务数: %d", task.FileName(), queue.Len()),
ID: task.ReplyMessageID, ID: task.ReplyMessageID,
}) })
return dispatcher.EndGroups return dispatcher.EndGroups
} }
func GenFileNameFromMessage(message tg.Message, file *types.File) string {
if file.FileName != "" {
return file.FileName
}
text := strings.TrimSpace(message.GetMessage())
if text == "" {
return file.Hash()
}
tags := common.ExtractTagsFromText(text)
if len(tags) > 0 {
return fmt.Sprintf("%s_%s", strings.Join(tags, "_"), strconv.Itoa(message.GetID()))
}
runes := []rune(text)
return string(runes[:min(128, len(runes))])
}

View File

@@ -7,12 +7,13 @@ import (
"path/filepath" "path/filepath"
"syscall" "syscall"
"slices"
"github.com/krau/SaveAny-Bot/bot" "github.com/krau/SaveAny-Bot/bot"
"github.com/krau/SaveAny-Bot/common" "github.com/krau/SaveAny-Bot/common"
"github.com/krau/SaveAny-Bot/config" "github.com/krau/SaveAny-Bot/config"
"github.com/krau/SaveAny-Bot/core" "github.com/krau/SaveAny-Bot/core"
"github.com/krau/SaveAny-Bot/dao" "github.com/krau/SaveAny-Bot/dao"
"github.com/krau/SaveAny-Bot/logger"
"github.com/krau/SaveAny-Bot/storage" "github.com/krau/SaveAny-Bot/storage"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@@ -24,32 +25,30 @@ func Run(_ *cobra.Command, _ []string) {
quit := make(chan os.Signal, 1) quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
sig := <-quit sig := <-quit
logger.L.Info(sig, ", exitting...") common.Log.Info(sig, ", exitting...")
defer logger.L.Info("Bye!") defer common.Log.Info("Bye!")
if config.Cfg.NoCleanCache { if config.Cfg.NoCleanCache {
return return
} }
if config.Cfg.Temp.BasePath != "" { if config.Cfg.Temp.BasePath != "" && !config.Cfg.Stream {
for _, path := range []string{"/", ".", "\\", ".."} { if slices.Contains([]string{"/", ".", "\\", ".."}, filepath.Clean(config.Cfg.Temp.BasePath)) {
if filepath.Clean(config.Cfg.Temp.BasePath) == path { common.Log.Error("无效的缓存文件夹: ", config.Cfg.Temp.BasePath)
logger.L.Error("Invalid cache dir: ", config.Cfg.Temp.BasePath) return
return
}
} }
currentDir, err := os.Getwd() currentDir, err := os.Getwd()
if err != nil { if err != nil {
logger.L.Error("Failed to get current dir: ", err) common.Log.Error("获取工作目录失败: ", err)
return return
} }
cachePath := filepath.Join(currentDir, config.Cfg.Temp.BasePath) cachePath := filepath.Join(currentDir, config.Cfg.Temp.BasePath)
cachePath, err = filepath.Abs(cachePath) cachePath, err = filepath.Abs(cachePath)
if err != nil { if err != nil {
logger.L.Error("Failed to get absolute path: ", err) common.Log.Error("获取缓存绝对路径失败: ", err)
return return
} }
logger.L.Info("Cleaning cache dir: ", cachePath) common.Log.Info("正在清理缓存文件夹: ", cachePath)
if err := os.RemoveAll(cachePath); err != nil { if err := os.RemoveAll(cachePath); err != nil {
logger.L.Error("Failed to clean cache dir: ", err) common.Log.Error("清理缓存失败: ", err)
} }
} }
} }
@@ -59,8 +58,8 @@ func InitAll() {
fmt.Println("加载配置文件失败: ", err) fmt.Println("加载配置文件失败: ", err)
os.Exit(1) os.Exit(1)
} }
logger.InitLogger() common.InitLogger()
logger.L.Info("正在启动 SaveAny-Bot...") common.Log.Info("正在启动 SaveAny-Bot...")
dao.Init() dao.Init()
storage.LoadStorages() storage.LoadStorages()
common.Init() common.Init()

View File

@@ -21,10 +21,12 @@ func initCache() {
gob.Register(types.File{}) gob.Register(types.File{})
gob.Register(tg.InputDocumentFileLocation{}) gob.Register(tg.InputDocumentFileLocation{})
gob.Register(tg.InputPhotoFileLocation{}) gob.Register(tg.InputPhotoFileLocation{})
gob.Register(tg.Message{})
gob.Register(tg.PeerUser{})
Cache = &CommonCache{cache: freecache.NewCache(10 * 1024 * 1024)} Cache = &CommonCache{cache: freecache.NewCache(10 * 1024 * 1024)}
} }
func (c *CommonCache) Get(key string, value *types.File) error { func (c *CommonCache) Get(key string, value any) error {
c.mu.RLock() c.mu.RLock()
defer c.mu.RUnlock() defer c.mu.RUnlock()
data, err := Cache.cache.Get([]byte(key)) data, err := Cache.cache.Get([]byte(key))
@@ -39,7 +41,7 @@ func (c *CommonCache) Get(key string, value *types.File) error {
return nil return nil
} }
func (c *CommonCache) Set(key string, value *types.File, expireSeconds int) error { func (c *CommonCache) Set(key string, value any, expireSeconds int) error {
c.mu.Lock() c.mu.Lock()
defer c.mu.Unlock() defer c.mu.Unlock()
var buf bytes.Buffer var buf bytes.Buffer

View File

@@ -1,21 +1,20 @@
package logger package common
import ( import (
"github.com/krau/SaveAny-Bot/config"
"github.com/gookit/slog" "github.com/gookit/slog"
"github.com/gookit/slog/handler" "github.com/gookit/slog/handler"
"github.com/gookit/slog/rotatefile" "github.com/gookit/slog/rotatefile"
"github.com/krau/SaveAny-Bot/config"
) )
var L *slog.Logger var Log *slog.Logger
func InitLogger() { func InitLogger() {
if L != nil { if Log != nil {
return return
} }
slog.DefaultChannelName = "SaveAnyBot" slog.DefaultChannelName = "SaveAnyBot"
L = slog.New() Log = slog.New()
logLevel := slog.LevelByName(config.Cfg.Log.Level) logLevel := slog.LevelByName(config.Cfg.Log.Level)
logFilePath := config.Cfg.Log.File logFilePath := config.Cfg.Log.File
logBackupNum := config.Cfg.Log.BackupCount logBackupNum := config.Cfg.Log.BackupCount
@@ -36,5 +35,5 @@ func InitLogger() {
if err != nil { if err != nil {
panic(err) panic(err)
} }
L.AddHandlers(consoleH, fileH) Log.AddHandlers(consoleH, fileH)
} }

View File

@@ -5,8 +5,6 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"time" "time"
"github.com/krau/SaveAny-Bot/logger"
) )
// 创建文件, 自动创建目录 // 创建文件, 自动创建目录
@@ -31,10 +29,10 @@ func PurgeFile(path string) error {
func RmFileAfter(path string, td time.Duration) { func RmFileAfter(path string, td time.Duration) {
_, err := os.Stat(path) _, err := os.Stat(path)
if err != nil { if err != nil {
logger.L.Errorf("Failed to create timer for %s: %s", path, err) Log.Errorf("Failed to create timer for %s: %s", path, err)
return return
} }
logger.L.Debugf("Remove file after %s: %s", td, path) Log.Debugf("Remove file after %s: %s", td, path)
time.AfterFunc(td, func() { time.AfterFunc(td, func() {
PurgeFile(path) PurgeFile(path)
}) })

View File

@@ -3,6 +3,7 @@ package common
import ( import (
"crypto/md5" "crypto/md5"
"encoding/hex" "encoding/hex"
"regexp"
) )
func HashString(s string) string { func HashString(s string) string {
@@ -10,3 +11,16 @@ func HashString(s string) string {
hash.Write([]byte(s)) hash.Write([]byte(s))
return hex.EncodeToString(hash.Sum(nil)) return hex.EncodeToString(hash.Sum(nil))
} }
var TagRe = regexp.MustCompile(`(?:^|[\p{Zs}\s.,!?(){}[\]<>\"\',。!?():;、])#([\p{L}\d_]+)`)
func ExtractTagsFromText(text string) []string {
matches := TagRe.FindAllStringSubmatch(text, -1)
tags := make([]string, 0)
for _, match := range matches {
if len(match) > 1 {
tags = append(tags, match[1])
}
}
return tags
}

View File

@@ -2,6 +2,7 @@
workers = 4 # 同时下载文件数 workers = 4 # 同时下载文件数
retry = 3 # 下载失败重试次数 retry = 3 # 下载失败重试次数
threads = 4 # 单个任务下载最大线程数 threads = 4 # 单个任务下载最大线程数
stream = false # 使用stream模式, 详情请查看文档
[telegram] [telegram]
# Bot Token # Bot Token
@@ -22,7 +23,7 @@ url = "socks5://127.0.0.1:7890"
[[storages]] [[storages]]
# 标识名, 需要唯一 # 标识名, 需要唯一
name = "本机1" name = "本机1"
# 存储类型, 目前可用: local , alist , webdav # 存储类型, 目前可用: local, alist, webdav, minio
type = "local" type = "local"
# 启用存储 # 启用存储
enable = true enable = true
@@ -58,6 +59,16 @@ url = 'https://example.com/dav'
username = 'username' username = 'username'
password = 'password' password = 'password'
[[storages]]
name = "MyMinio"
type = "minio"
enable = true
endpoint = 'play.min.io'
use_ssl = true
access_key_id = 'Q3AM3UQ867SPQQA43P2F'
secret_access_key = 'zuf+tfteSlswRu7BJ86wekitnifILbZam1KYY3TG'
bucket_name = 'saveanybot'
base_path = '/path/telegram'
# 用户列表 # 用户列表
[[users]] [[users]]
@@ -90,4 +101,4 @@ storages = ["本机1"]
# cache_ttl = 30 # cache_ttl = 30
# [db] # [db]
# path = "data/data.db" # 数据库文件路径 # path = "data/data.db" # 数据库文件路径

View File

@@ -1,95 +0,0 @@
package config
import (
"strconv"
"github.com/krau/SaveAny-Bot/types"
"gorm.io/datatypes"
)
// for compatibility
type deprecatedStorageConfig struct {
Alist alistConfig `toml:"alist" mapstructure:"alist"`
Local localConfig `toml:"local" mapstructure:"local"`
Webdav webdavConfig `toml:"webdav" mapstructure:"webdav"`
}
type alistConfig struct {
Enable bool `toml:"enable" mapstructure:"enable" json:"enable"`
URL string `toml:"url" mapstructure:"url" json:"url"`
Username string `toml:"username" mapstructure:"username" json:"username"`
Password string `toml:"password" mapstructure:"password" json:"password"`
Token string `toml:"token" mapstructure:"token" json:"token"`
BasePath string `toml:"base_path" mapstructure:"base_path" json:"base_path"`
TokenExp int64 `toml:"token_exp" mapstructure:"token_exp" json:"token_exp"`
}
func (a *alistConfig) ToJSON() datatypes.JSON {
tokenExp := strconv.FormatInt(a.TokenExp, 10)
return datatypes.JSON([]byte(`{"url":"` + a.URL + `","username":"` + a.Username + `","password":"` + a.Password + `","token":"` + a.Token + `","base_path":"` + a.BasePath + `","token_exp":` + tokenExp + `}`))
}
type localConfig struct {
Enable bool `toml:"enable" mapstructure:"enable" json:"enable"`
BasePath string `toml:"base_path" mapstructure:"base_path" json:"base_path"`
}
func (l *localConfig) ToJSON() datatypes.JSON {
return datatypes.JSON([]byte(`{"base_path":"` + l.BasePath + `"}`))
}
type webdavConfig struct {
Enable bool `toml:"enable" mapstructure:"enable" json:"enable"`
URL string `toml:"url" mapstructure:"url" json:"url"`
Username string `toml:"username" mapstructure:"username" json:"username"`
Password string `toml:"password" mapstructure:"password" json:"password"`
BasePath string `toml:"base_path" mapstructure:"base_path" json:"base_path"`
}
func (w *webdavConfig) ToJSON() datatypes.JSON {
return datatypes.JSON([]byte(`{"url":"` + w.URL + `","username":"` + w.Username + `","password":"` + w.Password + `","base_path":"` + w.BasePath + `"}`))
}
func transformDeprecatedStorageConfig() {
if Cfg.DeprecatedStorage.Alist.Enable {
alistStorage := &AlistStorageConfig{
NewStorageConfig: NewStorageConfig{
Name: "Alist",
Enable: true,
Type: string(types.StorageTypeAlist),
},
URL: Cfg.DeprecatedStorage.Alist.URL,
Username: Cfg.DeprecatedStorage.Alist.Username,
Password: Cfg.DeprecatedStorage.Alist.Password,
Token: Cfg.DeprecatedStorage.Alist.Token,
BasePath: Cfg.DeprecatedStorage.Alist.BasePath,
TokenExp: Cfg.DeprecatedStorage.Alist.TokenExp,
}
Cfg.Storages = append(Cfg.Storages, alistStorage)
}
if Cfg.DeprecatedStorage.Local.Enable {
localStorage := &LocalStorageConfig{
NewStorageConfig: NewStorageConfig{
Name: "Local",
Enable: true,
Type: string(types.StorageTypeLocal),
},
BasePath: Cfg.DeprecatedStorage.Local.BasePath,
}
Cfg.Storages = append(Cfg.Storages, localStorage)
}
if Cfg.DeprecatedStorage.Webdav.Enable {
webdavStorage := &WebdavStorageConfig{
NewStorageConfig: NewStorageConfig{
Name: "Webdav",
Enable: true,
Type: string(types.StorageTypeWebdav),
},
URL: Cfg.DeprecatedStorage.Webdav.URL,
Username: Cfg.DeprecatedStorage.Webdav.Username,
Password: Cfg.DeprecatedStorage.Webdav.Password,
BasePath: Cfg.DeprecatedStorage.Webdav.BasePath,
}
Cfg.Storages = append(Cfg.Storages, webdavStorage)
}
}

38
config/storage/alist.go Normal file
View File

@@ -0,0 +1,38 @@
package storage
import (
"fmt"
"github.com/krau/SaveAny-Bot/types"
)
type AlistStorageConfig struct {
BaseConfig
URL string `toml:"url" mapstructure:"url" json:"url"`
Username string `toml:"username" mapstructure:"username" json:"username"`
Password string `toml:"password" mapstructure:"password" json:"password"`
Token string `toml:"token" mapstructure:"token" json:"token"`
BasePath string `toml:"base_path" mapstructure:"base_path" json:"base_path"`
TokenExp int64 `toml:"token_exp" mapstructure:"token_exp" json:"token_exp"`
}
func (a *AlistStorageConfig) Validate() error {
if a.URL == "" {
return fmt.Errorf("url is required for alist storage")
}
if a.Token == "" && (a.Username == "" || a.Password == "") {
return fmt.Errorf("username and password or token is required for alist storage")
}
if a.BasePath == "" {
return fmt.Errorf("base_path is required for alist storage")
}
return nil
}
func (a *AlistStorageConfig) GetType() types.StorageType {
return types.StorageTypeAlist
}
func (a *AlistStorageConfig) GetName() string {
return a.Name
}

63
config/storage/factory.go Normal file
View File

@@ -0,0 +1,63 @@
package storage
import (
"fmt"
"reflect"
"github.com/krau/SaveAny-Bot/types"
"github.com/mitchellh/mapstructure"
"github.com/spf13/viper"
)
var storageFactories = map[types.StorageType]func(cfg *BaseConfig) (StorageConfig, error){
types.StorageTypeLocal: createStorageConfig(&LocalStorageConfig{}),
types.StorageTypeAlist: createStorageConfig(&AlistStorageConfig{}),
types.StorageTypeWebdav: createStorageConfig(&WebdavStorageConfig{}),
types.StorageTypeMinio: createStorageConfig(&MinioStorageConfig{}),
}
func createStorageConfig(configType StorageConfig) func(cfg *BaseConfig) (StorageConfig, error) {
return func(cfg *BaseConfig) (StorageConfig, error) {
configValue := reflect.New(reflect.TypeOf(configType).Elem()).Interface().(StorageConfig)
reflect.ValueOf(configValue).Elem().FieldByName("BaseConfig").Set(reflect.ValueOf(*cfg))
if err := mapstructure.Decode(cfg.RawConfig, configValue); err != nil {
return nil, fmt.Errorf("failed to decode %s storage config: %w", cfg.Type, err)
}
return configValue, nil
}
}
func LoadStorageConfigs(v *viper.Viper) ([]StorageConfig, error) {
var baseConfigs []BaseConfig
if err := v.UnmarshalKey("storages", &baseConfigs); err != nil {
return nil, fmt.Errorf("failed to unmarshal storage configs: %w", err)
}
var configs []StorageConfig
for _, baseCfg := range baseConfigs {
if !baseCfg.Enable {
continue
}
factory, ok := storageFactories[types.StorageType(baseCfg.Type)]
if !ok {
return nil, fmt.Errorf("unsupported storage type: %s", baseCfg.Type)
}
cfg, err := factory(&baseCfg)
if err != nil {
return nil, fmt.Errorf("failed to create storage config for %s: %w", baseCfg.Name, err)
}
if err := cfg.Validate(); err != nil {
return nil, fmt.Errorf("invalid storage config for %s: %w", baseCfg.Name, err)
}
configs = append(configs, cfg)
}
return configs, nil
}

27
config/storage/local.go Normal file
View File

@@ -0,0 +1,27 @@
package storage
import (
"fmt"
"github.com/krau/SaveAny-Bot/types"
)
type LocalStorageConfig struct {
BaseConfig
BasePath string `toml:"base_path" mapstructure:"base_path" json:"base_path"`
}
func (l *LocalStorageConfig) Validate() error {
if l.BasePath == "" {
return fmt.Errorf("path is required for local storage")
}
return nil
}
func (l *LocalStorageConfig) GetType() types.StorageType {
return types.StorageTypeLocal
}
func (l *LocalStorageConfig) GetName() string {
return l.Name
}

41
config/storage/minio.go Normal file
View File

@@ -0,0 +1,41 @@
package storage
import (
"fmt"
"github.com/krau/SaveAny-Bot/types"
)
type MinioStorageConfig struct {
BaseConfig
Endpoint string `toml:"endpoint" mapstructure:"endpoint" json:"endpoint"`
AccessKeyID string `toml:"access_key_id" mapstructure:"access_key_id" json:"access_key_id"`
SecretAccessKey string `toml:"secret_access_key" mapstructure:"secret_access_key" json:"secret_access_key"`
BucketName string `toml:"bucket_name" mapstructure:"bucket_name" json:"bucket_name"`
UseSSL bool `toml:"use_ssl" mapstructure:"use_ssl" json:"use_ssl"`
BasePath string `toml:"base_path" mapstructure:"base_path" json:"base_path"`
}
func (m *MinioStorageConfig) Validate() error {
if m.Endpoint == "" {
return fmt.Errorf("endpoint is required for minio storage")
}
if m.AccessKeyID == "" || m.SecretAccessKey == "" {
return fmt.Errorf("access_key_id and secret_access_key are required for minio storage")
}
if m.BucketName == "" {
return fmt.Errorf("bucket_name is required for minio storage")
}
if m.BasePath == "" {
return fmt.Errorf("base_path is required for minio storage")
}
return nil
}
func (m *MinioStorageConfig) GetType() types.StorageType {
return types.StorageTypeMinio
}
func (m *MinioStorageConfig) GetName() string {
return m.Name
}

16
config/storage/types.go Normal file
View File

@@ -0,0 +1,16 @@
package storage
import "github.com/krau/SaveAny-Bot/types"
type StorageConfig interface {
Validate() error
GetType() types.StorageType
GetName() string
}
type BaseConfig struct {
Name string `toml:"name" mapstructure:"name" json:"name"`
Type string `toml:"type" mapstructure:"type" json:"type"`
Enable bool `toml:"enable" mapstructure:"enable" json:"enable"`
RawConfig map[string]any `toml:"-" mapstructure:",remain"`
}

36
config/storage/webdav.go Normal file
View File

@@ -0,0 +1,36 @@
package storage
import (
"fmt"
"github.com/krau/SaveAny-Bot/types"
)
type WebdavStorageConfig struct {
BaseConfig
URL string `toml:"url" mapstructure:"url" json:"url"`
Username string `toml:"username" mapstructure:"username" json:"username"`
Password string `toml:"password" mapstructure:"password" json:"password"`
BasePath string `toml:"base_path" mapstructure:"base_path" json:"base_path"`
}
func (w *WebdavStorageConfig) Validate() error {
if w.URL == "" {
return fmt.Errorf("url is required for webdav storage")
}
if w.Username == "" || w.Password == "" {
return fmt.Errorf("username and password is required for webdav storage")
}
if w.BasePath == "" {
return fmt.Errorf("base_path is required for webdav storage")
}
return nil
}
func (w *WebdavStorageConfig) GetType() types.StorageType {
return types.StorageTypeWebdav
}
func (w *WebdavStorageConfig) GetName() string {
return w.Name
}

View File

@@ -1,104 +0,0 @@
// storage_config.go
package config
import (
"fmt"
"github.com/krau/SaveAny-Bot/types"
"github.com/mitchellh/mapstructure"
"github.com/spf13/viper"
)
type StorageConfig interface {
Validate() error
GetType() types.StorageType
GetName() string
}
// Base storage config
type NewStorageConfig struct {
Name string `toml:"name" mapstructure:"name" json:"name"`
Type string `toml:"type" mapstructure:"type" json:"type"`
Enable bool `toml:"enable" mapstructure:"enable" json:"enable"`
RawConfig map[string]interface{} `toml:"-" mapstructure:",remain"`
}
type StorageConfigFactory func(cfg *NewStorageConfig) (StorageConfig, error)
var storageFactories = make(map[string]StorageConfigFactory)
func RegisterStorageFactory(storageType string, factory StorageConfigFactory) {
storageFactories[storageType] = factory
}
func init() {
RegisterStorageFactory(string(types.StorageTypeLocal), newLocalStorageConfig)
RegisterStorageFactory(string(types.StorageTypeAlist), newAlistStorageConfig)
RegisterStorageFactory(string(types.StorageTypeWebdav), newWebdavStorageConfig)
}
func newLocalStorageConfig(cfg *NewStorageConfig) (StorageConfig, error) {
var localCfg LocalStorageConfig
localCfg.NewStorageConfig = *cfg
if err := mapstructure.Decode(cfg.RawConfig, &localCfg); err != nil {
return nil, fmt.Errorf("failed to decode local storage config: %w", err)
}
return &localCfg, nil
}
func newAlistStorageConfig(cfg *NewStorageConfig) (StorageConfig, error) {
var alistCfg AlistStorageConfig
alistCfg.NewStorageConfig = *cfg
if err := mapstructure.Decode(cfg.RawConfig, &alistCfg); err != nil {
return nil, fmt.Errorf("failed to decode alist storage config: %w", err)
}
return &alistCfg, nil
}
func newWebdavStorageConfig(cfg *NewStorageConfig) (StorageConfig, error) {
var webdavCfg WebdavStorageConfig
webdavCfg.NewStorageConfig = *cfg
if err := mapstructure.Decode(cfg.RawConfig, &webdavCfg); err != nil {
return nil, fmt.Errorf("failed to decode webdav storage config: %w", err)
}
return &webdavCfg, nil
}
func LoadStorageConfigs(v *viper.Viper) ([]StorageConfig, error) {
var baseConfigs []NewStorageConfig
if err := v.UnmarshalKey("storages", &baseConfigs); err != nil {
return nil, fmt.Errorf("failed to unmarshal storage configs: %w", err)
}
var configs []StorageConfig
for _, baseCfg := range baseConfigs {
if !baseCfg.Enable {
continue
}
factory, ok := storageFactories[baseCfg.Type]
if !ok {
return nil, fmt.Errorf("unsupported storage type: %s", baseCfg.Type)
}
cfg, err := factory(&baseCfg)
if err != nil {
return nil, fmt.Errorf("failed to create storage config for %s: %w", baseCfg.Name, err)
}
if err := cfg.Validate(); err != nil {
return nil, fmt.Errorf("invalid storage config for %s: %w", baseCfg.Name, err)
}
configs = append(configs, cfg)
}
return configs, nil
}

View File

@@ -1,106 +0,0 @@
package config
import (
"fmt"
"github.com/krau/SaveAny-Bot/types"
)
func (c *Config) GetStoragesByType(storageType types.StorageType) []StorageConfig {
var storages []StorageConfig
for _, storage := range c.Storages {
if storage.GetType() == storageType {
storages = append(storages, storage)
}
}
return storages
}
func (c *Config) GetStorageByName(name string) StorageConfig {
for _, storage := range c.Storages {
if storage.GetName() == name {
return storage
}
}
return nil
}
type LocalStorageConfig struct {
NewStorageConfig
BasePath string `toml:"base_path" mapstructure:"base_path" json:"base_path"`
}
func (l *LocalStorageConfig) Validate() error {
if l.BasePath == "" {
return fmt.Errorf("path is required for local storage")
}
return nil
}
func (l *LocalStorageConfig) GetType() types.StorageType {
return types.StorageTypeLocal
}
func (l *LocalStorageConfig) GetName() string {
return l.Name
}
type AlistStorageConfig struct {
NewStorageConfig
URL string `toml:"url" mapstructure:"url" json:"url"`
Username string `toml:"username" mapstructure:"username" json:"username"`
Password string `toml:"password" mapstructure:"password" json:"password"`
Token string `toml:"token" mapstructure:"token" json:"token"`
BasePath string `toml:"base_path" mapstructure:"base_path" json:"base_path"`
TokenExp int64 `toml:"token_exp" mapstructure:"token_exp" json:"token_exp"`
}
func (a *AlistStorageConfig) Validate() error {
if a.URL == "" {
return fmt.Errorf("url is required for alist storage")
}
if a.Token == "" && (a.Username == "" || a.Password == "") {
return fmt.Errorf("username and password or token is required for alist storage")
}
if a.BasePath == "" {
return fmt.Errorf("base_path is required for alist storage")
}
return nil
}
func (a *AlistStorageConfig) GetType() types.StorageType {
return types.StorageTypeAlist
}
func (a *AlistStorageConfig) GetName() string {
return a.Name
}
type WebdavStorageConfig struct {
NewStorageConfig
URL string `toml:"url" mapstructure:"url" json:"url"`
Username string `toml:"username" mapstructure:"username" json:"username"`
Password string `toml:"password" mapstructure:"password" json:"password"`
BasePath string `toml:"base_path" mapstructure:"base_path" json:"base_path"`
}
func (w *WebdavStorageConfig) Validate() error {
if w.URL == "" {
return fmt.Errorf("url is required for webdav storage")
}
if w.Username == "" || w.Password == "" {
return fmt.Errorf("username and password is required for webdav storage")
}
if w.BasePath == "" {
return fmt.Errorf("base_path is required for webdav storage")
}
return nil
}
func (w *WebdavStorageConfig) GetType() types.StorageType {
return types.StorageTypeWebdav
}
func (w *WebdavStorageConfig) GetName() string {
return w.Name
}

View File

@@ -5,6 +5,7 @@ import (
"os" "os"
"strings" "strings"
"github.com/krau/SaveAny-Bot/config/storage"
"github.com/spf13/viper" "github.com/spf13/viper"
) )
@@ -13,16 +14,15 @@ type Config struct {
Retry int `toml:"retry" mapstructure:"retry"` Retry int `toml:"retry" mapstructure:"retry"`
NoCleanCache bool `toml:"no_clean_cache" mapstructure:"no_clean_cache" json:"no_clean_cache"` NoCleanCache bool `toml:"no_clean_cache" mapstructure:"no_clean_cache" json:"no_clean_cache"`
Threads int `toml:"threads" mapstructure:"threads" json:"threads"` Threads int `toml:"threads" mapstructure:"threads" json:"threads"`
Stream bool `toml:"stream" mapstructure:"stream" json:"stream"`
Users []userConfig `toml:"users" mapstructure:"users" json:"users"` Users []userConfig `toml:"users" mapstructure:"users" json:"users"`
Temp tempConfig `toml:"temp" mapstructure:"temp"` Temp tempConfig `toml:"temp" mapstructure:"temp"`
Log logConfig `toml:"log" mapstructure:"log"` Log logConfig `toml:"log" mapstructure:"log"`
DB dbConfig `toml:"db" mapstructure:"db"` DB dbConfig `toml:"db" mapstructure:"db"`
Telegram telegramConfig `toml:"telegram" mapstructure:"telegram"` Telegram telegramConfig `toml:"telegram" mapstructure:"telegram"`
Storages []StorageConfig `toml:"-" mapstructure:"-" json:"storages"` Storages []storage.StorageConfig `toml:"-" mapstructure:"-" json:"storages"`
// Deprecated
DeprecatedStorage deprecatedStorageConfig `toml:"storage" mapstructure:"storage"`
} }
type tempConfig struct { type tempConfig struct {
@@ -57,6 +57,15 @@ type proxyConfig struct {
var Cfg *Config var Cfg *Config
func (c Config) GetStorageByName(name string) storage.StorageConfig {
for _, storage := range c.Storages {
if storage.GetName() == name {
return storage
}
}
return nil
}
func Init() error { func Init() error {
viper.SetConfigName("config") viper.SetConfigName("config")
viper.AddConfigPath(".") viper.AddConfigPath(".")
@@ -101,38 +110,12 @@ func Init() error {
os.Exit(1) os.Exit(1)
} }
if Cfg.Telegram.Admins != nil { storagesConfig, err := storage.LoadStorageConfigs(viper.GetViper())
fmt.Println("警告: 你正在使用旧版 Telegram 管理员配置, 该配置下的用户将可用所有存储.\ntelegram.admins 未来版本将会被废弃, 请参考新的配置文件模板, 使用 users 配置替代.")
for _, admin := range Cfg.Telegram.Admins {
found := false
for _, user := range Cfg.Users {
if user.ID == admin {
found = true
break
}
}
if found {
continue
}
Cfg.Users = append(Cfg.Users, userConfig{
ID: admin,
Storages: []string{},
Blacklist: true,
})
}
}
storagesConfig, err := LoadStorageConfigs(viper.GetViper())
if err != nil { if err != nil {
return fmt.Errorf("error loading storage configs: %w", err) return fmt.Errorf("error loading storage configs: %w", err)
} }
Cfg.Storages = storagesConfig Cfg.Storages = storagesConfig
if Cfg.DeprecatedStorage != (deprecatedStorageConfig{}) {
fmt.Println("\n警告: 你正在使用旧版存储配置, 未来版本将会被废弃.\n请参考新的配置文件模板.")
transformDeprecatedStorageConfig()
}
storageNames := make(map[string]struct{}) storageNames := make(map[string]struct{})
for _, storage := range Cfg.Storages { for _, storage := range Cfg.Storages {
if _, ok := storageNames[storage.GetName()]; ok { if _, ok := storageNames[storage.GetName()]; ok {

View File

@@ -7,8 +7,8 @@ import (
"github.com/celestix/gotgproto/ext" "github.com/celestix/gotgproto/ext"
"github.com/gotd/td/tg" "github.com/gotd/td/tg"
"github.com/krau/SaveAny-Bot/common"
"github.com/krau/SaveAny-Bot/config" "github.com/krau/SaveAny-Bot/config"
"github.com/krau/SaveAny-Bot/logger"
"github.com/krau/SaveAny-Bot/queue" "github.com/krau/SaveAny-Bot/queue"
"github.com/krau/SaveAny-Bot/types" "github.com/krau/SaveAny-Bot/types"
) )
@@ -17,18 +17,17 @@ func worker(queue *queue.TaskQueue, semaphore chan struct{}) {
for { for {
semaphore <- struct{}{} semaphore <- struct{}{}
task := queue.GetTask() task := queue.GetTask()
logger.L.Debugf("Got task: %s", task.String()) common.Log.Debugf("Got task: %s", task.String())
switch task.Status { switch task.Status {
case types.Pending: case types.Pending:
logger.L.Infof("Processing task: %s", task.String()) common.Log.Infof("Processing task: %s", task.String())
if err := processPendingTask(&task); err != nil { if err := processPendingTask(task); err != nil {
logger.L.Errorf("Failed to do task: %s", err)
task.Error = err task.Error = err
if errors.Is(err, context.Canceled) { if errors.Is(err, context.Canceled) {
logger.L.Debugf("Task canceled: %s", task.String())
task.Status = types.Canceled task.Status = types.Canceled
} else { } else {
common.Log.Errorf("Failed to do task: %s", err)
task.Status = types.Failed task.Status = types.Failed
} }
} else { } else {
@@ -36,29 +35,49 @@ func worker(queue *queue.TaskQueue, semaphore chan struct{}) {
} }
queue.AddTask(task) queue.AddTask(task)
case types.Succeeded: case types.Succeeded:
logger.L.Infof("Task succeeded: %s", task.String()) common.Log.Infof("Task succeeded: %s", task.String())
task.Ctx.(*ext.Context).EditMessage(task.ReplyChatID, &tg.MessagesEditMessageRequest{ extCtx, ok := task.Ctx.(*ext.Context)
Message: fmt.Sprintf("文件保存成功\n [%s]: %s", task.StorageName, task.StoragePath), if !ok {
ID: task.ReplyMessageID, common.Log.Errorf("Context is not *ext.Context: %T", task.Ctx)
}) } else {
extCtx.EditMessage(task.ReplyChatID, &tg.MessagesEditMessageRequest{
Message: fmt.Sprintf("文件保存成功\n [%s]: %s", task.StorageName, task.StoragePath),
ID: task.ReplyMessageID,
})
}
case types.Failed: case types.Failed:
logger.L.Errorf("Task failed: %s", task.String()) common.Log.Errorf("Task failed: %s", task.String())
task.Ctx.(*ext.Context).EditMessage(task.ReplyChatID, &tg.MessagesEditMessageRequest{ extCtx, ok := task.Ctx.(*ext.Context)
Message: "文件保存失败\n" + task.Error.Error(), if !ok {
ID: task.ReplyMessageID, common.Log.Errorf("Context is not *ext.Context: %T", task.Ctx)
}) } else {
extCtx.EditMessage(task.ReplyChatID, &tg.MessagesEditMessageRequest{
Message: "文件保存失败\n" + task.Error.Error(),
ID: task.ReplyMessageID,
})
}
case types.Canceled: case types.Canceled:
logger.L.Infof("Task canceled: %s", task.String()) common.Log.Infof("Task canceled: %s", task.String())
extCtx, ok := task.Ctx.(*ext.Context)
if !ok {
common.Log.Errorf("Context is not *ext.Context: %T", task.Ctx)
} else {
extCtx.EditMessage(task.ReplyChatID, &tg.MessagesEditMessageRequest{
Message: "任务已取消",
ID: task.ReplyMessageID,
})
}
default: default:
logger.L.Errorf("Unknown task status: %s", task.Status) common.Log.Errorf("Unknown task status: %s", task.Status)
} }
<-semaphore <-semaphore
logger.L.Debugf("Task done: %s", task.String()) common.Log.Debugf("Task done: %s; status: %s", task.String(), task.Status)
queue.DoneTask(task)
} }
} }
func Run() { func Run() {
logger.L.Info("Start processing tasks...") common.Log.Info("Start processing tasks...")
semaphore := make(chan struct{}, config.Cfg.Workers) semaphore := make(chan struct{}, config.Cfg.Workers)
for i := 0; i < config.Cfg.Workers; i++ { for i := 0; i < config.Cfg.Workers; i++ {
go worker(queue.Queue, semaphore) go worker(queue.Queue, semaphore)

View File

@@ -1,36 +1,38 @@
package core package core
import ( import (
"context"
"encoding/json"
"fmt" "fmt"
"io"
"net/http"
"path"
"path/filepath" "path/filepath"
"strings"
"time" "time"
"github.com/celestix/gotgproto/ext" "github.com/celestix/gotgproto/ext"
"github.com/celestix/telegraph-go/v2"
"github.com/duke-git/lancet/v2/fileutil" "github.com/duke-git/lancet/v2/fileutil"
"github.com/gotd/td/telegram/message/entity"
"github.com/gotd/td/telegram/message/styling"
"github.com/gotd/td/tg" "github.com/gotd/td/tg"
"github.com/krau/SaveAny-Bot/bot" "github.com/krau/SaveAny-Bot/bot"
"github.com/krau/SaveAny-Bot/common"
"github.com/krau/SaveAny-Bot/config" "github.com/krau/SaveAny-Bot/config"
"github.com/krau/SaveAny-Bot/logger"
"github.com/krau/SaveAny-Bot/storage" "github.com/krau/SaveAny-Bot/storage"
"github.com/krau/SaveAny-Bot/types" "github.com/krau/SaveAny-Bot/types"
"golang.org/x/sync/errgroup"
) )
func processPendingTask(task *types.Task) error { func processPendingTask(task *types.Task) error {
logger.L.Debugf("Start processing task: %s", task.String()) common.Log.Debugf("Start processing task: %s", task.String())
if task.FileName() == "" { if task.FileName() == "" {
task.File.FileName = fmt.Sprintf("%d_%d_%s", task.FileChatID, task.FileMessageID, task.File.Hash()) task.File.FileName = fmt.Sprintf("%d_%d_%s", task.FileChatID, task.FileMessageID, task.File.Hash())
} }
cacheDestPath := filepath.Join(config.Cfg.Temp.BasePath, task.FileName())
cacheDestPath, err := filepath.Abs(cacheDestPath)
if err != nil {
return fmt.Errorf("处理路径失败: %w", err)
}
if err := fileutil.CreateDir(filepath.Dir(cacheDestPath)); err != nil {
return fmt.Errorf("创建目录失败: %w", err)
}
if task.StoragePath == "" { if task.StoragePath == "" {
task.StoragePath = task.File.FileName task.StoragePath = task.FileName()
} }
taskStorage, err := storage.GetStorageByUserIDAndName(task.UserID, task.StorageName) taskStorage, err := storage.GetStorageByUserIDAndName(task.UserID, task.StorageName)
@@ -39,44 +41,220 @@ func processPendingTask(task *types.Task) error {
} }
task.StoragePath = taskStorage.JoinStoragePath(*task) task.StoragePath = taskStorage.JoinStoragePath(*task)
if task.File.FileSize == 0 {
return processPhoto(task, taskStorage, cacheDestPath)
}
ctx, ok := task.Ctx.(*ext.Context) ctx, ok := task.Ctx.(*ext.Context)
if !ok { if !ok {
return fmt.Errorf("context is not *ext.Context: %T", task.Ctx) return fmt.Errorf("context is not *ext.Context: %T", task.Ctx)
} }
cancelCtx, cancel := context.WithCancel(ctx)
task.Cancel = cancel
if task.IsTelegraph {
return processTelegraph(ctx, cancelCtx, task, taskStorage)
}
if task.File.FileSize == 0 {
return processPhoto(task, taskStorage)
}
downloadBuilder := Downloader.Download(bot.Client.API(), task.File.Location).WithThreads(getTaskThreads(task.File.FileSize))
if config.Cfg.Stream {
text, entities := buildProgressMessageEntity(task, 0, task.StartTime, 0)
ctx.EditMessage(task.ReplyChatID, &tg.MessagesEditMessageRequest{
Message: text,
Entities: entities,
ID: task.ReplyMessageID,
ReplyMarkup: getCancelTaskMarkup(task),
})
pr, pw := io.Pipe()
defer pr.Close()
task.StartTime = time.Now()
progressCallback := buildProgressCallback(ctx, task, getProgressUpdateCount(task.File.FileSize))
progressStream := NewProgressStream(pw, task.File.FileSize, progressCallback)
eg, uploadCtx := errgroup.WithContext(cancelCtx)
eg.Go(func() error {
return taskStorage.Save(uploadCtx, pr, task.StoragePath)
})
eg.Go(func() error {
_, err := downloadBuilder.Stream(uploadCtx, progressStream)
if closeErr := pw.CloseWithError(err); closeErr != nil {
common.Log.Errorf("Failed to close pipe writer: %v", closeErr)
}
return err
})
if err := eg.Wait(); err != nil {
return err
}
return nil
}
cacheDestPath := filepath.Join(config.Cfg.Temp.BasePath, task.FileName())
cacheDestPath, err = filepath.Abs(cacheDestPath)
if err != nil {
return fmt.Errorf("处理路径失败: %w", err)
}
if err := fileutil.CreateDir(filepath.Dir(cacheDestPath)); err != nil {
return fmt.Errorf("创建目录失败: %w", err)
}
text, entities := buildProgressMessageEntity(task, 0, task.StartTime, 0) text, entities := buildProgressMessageEntity(task, 0, task.StartTime, 0)
ctx.EditMessage(task.ReplyChatID, &tg.MessagesEditMessageRequest{ ctx.EditMessage(task.ReplyChatID, &tg.MessagesEditMessageRequest{
Message: text, Message: text,
Entities: entities, Entities: entities,
ID: task.ReplyMessageID, ID: task.ReplyMessageID,
ReplyMarkup: getCancelTaskMarkup(task),
}) })
progressCallback := buildProgressCallback(ctx, task, getProgressUpdateCount(task.File.FileSize))
progressCallback := buildProgressCallback(ctx, task, getProgressUpdateCount(task.File.FileSize))
dest, err := NewTaskLocalFile(cacheDestPath, task.File.FileSize, progressCallback) dest, err := NewTaskLocalFile(cacheDestPath, task.File.FileSize, progressCallback)
if err != nil { if err != nil {
return fmt.Errorf("创建文件失败: %w", err) return fmt.Errorf("创建文件失败: %w", err)
} }
defer dest.Close() defer dest.Close()
task.StartTime = time.Now() task.StartTime = time.Now()
downloadBuider := Downloader.Download(bot.Client.API(), task.File.Location).WithThreads(getTaskThreads(task.File.FileSize)) _, err = downloadBuilder.Parallel(cancelCtx, dest)
_, err = downloadBuider.Parallel(ctx, dest)
if err != nil { if err != nil {
return fmt.Errorf("下载文件失败: %w", err) return fmt.Errorf("下载文件失败: %w", err)
} }
defer cleanCacheFile(cacheDestPath) defer cleanCacheFile(cacheDestPath)
fixTaskFileExt(task, cacheDestPath) fixTaskFileExt(task, cacheDestPath)
logger.L.Infof("Downloaded file: %s", cacheDestPath) common.Log.Infof("Downloaded file: %s", cacheDestPath)
ctx.EditMessage(task.ReplyChatID, &tg.MessagesEditMessageRequest{ ctx.EditMessage(task.ReplyChatID, &tg.MessagesEditMessageRequest{
Message: fmt.Sprintf("下载完成: %s\n正在转存文件...", task.FileName()), Message: fmt.Sprintf("下载完成: %s\n正在转存文件...", task.FileName()),
ID: task.ReplyMessageID, ID: task.ReplyMessageID,
}) })
return saveFileWithRetry(task, taskStorage, cacheDestPath) return saveFileWithRetry(cancelCtx, task.StoragePath, taskStorage, cacheDestPath)
}
func processTelegraph(extCtx *ext.Context, cancelCtx context.Context, task *types.Task, taskStorage storage.Storage) error {
if bot.TelegraphClient == nil {
return fmt.Errorf("telegraph client is not initialized")
}
tgphUrl := task.TelegraphURL
tgphPath := strings.Split(tgphUrl, "/")[len(strings.Split(tgphUrl, "/"))-1]
if tgphUrl == "" || tgphPath == "" {
return fmt.Errorf("invalid telegraph url")
}
entityBuilder := entity.Builder{}
text := fmt.Sprintf("正在下载 Telegraph \n文件夹: %s\n保存路径: %s",
task.FileName(),
fmt.Sprintf("[%s]:%s", task.StorageName, task.StoragePath),
)
var entities []tg.MessageEntityClass
if err := styling.Perform(&entityBuilder,
styling.Plain("正在下载 Telegraph \n文件夹: "),
styling.Code(task.FileName()),
styling.Plain("\n保存路径: "),
styling.Code(fmt.Sprintf("[%s]:%s", task.StorageName, task.StoragePath)),
); err != nil {
common.Log.Errorf("Failed to build entities: %s", err)
}
extCtx.EditMessage(task.ReplyChatID, &tg.MessagesEditMessageRequest{
Message: text,
Entities: entities,
ID: task.ReplyMessageID,
ReplyMarkup: getCancelTaskMarkup(task),
})
resultCh := make(chan error)
go func() {
page, err := bot.TelegraphClient.GetPage(tgphPath, true)
if err != nil {
resultCh <- fmt.Errorf("获取 telegraph 页面失败: %w", err)
return
}
imgs := make([]string, 0)
for _, element := range page.Content {
var node telegraph.NodeElement
data, err := json.Marshal(element)
if err != nil {
common.Log.Errorf("Failed to marshal element: %s", err)
continue
}
err = json.Unmarshal(data, &node)
if err != nil {
common.Log.Errorf("Failed to unmarshal element: %s", err)
continue
}
if node.Tag == "img" {
if src, ok := node.Attrs["src"]; ok {
imgs = append(imgs, src)
}
}
}
if len(imgs) == 0 {
resultCh <- fmt.Errorf("没有找到图片")
return
}
hc := bot.TelegraphClient.HttpClient
eg, ectx := errgroup.WithContext(cancelCtx)
eg.SetLimit(config.Cfg.Workers) // TODO: use a new config field for this
for i, img := range imgs {
if strings.HasPrefix(img, "/file/") {
img = "https://telegra.ph" + img
}
eg.Go(func() error {
var lastErr error
for attempt := range config.Cfg.Retry {
if attempt > 0 {
retryDelay := time.Duration(attempt*attempt) * time.Second
select {
case <-ectx.Done():
return ectx.Err()
case <-time.After(retryDelay):
}
common.Log.Debugf("Retrying to download image %s (attempt %d)", img, attempt+1)
}
req, err := http.NewRequestWithContext(ectx, http.MethodGet, img, nil)
if err != nil {
lastErr = fmt.Errorf("创建请求失败: %w", err)
continue
}
resp, err := hc.Do(req)
if err != nil {
lastErr = fmt.Errorf("发送请求失败: %w", err)
continue
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
lastErr = fmt.Errorf("请求图片失败: %s", resp.Status)
continue
}
targetPath := path.Join(task.StoragePath, fmt.Sprintf("%d%s", i+1, path.Ext(img)))
err = taskStorage.Save(ectx, resp.Body, targetPath)
if err != nil {
lastErr = fmt.Errorf("保存图片失败: %w", err)
continue
}
common.Log.Infof("Saved image: %s", targetPath)
return nil
}
return lastErr
})
}
if err := eg.Wait(); err != nil {
resultCh <- err
return
}
resultCh <- nil
}()
select {
case err := <-resultCh:
return err
case <-cancelCtx.Done():
return cancelCtx.Err()
}
} }

View File

@@ -1,7 +1,10 @@
package core package core
import ( import (
"bytes"
"context"
"fmt" "fmt"
"io"
"os" "os"
"path" "path"
"time" "time"
@@ -14,18 +17,29 @@ import (
"github.com/krau/SaveAny-Bot/bot" "github.com/krau/SaveAny-Bot/bot"
"github.com/krau/SaveAny-Bot/common" "github.com/krau/SaveAny-Bot/common"
"github.com/krau/SaveAny-Bot/config" "github.com/krau/SaveAny-Bot/config"
"github.com/krau/SaveAny-Bot/logger"
"github.com/krau/SaveAny-Bot/storage" "github.com/krau/SaveAny-Bot/storage"
"github.com/krau/SaveAny-Bot/types" "github.com/krau/SaveAny-Bot/types"
) )
func saveFileWithRetry(task *types.Task, taskStorage storage.Storage, localFilePath string) error { func saveFileWithRetry(ctx context.Context, storagePath string, taskStorage storage.Storage, cacheFilePath string) error {
for i := 0; i <= config.Cfg.Retry; i++ { for i := 0; i <= config.Cfg.Retry; i++ {
if err := taskStorage.Save(task.Ctx, localFilePath, task.StoragePath); err != nil { if err := ctx.Err(); err != nil {
return fmt.Errorf("context canceled while saving file: %w", err)
}
file, err := os.Open(cacheFilePath)
if err != nil {
return fmt.Errorf("failed to open cache file: %w", err)
}
if err := taskStorage.Save(ctx, file, storagePath); err != nil {
if i == config.Cfg.Retry { if i == config.Cfg.Retry {
return fmt.Errorf("failed to save file: %w", err) return fmt.Errorf("failed to save file: %w", err)
} }
logger.L.Errorf("Failed to save file: %s, retrying...", err) common.Log.Errorf("Failed to save file: %s, retrying...", err)
select {
case <-ctx.Done():
return fmt.Errorf("context canceled during retry delay: %w", ctx.Err())
case <-time.After(time.Duration(i*500) * time.Millisecond):
}
continue continue
} }
return nil return nil
@@ -33,7 +47,7 @@ func saveFileWithRetry(task *types.Task, taskStorage storage.Storage, localFileP
return nil return nil
} }
func processPhoto(task *types.Task, taskStorage storage.Storage, cachePath string) error { func processPhoto(task *types.Task, taskStorage storage.Storage) error {
res, err := bot.Client.API().UploadGetFile(task.Ctx, &tg.UploadGetFileRequest{ res, err := bot.Client.API().UploadGetFile(task.Ctx, &tg.UploadGetFileRequest{
Location: task.File.Location, Location: task.File.Location,
Offset: 0, Offset: 0,
@@ -48,36 +62,17 @@ func processPhoto(task *types.Task, taskStorage storage.Storage, cachePath strin
return fmt.Errorf("unexpected type %T", res) return fmt.Errorf("unexpected type %T", res)
} }
if err := os.WriteFile(cachePath, result.Bytes, os.ModePerm); err != nil { common.Log.Infof("Downloaded photo: %s", task.FileName())
return fmt.Errorf("failed to write file: %w", err)
}
defer cleanCacheFile(cachePath) return taskStorage.Save(task.Ctx, bytes.NewReader(result.Bytes), task.StoragePath)
logger.L.Infof("Downloaded file: %s", cachePath)
return saveFileWithRetry(task, taskStorage, cachePath)
} }
// func getProgressBar(progress float64, updateCount int) string {
// bar := ""
// barSize := 100 / updateCount
// for i := 0; i < updateCount; i++ {
// if progress >= float64(barSize*(i+1)) {
// bar += "█"
// } else {
// bar += "░"
// }
// }
// return bar
// }
func cleanCacheFile(destPath string) { func cleanCacheFile(destPath string) {
if config.Cfg.Temp.CacheTTL > 0 { if config.Cfg.Temp.CacheTTL > 0 {
common.RmFileAfter(destPath, time.Duration(config.Cfg.Temp.CacheTTL)*time.Second) common.RmFileAfter(destPath, time.Duration(config.Cfg.Temp.CacheTTL)*time.Second)
} else { } else {
if err := os.Remove(destPath); err != nil { if err := os.Remove(destPath); err != nil {
logger.L.Errorf("Failed to purge file: %s", err) common.Log.Errorf("Failed to purge file: %s", err)
} }
} }
} }
@@ -123,7 +118,7 @@ func buildProgressMessageEntity(task *types.Task, bytesRead int64, startTime tim
styling.Plain("\n当前进度: "), styling.Plain("\n当前进度: "),
styling.Bold(fmt.Sprintf("%.2f%%", progress)), styling.Bold(fmt.Sprintf("%.2f%%", progress)),
); err != nil { ); err != nil {
logger.L.Errorf("Failed to build entities: %s", err) common.Log.Errorf("Failed to build entities: %s", err)
return text, entities return text, entities
} }
return entityBuilder.Complete() return entityBuilder.Complete()
@@ -132,25 +127,32 @@ func buildProgressMessageEntity(task *types.Task, bytesRead int64, startTime tim
func buildProgressCallback(ctx *ext.Context, task *types.Task, updateCount int) func(bytesRead, contentLength int64) { func buildProgressCallback(ctx *ext.Context, task *types.Task, updateCount int) func(bytesRead, contentLength int64) {
return func(bytesRead, contentLength int64) { return func(bytesRead, contentLength int64) {
progress := float64(bytesRead) / float64(contentLength) * 100 progress := float64(bytesRead) / float64(contentLength) * 100
logger.L.Tracef("Downloading %s: %.2f%%", task.String(), progress) common.Log.Tracef("Downloading %s: %.2f%%", task.String(), progress)
progressInt := int(progress) progressInt := int(progress)
if task.File.FileSize < 1024*1024*50 || progressInt == 0 || progressInt%int(100/updateCount) != 0 { if task.File.FileSize < 1024*1024*50 || progressInt == 0 || progressInt%int(100/updateCount) != 0 {
return return
} }
text, entities := buildProgressMessageEntity(task, bytesRead, task.StartTime, progress) text, entities := buildProgressMessageEntity(task, bytesRead, task.StartTime, progress)
ctx.EditMessage(task.ReplyChatID, &tg.MessagesEditMessageRequest{ ctx.EditMessage(task.ReplyChatID, &tg.MessagesEditMessageRequest{
Message: text, Message: text,
Entities: entities, Entities: entities,
ID: task.ReplyMessageID, ID: task.ReplyMessageID,
ReplyMarkup: getCancelTaskMarkup(task),
}) })
} }
} }
func getCancelTaskMarkup(task *types.Task) *tg.ReplyInlineMarkup {
return &tg.ReplyInlineMarkup{
Rows: []tg.KeyboardButtonRow{{Buttons: []tg.KeyboardButtonClass{&tg.KeyboardButtonCallback{Text: "取消任务", Data: fmt.Appendf(nil, "cancel %s", task.Key())}}}},
}
}
func fixTaskFileExt(task *types.Task, localFilePath string) { func fixTaskFileExt(task *types.Task, localFilePath string) {
if path.Ext(task.FileName()) == "" { if path.Ext(task.FileName()) == "" {
mimeType, err := mimetype.DetectFile(localFilePath) mimeType, err := mimetype.DetectFile(localFilePath)
if err != nil { if err != nil {
logger.L.Errorf("Failed to detect mime type: %s", err) common.Log.Errorf("Failed to detect mime type: %s", err)
} else { } else {
task.File.FileName = fmt.Sprintf("%s%s", task.FileName(), mimeType.Extension()) task.File.FileName = fmt.Sprintf("%s%s", task.FileName(), mimeType.Extension())
task.StoragePath = fmt.Sprintf("%s%s", task.StoragePath, mimeType.Extension()) task.StoragePath = fmt.Sprintf("%s%s", task.StoragePath, mimeType.Extension())
@@ -217,3 +219,40 @@ func NewTaskLocalFile(filePath string, fileSize int64, progressCallback func(byt
callbackInterval: callbackInterval, callbackInterval: callbackInterval,
}, nil }, nil
} }
type ProgressStream struct {
writer io.Writer
size int64
done int64
callback func(bytesRead, contentLength int64)
nextAt int64
interval int64
}
func (ps *ProgressStream) Write(p []byte) (n int, err error) {
n, err = ps.writer.Write(p)
if err != nil {
return n, err
}
ps.done += int64(n)
if ps.callback != nil && ps.done >= ps.nextAt {
ps.callback(ps.done, ps.size)
ps.nextAt += ps.interval
}
return n, nil
}
func NewProgressStream(writer io.Writer, size int64, callback func(bytesRead, contentLength int64)) *ProgressStream {
var interval int64
interval = size / 100
if interval == 0 {
interval = 1
}
return &ProgressStream{
writer: writer,
size: size,
callback: callback,
nextAt: interval,
interval: interval,
}
}

View File

@@ -7,8 +7,8 @@ import (
"time" "time"
"github.com/glebarez/sqlite" "github.com/glebarez/sqlite"
"github.com/krau/SaveAny-Bot/common"
"github.com/krau/SaveAny-Bot/config" "github.com/krau/SaveAny-Bot/config"
"github.com/krau/SaveAny-Bot/logger"
"gorm.io/gorm" "gorm.io/gorm"
glogger "gorm.io/gorm/logger" glogger "gorm.io/gorm/logger"
) )
@@ -17,12 +17,12 @@ var db *gorm.DB
func Init() { func Init() {
if err := os.MkdirAll(filepath.Dir(config.Cfg.DB.Path), 0755); err != nil { if err := os.MkdirAll(filepath.Dir(config.Cfg.DB.Path), 0755); err != nil {
logger.L.Fatal("Failed to create data directory: ", err) common.Log.Fatal("Failed to create data directory: ", err)
os.Exit(1) os.Exit(1)
} }
var err error var err error
db, err = gorm.Open(sqlite.Open(config.Cfg.DB.Path), &gorm.Config{ db, err = gorm.Open(sqlite.Open(config.Cfg.DB.Path), &gorm.Config{
Logger: glogger.New(logger.L, glogger.Config{ Logger: glogger.New(common.Log, glogger.Config{
Colorful: true, Colorful: true,
SlowThreshold: time.Second * 5, SlowThreshold: time.Second * 5,
LogLevel: glogger.Error, LogLevel: glogger.Error,
@@ -32,16 +32,16 @@ func Init() {
PrepareStmt: true, PrepareStmt: true,
}) })
if err != nil { if err != nil {
logger.L.Fatal("Failed to open database: ", err) common.Log.Fatal("Failed to open database: ", err)
os.Exit(1) os.Exit(1)
} }
logger.L.Debug("Database connected") common.Log.Debug("Database connected")
if err := db.AutoMigrate(&ReceivedFile{}, &User{}, &Dir{}, &CallbackData{}); err != nil { if err := db.AutoMigrate(&ReceivedFile{}, &User{}, &Dir{}, &CallbackData{}); err != nil {
logger.L.Fatal("迁移数据库失败, 如果您从旧版本升级, 建议手动删除数据库文件后重试: ", err) common.Log.Fatal("迁移数据库失败, 如果您从旧版本升级, 建议手动删除数据库文件后重试: ", err)
} }
if err := syncUsers(); err != nil { if err := syncUsers(); err != nil {
logger.L.Fatal("Failed to sync users:", err) common.Log.Fatal("Failed to sync users:", err)
} }
} }
@@ -66,7 +66,7 @@ func syncUsers() error {
if err := CreateUser(cfgID); err != nil { if err := CreateUser(cfgID); err != nil {
return fmt.Errorf("failed to create user %d: %w", cfgID, err) return fmt.Errorf("failed to create user %d: %w", cfgID, err)
} }
logger.L.Infof("创建用户: %d", cfgID) common.Log.Infof("创建用户: %d", cfgID)
} }
} }
@@ -75,7 +75,7 @@ func syncUsers() error {
if err := DeleteUser(&dbUser); err != nil { if err := DeleteUser(&dbUser); err != nil {
return fmt.Errorf("failed to delete user %d: %w", dbID, err) return fmt.Errorf("failed to delete user %d: %w", dbID, err)
} }
logger.L.Infof("删除用户: %d", dbID) common.Log.Infof("删除用户: %d", dbID)
} }
} }

View File

@@ -14,6 +14,8 @@ type ReceivedFile struct {
ReplyMessageID int ReplyMessageID int
ReplyChatID int64 ReplyChatID int64
FileName string FileName string
IsTelegraph bool
TelegraphURL string
} }
type User struct { type User struct {

View File

@@ -7,4 +7,7 @@ services:
- ./data:/app/data - ./data:/app/data
- ./config.toml:/app/config.toml - ./config.toml:/app/config.toml
- ./downloads:/app/downloads - ./downloads:/app/downloads
- ./cache:/app/cache - ./cache:/app/cache
# 使用 host 模式以便访问宿主机服务 (如代理)
# 如果你对 Docker 网络模式熟悉, 可以自行修改
network_mode: host

94
docs/docs/deploy.md Normal file
View File

@@ -0,0 +1,94 @@
# 部署指南
## 从二进制文件部署
在 [Release](https://github.com/krau/SaveAny-Bot/releases) 页面下载对应平台的二进制文件.
在解压后目录新建 `config.toml` 文件, 参考 [config.example.toml](https://github.com/krau/SaveAny-Bot/blob/main/config.example.toml) 编辑配置文件.
运行:
```bash
chmod +x saveany-bot
./saveany-bot
```
### 添加为 systemd 服务
创建文件 `/etc/systemd/system/saveany-bot.service` 并写入以下内容:
```
[Unit]
Description=SaveAnyBot
After=systemd-user-sessions.service
[Service]
Type=simple
WorkingDirectory=/yourpath/
ExecStart=/yourpath/saveany-bot
Restart=on-failure
[Install]
WantedBy=multi-user.target
```
设为开机启动并启动服务:
```bash
systemctl enable --now saveany-bot
```
### 为OpenWrt及衍生系统添加开机自启动服务
创建文件 ` /etc/init.d/saveanybot` ,参考[saveanybot](https://github.com/krau/SaveAny-Bot/blob/main/docs/saveanybot)自行修改.
`chmod +x /etc/init.d/saveanybot`
完成后,将文件复制到 `/etc/rc.d`并重命名为`S99saveanybot`.
`chmod +x /etc/rc.d/S99saveanybot`
### 为OpenWrt及衍生系统添加快捷指令
创建文件` /usr/bin/sabot` ,参考[sabot](https://github.com/krau/SaveAny-Bot/blob/main/docs/sabot)自行配置修改,注意此处文件编码仅支持 ANSI 936 .
`chmod +x /usr/bin/sabot`
之后,终端输入`sabot start|stop|restart|status|enable|disable`即可.
## 使用 Docker 部署
### Docker Compose
下载 [docker-compose.yml](https://github.com/krau/SaveAny-Bot/blob/main/docker-compose.yml) 文件, 在同目录下新建 `config.toml` 文件, 参考 [config.example.toml](https://github.com/krau/SaveAny-Bot/blob/main/config.example.toml) 编辑配置文件.
启动:
```bash
docker compose up -d
```
### Docker
```shell
docker run -d --name saveany-bot \
-v /path/to/config.toml:/app/config.toml \
-v /path/to/downloads:/app/downloads \
ghcr.io/krau/saveany-bot:latest
```
## 更新
使用 `upgrade``up` 升级到最新版
```bash
./saveany-bot upgrade
```
如果是 Docker 部署, 使用以下命令更新:
```bash
docker pull ghcr.io/krau/saveany-bot:latest
docker restart saveany-bot
```

16
docs/docs/faq.md Normal file
View File

@@ -0,0 +1,16 @@
# 常见问题
## 上传 alist 失败也会显示成功
在 alist 管理页面适当调整上传分片大小, 为 alist 使用更稳定的网络环境部署, 都可以减少这种情况的发生.
## Bot 提示下载成功但是 alist 未显示
alist 缓存了目录结构, 参考文档可以调整缓存时间
https://alist.nn.ci/zh/guide/drivers/common.html#缓存过期
## docker部署配置了代理后仍无法连接 telegram (初始化客户端超时)
docker 不能直接访问宿主机网络, 如果你不熟悉其用法, 请将容器设为 host 模式:

35
docs/docs/help.md Normal file
View File

@@ -0,0 +1,35 @@
# 使用帮助
## 保存文件
Bot 接受两种消息: 文件和链接.
目前, 链接仅支持公开频道 (具有用户名) 的链接, 例如: `https://t.me/acherkrau/1097`.
**即使频道禁止了转发和保存, Bot 依然可以下载其文件.**
## 静默模式 (silent)
使用 `/silent` 命令可以开关静默模式.
默认情况下不开启静默模式, Bot 会询问你每个文件的保存位置.
开启静默模式后, Bot 会直接保存文件到默认位置, 无需确认.
在开启静默模式之前, 需要使用 `/storage` 命令设置默认保存位置.
## Stream 模式
在配置文件中将 `stream` 设置为 `true` 可以开启 Stream 模式.
未开启时, Bot 处理任务分为两步: 下载和上传. Bot 会将文件暂存到本地, 然后上传到对应存储位置, 最后删除本地文件.
开启后, Bot 将直接将文件流式传输到存储端, 不需要下载到本地.
该功能对于硬盘空间有限的部署环境十分有用, 然而相较于普通模式也具有一些弊端:
- 无法使用多线程从 telegram 下载文件, 速度较慢.
- 网络不稳定时, 任务失败率高.
- 无法在中间层对文件进行处理, 例如自动文件类型识别.
虽然目前 Bot 适配的所有存储端 (Alist, 本地磁盘, Webdav) 都支持 Stream 模式, 但今后可能会有不支持的存储端, 此时即使开启 Stream 模式, Bot 也会自动切换到普通模式.

7
docs/docs/index.md Normal file
View File

@@ -0,0 +1,7 @@
# SaveAnyBot 文档
SaveAnyBot 是一个可以保存 Telegram 上的文件到云存储的机器人, 就像 PikPak Bot 一样.
不同的是, SaveAnyBot 提供更灵活的存储端选择, 并实现一些更强大的功能.
本项目以 AGPL-3.0 协议开源, 请遵守协议使用.

33
docs/mkdocs.yml Normal file
View File

@@ -0,0 +1,33 @@
site_name: SaveAnyBot 官方文档
site_author: Krau
site_description: SaveAnyBot 是一个可以保存 Telegram 上的文件到多种云存储的机器人, 本文档将帮助你了解如何部署和使用它.
repo_name: krau/saveany-bot
repo_url: https://github.com/krau/saveany-bot
copyright: CC BY-NC-SA 4.0
theme:
name: material
language: zh
highlightjs: true
palette:
- media: "(prefers-color-scheme)"
toggle:
icon: material/brightness-auto
name: 切换主题
- media: "(prefers-color-scheme: light)"
scheme: default
primary: indigo
toggle:
icon: material/brightness-7
name: 暗色模式
- media: "(prefers-color-scheme: dark)"
scheme: slate
primary: blue grey
toggle:
icon: material/brightness-4
name: 亮色模式
nav:
- index.md
- deploy.md
- help.md
- faq.md

22
go.mod
View File

@@ -5,20 +5,20 @@ go 1.23.5
require ( require (
github.com/blang/semver v3.5.1+incompatible github.com/blang/semver v3.5.1+incompatible
github.com/celestix/gotgproto v1.0.0-beta20.2 github.com/celestix/gotgproto v1.0.0-beta20.2
github.com/celestix/telegraph-go/v2 v2.0.4
github.com/gabriel-vasile/mimetype v1.4.8 github.com/gabriel-vasile/mimetype v1.4.8
github.com/gookit/slog v0.5.7 github.com/gookit/slog v0.5.7
github.com/gotd/contrib v0.21.0 github.com/gotd/contrib v0.21.0
github.com/gotd/td v0.120.0 github.com/gotd/td v0.120.0
github.com/minio/minio-go/v7 v7.0.81
github.com/rhysd/go-github-selfupdate v1.2.3 github.com/rhysd/go-github-selfupdate v1.2.3
github.com/spf13/cobra v1.8.1 github.com/spf13/cobra v1.8.1
github.com/spf13/viper v1.19.0 github.com/spf13/viper v1.19.0
github.com/studio-b12/gowebdav v0.10.0 golang.org/x/net v0.37.0
golang.org/x/net v0.35.0
golang.org/x/time v0.10.0 golang.org/x/time v0.10.0
) )
require ( require (
filippo.io/edwards25519 v1.1.0 // indirect
github.com/AnimeKaizoku/cacher v1.0.2 // indirect github.com/AnimeKaizoku/cacher v1.0.2 // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect
@@ -32,7 +32,8 @@ require (
github.com/go-faster/jx v1.1.0 // indirect github.com/go-faster/jx v1.1.0 // indirect
github.com/go-faster/xor v1.0.0 // indirect github.com/go-faster/xor v1.0.0 // indirect
github.com/go-faster/yaml v0.4.6 // indirect github.com/go-faster/yaml v0.4.6 // indirect
github.com/go-sql-driver/mysql v1.8.1 // indirect github.com/go-ini/ini v1.67.0 // indirect
github.com/goccy/go-json v0.10.3 // indirect
github.com/google/go-github/v30 v30.1.0 // indirect github.com/google/go-github/v30 v30.1.0 // indirect
github.com/google/go-querystring v1.1.0 // indirect github.com/google/go-querystring v1.1.0 // indirect
github.com/google/pprof v0.0.0-20250128161936-077ca0a936bf // indirect github.com/google/pprof v0.0.0-20250128161936-077ca0a936bf // indirect
@@ -42,13 +43,16 @@ require (
github.com/inconshreveable/go-update v0.0.0-20160112193335-8152e7eb6ccf // indirect github.com/inconshreveable/go-update v0.0.0-20160112193335-8152e7eb6ccf // indirect
github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect github.com/jinzhu/now v1.1.5 // indirect
github.com/klauspost/cpuid/v2 v2.2.8 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/minio/md5-simd v1.1.2 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/ogen-go/ogen v1.10.0 // indirect github.com/ogen-go/ogen v1.10.0 // indirect
github.com/onsi/gomega v1.36.2 // indirect github.com/onsi/gomega v1.36.2 // indirect
github.com/pkg/errors v0.9.1 // indirect github.com/pkg/errors v0.9.1 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rs/xid v1.6.0 // indirect
github.com/segmentio/asm v1.2.0 // indirect github.com/segmentio/asm v1.2.0 // indirect
github.com/tcnksm/go-gitconfig v0.1.2 // indirect github.com/tcnksm/go-gitconfig v0.1.2 // indirect
github.com/ulikunitz/xz v0.5.12 // indirect github.com/ulikunitz/xz v0.5.12 // indirect
@@ -57,12 +61,11 @@ require (
go.opentelemetry.io/otel/trace v1.34.0 // indirect go.opentelemetry.io/otel/trace v1.34.0 // indirect
go.uber.org/atomic v1.11.0 // indirect go.uber.org/atomic v1.11.0 // indirect
go.uber.org/zap v1.27.0 // indirect go.uber.org/zap v1.27.0 // indirect
golang.org/x/crypto v0.33.0 // indirect golang.org/x/crypto v0.36.0 // indirect
golang.org/x/mod v0.23.0 // indirect golang.org/x/mod v0.23.0 // indirect
golang.org/x/oauth2 v0.26.0 // indirect golang.org/x/oauth2 v0.26.0 // indirect
golang.org/x/tools v0.30.0 // indirect golang.org/x/tools v0.30.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect
gorm.io/driver/mysql v1.5.6 // indirect
modernc.org/libc v1.61.13 // indirect modernc.org/libc v1.61.13 // indirect
modernc.org/mathutil v1.7.1 // indirect modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.8.2 // indirect modernc.org/memory v1.8.2 // indirect
@@ -95,11 +98,10 @@ require (
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
go.uber.org/multierr v1.11.0 // indirect go.uber.org/multierr v1.11.0 // indirect
golang.org/x/exp v0.0.0-20250210185358-939b2ce775ac // indirect golang.org/x/exp v0.0.0-20250210185358-939b2ce775ac // indirect
golang.org/x/sync v0.11.0 // indirect golang.org/x/sync v0.12.0
golang.org/x/sys v0.30.0 // indirect golang.org/x/sys v0.31.0 // indirect
golang.org/x/text v0.22.0 // indirect golang.org/x/text v0.23.0 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
gorm.io/datatypes v1.2.5
gorm.io/gorm v1.25.12 gorm.io/gorm v1.25.12
) )

74
go.sum
View File

@@ -1,11 +1,11 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/AnimeKaizoku/cacher v1.0.2 h1:7Bf5qRylWb7q2Evib0OXlhG37/t7BP2HK/7IyPvSmGQ= github.com/AnimeKaizoku/cacher v1.0.2 h1:7Bf5qRylWb7q2Evib0OXlhG37/t7BP2HK/7IyPvSmGQ=
github.com/AnimeKaizoku/cacher v1.0.2/go.mod h1:jw0de/b0K6W7Y3T9rHCMGVKUf6oG7hENNcssxYcZTCc= github.com/AnimeKaizoku/cacher v1.0.2/go.mod h1:jw0de/b0K6W7Y3T9rHCMGVKUf6oG7hENNcssxYcZTCc=
github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ= github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ=
github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk= github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk=
github.com/celestix/gotgproto v1.0.0-beta20.2 h1:+WcsKdsyj4xy+TAV+4Sw6zp1xiQrIr4dMnM31+k8NYM= github.com/celestix/gotgproto v1.0.0-beta20.2 h1:+WcsKdsyj4xy+TAV+4Sw6zp1xiQrIr4dMnM31+k8NYM=
github.com/celestix/gotgproto v1.0.0-beta20.2/go.mod h1:j42ZhBMUke6QyBLvCgx8tA+TL9L3+pq/Q46B+b5+3aU= github.com/celestix/gotgproto v1.0.0-beta20.2/go.mod h1:j42ZhBMUke6QyBLvCgx8tA+TL9L3+pq/Q46B+b5+3aU=
github.com/celestix/telegraph-go/v2 v2.0.4 h1:w8HWymJFhMSMPjdGoyTh3/NqE3eXAT1njTvelh0338k=
github.com/celestix/telegraph-go/v2 v2.0.4/go.mod h1:vu2LtqM7MgOAJ2LDF8XK27DWdd1QYLBfZGhalEh086Y=
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
@@ -49,17 +49,14 @@ github.com/go-faster/xor v1.0.0 h1:2o8vTOgErSGHP3/7XwA5ib1FTtUsNtwCoLLBjl31X38=
github.com/go-faster/xor v1.0.0/go.mod h1:x5CaDY9UKErKzqfRfFZdfu+OSTfoZny3w5Ak7UxcipQ= github.com/go-faster/xor v1.0.0/go.mod h1:x5CaDY9UKErKzqfRfFZdfu+OSTfoZny3w5Ak7UxcipQ=
github.com/go-faster/yaml v0.4.6 h1:lOK/EhI04gCpPgPhgt0bChS6bvw7G3WwI8xxVe0sw9I= github.com/go-faster/yaml v0.4.6 h1:lOK/EhI04gCpPgPhgt0bChS6bvw7G3WwI8xxVe0sw9I=
github.com/go-faster/yaml v0.4.6/go.mod h1:390dRIvV4zbnO7qC9FGo6YYutc+wyyUSHBgbXL52eXk= github.com/go-faster/yaml v0.4.6/go.mod h1:390dRIvV4zbnO7qC9FGo6YYutc+wyyUSHBgbXL52eXk=
github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A=
github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA=
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA=
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A=
github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
@@ -97,20 +94,15 @@ github.com/inconshreveable/go-update v0.0.0-20160112193335-8152e7eb6ccf h1:WfD7V
github.com/inconshreveable/go-update v0.0.0-20160112193335-8152e7eb6ccf/go.mod h1:hyb9oH7vZsitZCiBt0ZvifOrB+qc8PS5IiilCIb87rg= github.com/inconshreveable/go-update v0.0.0-20160112193335-8152e7eb6ccf/go.mod h1:hyb9oH7vZsitZCiBt0ZvifOrB+qc8PS5IiilCIb87rg=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20231201235250-de7065d80cb9 h1:L0QtFUgDarD7Fpv9jeVMgy/+Ec0mtnmYuImjTz6dtDA=
github.com/jackc/pgservicefile v0.0.0-20231201235250-de7065d80cb9/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.5.5 h1:amBjrZVmksIdNjxGW/IiIMzxMKZFelXbUoPNb+8sjQw=
github.com/jackc/pgx/v5 v5.5.5/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A=
github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc= github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc=
github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0= github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0=
github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.8 h1:+StwCXwm9PdpiEkPyzBXIy+M9KUb4ODm0Zarf1kS5BM=
github.com/klauspost/cpuid/v2 v2.2.8/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
@@ -124,10 +116,10 @@ github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHP
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM= github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=
github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM=
github.com/microsoft/go-mssqldb v1.7.2 h1:CHkFJiObW7ItKTJfHo1QX7QBBD1iV+mn1eOyRP3b/PA= github.com/minio/minio-go/v7 v7.0.81 h1:SzhMN0TQ6T/xSBu6Nvw3M5M8voM+Ht8RH3hE8S7zxaA=
github.com/microsoft/go-mssqldb v1.7.2/go.mod h1:kOvZKUdrhhFQmxLZqbwUV0rHkNkZpthMITIb2Ko1IoA= github.com/minio/minio-go/v7 v7.0.81/go.mod h1:84gmIilaX4zcvAWWzJ5Z1WI5axN+hAbM5w25xf8xvC0=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= 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/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
@@ -150,6 +142,8 @@ github.com/rhysd/go-github-selfupdate v1.2.3 h1:iaa+J202f+Nc+A8zi75uccC8Wg3omaM7
github.com/rhysd/go-github-selfupdate v1.2.3/go.mod h1:mp/N8zj6jFfBQy/XMYoWsmfzxazpPAODuqarmPDe2Rg= github.com/rhysd/go-github-selfupdate v1.2.3/go.mod h1:mp/N8zj6jFfBQy/XMYoWsmfzxazpPAODuqarmPDe2Rg=
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sagikazarmark/locafero v0.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsFaodPcyo= github.com/sagikazarmark/locafero v0.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsFaodPcyo=
github.com/sagikazarmark/locafero v0.7.0/go.mod h1:2za3Cg5rMaTMoG/2Ulr9AwtFaIppKXTRYnozin4aB5k= github.com/sagikazarmark/locafero v0.7.0/go.mod h1:2za3Cg5rMaTMoG/2Ulr9AwtFaIppKXTRYnozin4aB5k=
@@ -172,8 +166,6 @@ github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI=
github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg= github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/studio-b12/gowebdav v0.10.0 h1:Yewz8FFiadcGEu4hxS/AAJQlHelndqln1bns3hcJIYc=
github.com/studio-b12/gowebdav v0.10.0/go.mod h1:bHA7t77X/QFExdeAnDzK6vKM34kEZAcE1OX4MfiwjkE=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/tcnksm/go-gitconfig v0.1.2 h1:iiDhRitByXAEyjgBqsKi9QU4o2TNtv9kPP3RgPgXBPw= github.com/tcnksm/go-gitconfig v0.1.2 h1:iiDhRitByXAEyjgBqsKi9QU4o2TNtv9kPP3RgPgXBPw=
@@ -203,8 +195,8 @@ go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus= golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M= golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
golang.org/x/exp v0.0.0-20250210185358-939b2ce775ac h1:l5+whBCLH3iH2ZNHYLbAe58bo7yrN4mVcnkHDYz5vvs= golang.org/x/exp v0.0.0-20250210185358-939b2ce775ac h1:l5+whBCLH3iH2ZNHYLbAe58bo7yrN4mVcnkHDYz5vvs=
golang.org/x/exp v0.0.0-20250210185358-939b2ce775ac/go.mod h1:hH+7mtFmImwwcMvScyxUhjuVHR3HGaDPMn9rMSUUbxo= golang.org/x/exp v0.0.0-20250210185358-939b2ce775ac/go.mod h1:hH+7mtFmImwwcMvScyxUhjuVHR3HGaDPMn9rMSUUbxo=
golang.org/x/mod v0.23.0 h1:Zb7khfcRGKk+kqfxFaP5tZqCnDZMjC5VtUBs87Hr6QM= golang.org/x/mod v0.23.0 h1:Zb7khfcRGKk+kqfxFaP5tZqCnDZMjC5VtUBs87Hr6QM=
@@ -213,29 +205,30 @@ golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73r
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8= golang.org/x/net v0.37.0 h1:1zLorHbz+LYj7MQlSf1+2tPIIgibq2eL5xkrGk6f+2c=
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk= golang.org/x/net v0.37.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20181106182150-f42d05182288/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20181106182150-f42d05182288/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.26.0 h1:afQXWNNaeC4nvZ0Ed9XvCCzXM6UHJG7iCg0W4fPqSBE= golang.org/x/oauth2 v0.26.0 h1:afQXWNNaeC4nvZ0Ed9XvCCzXM6UHJG7iCg0W4fPqSBE=
golang.org/x/oauth2 v0.26.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= golang.org/x/oauth2 v0.26.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w= golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw=
golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
golang.org/x/term v0.29.0 h1:L6pJp37ocefwRRtYPKSWOWzOtWSxVajvz2ldH/xi3iU= golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y=
golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s= golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
golang.org/x/time v0.10.0 h1:3usCWA8tQn0L8+hFJQNgzpWbd89begxN66o1Ojdn5L4= golang.org/x/time v0.10.0 h1:3usCWA8tQn0L8+hFJQNgzpWbd89begxN66o1Ojdn5L4=
golang.org/x/time v0.10.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/time v0.10.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@@ -257,17 +250,6 @@ gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/datatypes v1.2.5 h1:9UogU3jkydFVW1bIVVeoYsTpLRgwDVW3rHfJG6/Ek9I=
gorm.io/datatypes v1.2.5/go.mod h1:I5FUdlKpLb5PMqeMQhm30CQ6jXP8Rj89xkTeCSAaAD4=
gorm.io/driver/mysql v1.5.6 h1:Ld4mkIickM+EliaQZQx3uOJDJHtrd70MxAUqWqlx3Y8=
gorm.io/driver/mysql v1.5.6/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM=
gorm.io/driver/postgres v1.5.0 h1:u2FXTy14l45qc3UeCJ7QaAXZmZfDDv0YrthvmRq1l0U=
gorm.io/driver/postgres v1.5.0/go.mod h1:FUZXzO+5Uqg5zzwzv4KK49R8lvGIyscBOqYrtI1Ce9A=
gorm.io/driver/sqlite v1.5.5 h1:7MDMtUZhV065SilG62E0MquljeArQZNfJnjd9i9gx3E=
gorm.io/driver/sqlite v1.5.5/go.mod h1:6NgQ7sQWAIFsPrJJl1lSNSu2TABh0ZZ/zm5fosATavE=
gorm.io/driver/sqlserver v1.5.4 h1:xA+Y1KDNspv79q43bPyjDMUgHoYHLhXYmdFcYPobg8g=
gorm.io/driver/sqlserver v1.5.4/go.mod h1:+frZ/qYmuna11zHPlh5oc2O6ZA/lS88Keb0XSH1Zh/g=
gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8= gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8=
gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ= gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ=
modernc.org/cc/v4 v4.24.4 h1:TFkx1s6dCkQpd6dKurBNmpo+G8Zl4Sq/ztJ+2+DEsh0= modernc.org/cc/v4 v4.24.4 h1:TFkx1s6dCkQpd6dKurBNmpo+G8Zl4Sq/ztJ+2+DEsh0=

View File

@@ -8,30 +8,65 @@ import (
) )
type TaskQueue struct { type TaskQueue struct {
list *list.List list *list.List
cond *sync.Cond cond *sync.Cond
mutex *sync.Mutex mutex *sync.Mutex
activeMap map[string]*types.Task
} }
func (q *TaskQueue) AddTask(task types.Task) { func (q *TaskQueue) AddTask(task *types.Task) {
q.mutex.Lock() q.mutex.Lock()
defer q.mutex.Unlock() defer q.mutex.Unlock()
q.list.PushBack(task) q.list.PushBack(task)
q.cond.Signal() q.cond.Signal()
if task.Status != types.Pending {
delete(q.activeMap, task.Key())
}
} }
func (q *TaskQueue) GetTask() types.Task { func (q *TaskQueue) GetTask() *types.Task {
q.mutex.Lock() q.mutex.Lock()
defer q.mutex.Unlock() defer q.mutex.Unlock()
for q.list.Len() == 0 { for q.list.Len() == 0 {
q.cond.Wait() q.cond.Wait()
} }
e := q.list.Front() e := q.list.Front()
task := e.Value.(types.Task) task := e.Value.(*types.Task)
q.list.Remove(e) q.list.Remove(e)
if task.Status == types.Pending {
q.activeMap[task.Key()] = task
}
return task return task
} }
func (q *TaskQueue) DoneTask(task *types.Task) {
q.mutex.Lock()
defer q.mutex.Unlock()
delete(q.activeMap, task.Key())
}
func (q *TaskQueue) CancelTask(key string) bool {
q.mutex.Lock()
defer q.mutex.Unlock()
if task, ok := q.activeMap[key]; ok {
if task.Cancel != nil {
task.Cancel()
return true
}
}
for e := q.list.Front(); e != nil; e = e.Next() {
task := e.Value.(*types.Task)
if task.Key() == key {
if task.Cancel != nil {
task.Cancel()
}
q.list.Remove(e)
return true
}
}
return false
}
func (q *TaskQueue) Len() int { func (q *TaskQueue) Len() int {
q.mutex.Lock() q.mutex.Lock()
defer q.mutex.Unlock() defer q.mutex.Unlock()
@@ -47,20 +82,29 @@ func init() {
func NewQueue() *TaskQueue { func NewQueue() *TaskQueue {
m := &sync.Mutex{} m := &sync.Mutex{}
return &TaskQueue{ return &TaskQueue{
list: list.New(), list: list.New(),
cond: sync.NewCond(m), cond: sync.NewCond(m),
mutex: m, mutex: m,
activeMap: make(map[string]*types.Task),
} }
} }
func AddTask(task types.Task) { func AddTask(task *types.Task) {
Queue.AddTask(task) Queue.AddTask(task)
} }
func GetTask() types.Task { func GetTask() *types.Task {
return Queue.GetTask() return Queue.GetTask()
} }
func Len() int { func Len() int {
return Queue.Len() return Queue.Len()
} }
func CancelTask(key string) bool {
return Queue.CancelTask(key)
}
func DoneTask(task *types.Task) {
Queue.DoneTask(task)
}

View File

@@ -7,12 +7,11 @@ import (
"io" "io"
"net/http" "net/http"
"net/url" "net/url"
"os"
"path" "path"
"time" "time"
"github.com/krau/SaveAny-Bot/config" "github.com/krau/SaveAny-Bot/common"
"github.com/krau/SaveAny-Bot/logger" config "github.com/krau/SaveAny-Bot/config/storage"
"github.com/krau/SaveAny-Bot/types" "github.com/krau/SaveAny-Bot/types"
) )
@@ -42,36 +41,36 @@ func (a *Alist) Init(cfg config.StorageConfig) error {
defer cancel() defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, a.baseURL+"/api/me", nil) req, err := http.NewRequestWithContext(ctx, http.MethodGet, a.baseURL+"/api/me", nil)
if err != nil { if err != nil {
logger.L.Fatalf("Failed to create request: %v", err) common.Log.Fatalf("Failed to create request: %v", err)
return err return err
} }
req.Header.Set("Authorization", a.token) req.Header.Set("Authorization", a.token)
resp, err := a.client.Do(req) resp, err := a.client.Do(req)
if err != nil { if err != nil {
logger.L.Fatalf("Failed to send request: %v", err) common.Log.Fatalf("Failed to send request: %v", err)
return err return err
} }
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
logger.L.Fatalf("Failed to get alist user info: %s", resp.Status) common.Log.Fatalf("Failed to get alist user info: %s", resp.Status)
return err return err
} }
body, err := io.ReadAll(resp.Body) body, err := io.ReadAll(resp.Body)
if err != nil { if err != nil {
logger.L.Fatalf("Failed to read response body: %v", err) common.Log.Fatalf("Failed to read response body: %v", err)
return err return err
} }
var meResp meResponse var meResp meResponse
if err := json.Unmarshal(body, &meResp); err != nil { if err := json.Unmarshal(body, &meResp); err != nil {
logger.L.Fatalf("Failed to unmarshal me response: %v", err) common.Log.Fatalf("Failed to unmarshal me response: %v", err)
return err return err
} }
if meResp.Code != http.StatusOK { if meResp.Code != http.StatusOK {
logger.L.Fatalf("Failed to get alist user info: %s", meResp.Message) common.Log.Fatalf("Failed to get alist user info: %s", meResp.Message)
return err return err
} }
logger.L.Debugf("Logged in Alist as %s", meResp.Data.Username) common.Log.Debugf("Logged in Alist as %s", meResp.Data.Username)
return nil return nil
} }
a.loginInfo = &loginRequest{ a.loginInfo = &loginRequest{
@@ -80,10 +79,10 @@ func (a *Alist) Init(cfg config.StorageConfig) error {
} }
if err := a.getToken(); err != nil { if err := a.getToken(); err != nil {
logger.L.Fatalf("Failed to login to Alist: %v", err) common.Log.Fatalf("Failed to login to Alist: %v", err)
return err return err
} }
logger.L.Debug("Logged in to Alist") common.Log.Debug("Logged in to Alist")
go a.refreshToken(*alistConfig) go a.refreshToken(*alistConfig)
return nil return nil
@@ -97,28 +96,16 @@ func (a *Alist) Name() string {
return a.config.Name return a.config.Name
} }
func (a *Alist) Save(ctx context.Context, filePath, storagePath string) error { func (a *Alist) Save(ctx context.Context, reader io.Reader, storagePath string) error {
logger.L.Infof("Saving file %s to %s", filePath, storagePath) common.Log.Infof("Saving file to %s", storagePath)
file, err := os.Open(filePath)
if err != nil {
return fmt.Errorf("failed to open file: %w", err)
}
defer file.Close()
filestat, err := file.Stat() req, err := http.NewRequestWithContext(ctx, http.MethodPut, a.baseURL+"/api/fs/put", reader)
if err != nil {
return fmt.Errorf("failed to get file stats: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPut, a.baseURL+"/api/fs/put", file)
if err != nil { if err != nil {
return fmt.Errorf("failed to create request: %w", err) return fmt.Errorf("failed to create request: %w", err)
} }
req.Header.Set("Authorization", a.token) req.Header.Set("Authorization", a.token)
req.Header.Set("File-Path", url.PathEscape(storagePath)) req.Header.Set("File-Path", url.PathEscape(storagePath))
req.Header.Set("As-Task", "true")
req.Header.Set("Content-Type", "application/octet-stream") req.Header.Set("Content-Type", "application/octet-stream")
req.ContentLength = filestat.Size()
resp, err := a.client.Do(req) resp, err := a.client.Do(req)
if err != nil { if err != nil {

View File

@@ -8,8 +8,8 @@ import (
"net/http" "net/http"
"time" "time"
"github.com/krau/SaveAny-Bot/config" "github.com/krau/SaveAny-Bot/common"
"github.com/krau/SaveAny-Bot/logger" config "github.com/krau/SaveAny-Bot/config/storage"
) )
func (a *Alist) getToken() error { func (a *Alist) getToken() error {
@@ -51,15 +51,15 @@ func (a *Alist) getToken() error {
func (a *Alist) refreshToken(cfg config.AlistStorageConfig) { func (a *Alist) refreshToken(cfg config.AlistStorageConfig) {
tokenExp := cfg.TokenExp tokenExp := cfg.TokenExp
if tokenExp <= 0 { if tokenExp <= 0 {
logger.L.Warn("Invalid token expiration time, using default value") common.Log.Warn("Invalid token expiration time, using default value")
tokenExp = 3600 tokenExp = 3600
} }
for { for {
time.Sleep(time.Duration(tokenExp) * time.Second) time.Sleep(time.Duration(tokenExp) * time.Second)
if err := a.getToken(); err != nil { if err := a.getToken(); err != nil {
logger.L.Errorf("Failed to refresh jwt token: %v", err) common.Log.Errorf("Failed to refresh jwt token: %v", err)
continue continue
} }
logger.L.Info("Refreshed Alist jwt token") common.Log.Info("Refreshed Alist jwt token")
} }
} }

View File

@@ -3,12 +3,13 @@ package local
import ( import (
"context" "context"
"fmt" "fmt"
"io"
"os" "os"
"path/filepath" "path/filepath"
"github.com/duke-git/lancet/v2/fileutil" "github.com/duke-git/lancet/v2/fileutil"
"github.com/krau/SaveAny-Bot/config" "github.com/krau/SaveAny-Bot/common"
"github.com/krau/SaveAny-Bot/logger" config "github.com/krau/SaveAny-Bot/config/storage"
"github.com/krau/SaveAny-Bot/types" "github.com/krau/SaveAny-Bot/types"
) )
@@ -40,8 +41,13 @@ func (l *Local) Name() string {
return l.config.Name return l.config.Name
} }
func (l *Local) Save(ctx context.Context, filePath, storagePath string) error { func (l *Local) JoinStoragePath(task types.Task) string {
logger.L.Infof("Saving file %s to %s", filePath, storagePath) return filepath.Join(l.config.BasePath, task.StoragePath)
}
func (l *Local) Save(ctx context.Context, r io.Reader, storagePath string) error {
common.Log.Infof("Saving file to %s", storagePath)
absPath, err := filepath.Abs(storagePath) absPath, err := filepath.Abs(storagePath)
if err != nil { if err != nil {
return err return err
@@ -49,9 +55,11 @@ func (l *Local) Save(ctx context.Context, filePath, storagePath string) error {
if err := fileutil.CreateDir(filepath.Dir(absPath)); err != nil { if err := fileutil.CreateDir(filepath.Dir(absPath)); err != nil {
return err return err
} }
return fileutil.CopyFile(filePath, storagePath) file, err := os.Create(absPath)
} if err != nil {
return err
func (l *Local) JoinStoragePath(task types.Task) string { }
return filepath.Join(l.config.BasePath, task.StoragePath) defer file.Close()
_, err = io.Copy(file, r)
return err
} }

72
storage/minio/client.go Normal file
View File

@@ -0,0 +1,72 @@
package minio
import (
"context"
"fmt"
"io"
"path"
"github.com/krau/SaveAny-Bot/common"
config "github.com/krau/SaveAny-Bot/config/storage"
"github.com/krau/SaveAny-Bot/types"
"github.com/minio/minio-go/v7"
"github.com/minio/minio-go/v7/pkg/credentials"
)
type Minio struct {
config config.MinioStorageConfig
client *minio.Client
}
func (m *Minio) Init(cfg config.StorageConfig) error {
minioConfig, ok := cfg.(*config.MinioStorageConfig)
if !ok {
return fmt.Errorf("failed to cast minio config")
}
if err := minioConfig.Validate(); err != nil {
return err
}
m.config = *minioConfig
client, err := minio.New(m.config.Endpoint, &minio.Options{
Creds: credentials.NewStaticV4(m.config.AccessKeyID, m.config.SecretAccessKey, ""),
Secure: m.config.UseSSL,
})
if err != nil {
return fmt.Errorf("failed to create minio client: %w", err)
}
exists, err := client.BucketExists(context.Background(), m.config.BucketName)
if err != nil {
return fmt.Errorf("failed to check bucket existence: %w", err)
}
if !exists {
return fmt.Errorf("bucket %s does not exist", m.config.BucketName)
}
m.client = client
return nil
}
func (m *Minio) Type() types.StorageType {
return types.StorageTypeMinio
}
func (m *Minio) Name() string {
return m.config.Name
}
func (m *Minio) JoinStoragePath(task types.Task) string {
return path.Join(m.config.BasePath, task.StoragePath)
}
func (m *Minio) Save(ctx context.Context, r io.Reader, storagePath string) error {
common.Log.Infof("Saving file from reader to %s", storagePath)
_, err := m.client.PutObject(ctx, m.config.BucketName, storagePath, r, -1, minio.PutObjectOptions{})
if err != nil {
return fmt.Errorf("failed to upload file to minio: %w", err)
}
return nil
}

View File

@@ -3,21 +3,24 @@ package storage
import ( import (
"context" "context"
"fmt" "fmt"
"io"
"github.com/krau/SaveAny-Bot/common"
"github.com/krau/SaveAny-Bot/config" "github.com/krau/SaveAny-Bot/config"
"github.com/krau/SaveAny-Bot/logger" sc "github.com/krau/SaveAny-Bot/config/storage"
"github.com/krau/SaveAny-Bot/storage/alist" "github.com/krau/SaveAny-Bot/storage/alist"
"github.com/krau/SaveAny-Bot/storage/local" "github.com/krau/SaveAny-Bot/storage/local"
"github.com/krau/SaveAny-Bot/storage/minio"
"github.com/krau/SaveAny-Bot/storage/webdav" "github.com/krau/SaveAny-Bot/storage/webdav"
"github.com/krau/SaveAny-Bot/types" "github.com/krau/SaveAny-Bot/types"
) )
type Storage interface { type Storage interface {
Init(cfg config.StorageConfig) error Init(cfg sc.StorageConfig) error
Type() types.StorageType Type() types.StorageType
Name() string Name() string
JoinStoragePath(task types.Task) string JoinStoragePath(task types.Task) string
Save(cttx context.Context, localFilePath, storagePath string) error Save(ctx context.Context, reader io.Reader, storagePath string) error
} }
var Storages = make(map[string]Storage) var Storages = make(map[string]Storage)
@@ -84,9 +87,10 @@ var storageConstructors = map[string]StorageConstructor{
string(types.StorageTypeAlist): func() Storage { return new(alist.Alist) }, string(types.StorageTypeAlist): func() Storage { return new(alist.Alist) },
string(types.StorageTypeLocal): func() Storage { return new(local.Local) }, string(types.StorageTypeLocal): func() Storage { return new(local.Local) },
string(types.StorageTypeWebdav): func() Storage { return new(webdav.Webdav) }, string(types.StorageTypeWebdav): func() Storage { return new(webdav.Webdav) },
string(types.StorageTypeMinio): func() Storage { return new(minio.Minio) },
} }
func NewStorage(cfg config.StorageConfig) (Storage, error) { func NewStorage(cfg sc.StorageConfig) (Storage, error) {
constructor, ok := storageConstructors[string(cfg.GetType())] constructor, ok := storageConstructors[string(cfg.GetType())]
if !ok { if !ok {
return nil, fmt.Errorf("不支持的存储类型: %s", cfg.GetType()) return nil, fmt.Errorf("不支持的存储类型: %s", cfg.GetType())
@@ -101,14 +105,14 @@ func NewStorage(cfg config.StorageConfig) (Storage, error) {
} }
func LoadStorages() { func LoadStorages() {
logger.L.Info("加载存储...") common.Log.Info("加载存储...")
for _, storage := range config.Cfg.Storages { for _, storage := range config.Cfg.Storages {
_, err := GetStorageByName(storage.GetName()) _, err := GetStorageByName(storage.GetName())
if err != nil { if err != nil {
logger.L.Errorf("加载存储 %s 失败: %v", storage.GetName(), err) common.Log.Errorf("加载存储 %s 失败: %v", storage.GetName(), err)
} }
} }
logger.L.Infof("成功加载 %d 个存储", len(Storages)) common.Log.Infof("成功加载 %d 个存储", len(Storages))
for user := range config.Cfg.GetUsersID() { for user := range config.Cfg.GetUsersID() {
UserStorages[int64(user)] = GetUserStorages(int64(user)) UserStorages[int64(user)] = GetUserStorages(int64(user))
} }

70
storage/webdav/client.go Normal file
View File

@@ -0,0 +1,70 @@
package webdav
import (
"context"
"fmt"
"io"
"net/http"
"strings"
)
type Client struct {
BaseURL string
Username string
Password string
httpClient *http.Client
}
func NewClient(baseURL, username, password string, httpClient *http.Client) *Client {
if !strings.HasSuffix(baseURL, "/") {
baseURL += "/"
}
if httpClient == nil {
httpClient = http.DefaultClient
}
return &Client{
BaseURL: baseURL,
Username: username,
Password: password,
httpClient: httpClient,
}
}
func (c *Client) doRequest(ctx context.Context, method, url string, body io.Reader) (*http.Response, error) {
req, err := http.NewRequestWithContext(ctx, method, url, body)
if err != nil {
return nil, err
}
if c.Username != "" && c.Password != "" {
req.SetBasicAuth(c.Username, c.Password)
}
return c.httpClient.Do(req)
}
func (c *Client) MkDir(ctx context.Context, dirPath string) error {
url := c.BaseURL + dirPath
resp, err := c.doRequest(ctx, "MKCOL", url, nil)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
return nil
}
return fmt.Errorf("MKCOL: %s", resp.Status)
}
func (c *Client) WriteFile(ctx context.Context, remotePath string, content io.Reader) error {
url := c.BaseURL + remotePath
resp, err := c.doRequest(ctx, "PUT", url, content)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
return nil
}
return fmt.Errorf("PUT: %s", resp.Status)
}

View File

@@ -3,19 +3,19 @@ package webdav
import ( import (
"context" "context"
"fmt" "fmt"
"os" "io"
"net/http"
"path" "path"
"time" "time"
"github.com/krau/SaveAny-Bot/config" "github.com/krau/SaveAny-Bot/common"
"github.com/krau/SaveAny-Bot/logger" config "github.com/krau/SaveAny-Bot/config/storage"
"github.com/krau/SaveAny-Bot/types" "github.com/krau/SaveAny-Bot/types"
"github.com/studio-b12/gowebdav"
) )
type Webdav struct { type Webdav struct {
config config.WebdavStorageConfig config config.WebdavStorageConfig
client *gowebdav.Client client *Client
} }
func (w *Webdav) Init(cfg config.StorageConfig) error { func (w *Webdav) Init(cfg config.StorageConfig) error {
@@ -27,12 +27,9 @@ func (w *Webdav) Init(cfg config.StorageConfig) error {
return err return err
} }
w.config = *webdavConfig w.config = *webdavConfig
client := gowebdav.NewClient(webdavConfig.URL, webdavConfig.Username, webdavConfig.Password) w.client = NewClient(w.config.URL, w.config.Username, w.config.Password, &http.Client{
if err := client.Connect(); err != nil { Timeout: time.Hour * 12,
return fmt.Errorf("failed to connect to webdav server: %w", err) })
}
client.SetTimeout(12 * time.Hour)
w.client = client
return nil return nil
} }
@@ -44,26 +41,19 @@ func (w *Webdav) Name() string {
return w.config.Name return w.config.Name
} }
func (w *Webdav) Save(ctx context.Context, filePath, storagePath string) error { func (w *Webdav) JoinStoragePath(task types.Task) string {
logger.L.Infof("Saving file %s to %s", filePath, storagePath) return path.Join(w.config.BasePath, task.StoragePath)
if err := w.client.MkdirAll(path.Dir(storagePath), os.ModePerm); err != nil { }
logger.L.Errorf("Failed to create directory %s: %v", path.Dir(storagePath), err)
func (w *Webdav) Save(ctx context.Context, r io.Reader, storagePath string) error {
common.Log.Infof("Saving file to %s", storagePath)
if err := w.client.MkDir(ctx, path.Dir(storagePath)); err != nil {
common.Log.Errorf("Failed to create directory %s: %v", path.Dir(storagePath), err)
return ErrFailedToCreateDirectory return ErrFailedToCreateDirectory
} }
file, err := os.Open(filePath) if err := w.client.WriteFile(ctx, storagePath, r); err != nil {
if err != nil { common.Log.Errorf("Failed to write file %s: %v", storagePath, err)
logger.L.Errorf("Failed to open file %s: %v", filePath, err)
return err
}
defer file.Close()
if err := w.client.WriteStream(storagePath, file, os.ModePerm); err != nil {
logger.L.Errorf("Failed to write file %s: %v", storagePath, err)
return ErrFailedToWriteFile return ErrFailedToWriteFile
} }
return nil return nil
} }
func (w *Webdav) JoinStoragePath(task types.Task) string {
return path.Join(w.config.BasePath, task.StoragePath)
}

View File

@@ -5,6 +5,8 @@ import (
"crypto/md5" "crypto/md5"
"encoding/hex" "encoding/hex"
"fmt" "fmt"
"net/url"
"strings"
"time" "time"
"github.com/gotd/td/tg" "github.com/gotd/td/tg"
@@ -25,38 +27,62 @@ var (
StorageTypeLocal StorageType = "local" StorageTypeLocal StorageType = "local"
StorageTypeWebdav StorageType = "webdav" StorageTypeWebdav StorageType = "webdav"
StorageTypeAlist StorageType = "alist" StorageTypeAlist StorageType = "alist"
StorageTypeMinio StorageType = "minio"
) )
var StorageTypes = []StorageType{StorageTypeLocal, StorageTypeAlist, StorageTypeWebdav} var StorageTypes = []StorageType{StorageTypeLocal, StorageTypeAlist, StorageTypeWebdav, StorageTypeMinio}
var StorageTypeDisplay = map[StorageType]string{ var StorageTypeDisplay = map[StorageType]string{
StorageTypeLocal: "本地磁盘", StorageTypeLocal: "本地磁盘",
StorageTypeWebdav: "WebDAV", StorageTypeWebdav: "WebDAV",
StorageTypeAlist: "Alist", StorageTypeAlist: "Alist",
StorageTypeMinio: "Minio",
} }
type Task struct { type Task struct {
Ctx context.Context Ctx context.Context
Cancel context.CancelFunc
Error error Error error
Status TaskStatus Status TaskStatus
File *File
StorageName string StorageName string
StoragePath string StoragePath string
StartTime time.Time StartTime time.Time
File *File
FileMessageID int FileMessageID int
FileChatID int64 FileChatID int64
IsTelegraph bool
TelegraphURL string
// to track the reply message // to track the reply message
ReplyMessageID int ReplyMessageID int
ReplyChatID int64 ReplyChatID int64
// to track the user UserID int64
UserID int64 }
func (t Task) Key() string {
if t.IsTelegraph {
return hashStr(t.TelegraphURL)
}
return fmt.Sprintf("%d:%d", t.FileChatID, t.FileMessageID)
} }
func (t Task) String() string { func (t Task) String() string {
if t.IsTelegraph {
return fmt.Sprintf("[telegraph]:%s", t.TelegraphURL)
}
return fmt.Sprintf("[%d:%d]:%s", t.FileChatID, t.FileMessageID, t.File.FileName) return fmt.Sprintf("[%d:%d]:%s", t.FileChatID, t.FileMessageID, t.File.FileName)
} }
func (t Task) FileName() string { func (t Task) FileName() string {
if t.IsTelegraph {
tgphPath := strings.Split(t.TelegraphURL, "/")[len(strings.Split(t.TelegraphURL, "/"))-1]
tgphPathUnescaped, err := url.PathUnescape(tgphPath)
if err != nil {
return tgphPath
}
return tgphPathUnescaped
}
return t.File.FileName return t.File.FileName
} }

12
types/utils.go Normal file
View File

@@ -0,0 +1,12 @@
package types
import (
"crypto/md5"
"encoding/hex"
)
func hashStr(s string) string {
hash := md5.New()
hash.Write([]byte(s))
return hex.EncodeToString(hash.Sum(nil))
}