Compare commits

...

11 Commits

Author SHA1 Message Date
krau
edef778959 feat: 添加块大小配置项 2024-10-12 13:36:17 +08:00
krau
0bd9b77856 fix: mk cache dir 2024-10-12 13:27:39 +08:00
krau
d5f214494c feat: configurable download threads and workers 2024-10-12 13:19:25 +08:00
krau
b5af39c132 chore: update readme 2024-10-12 13:09:56 +08:00
krau
4aba14c4c7 feat: set full commands 2024-10-12 13:05:12 +08:00
krau
a66fa5df81 chore: update config and readme 2024-10-12 13:04:13 +08:00
krau
f58e28412f feat: task progress 2024-10-12 13:03:11 +08:00
krau
c8376f78a2 feat: reimpl save command 2024-10-12 12:55:44 +08:00
krau
5d6b1003e9 refactor: use MTP API 2024-10-12 12:47:33 +08:00
krau
5ef7c5ce60 fix: timeout in alist and webdav 2024-10-11 16:50:47 +08:00
krau
03c833acbf refactor: optimize timeout setting in initClient function 2024-10-11 16:38:43 +08:00
18 changed files with 310 additions and 378 deletions

4
.gitignore vendored
View File

@@ -3,4 +3,6 @@ logs/
tmp/ tmp/
data/ data/
downloads/ downloads/
cache/ cache/
session.*
cache.db

View File

@@ -1,8 +1,8 @@
# Save Any Bot # Save Any Bot
把 Telegram 的文件保存到各类存储端. 把 Telegram 的文件保存到各类存储端.
> *就像 PikPak Bot 一样* > _就像 PikPak Bot 一样_
## 部署 ## 部署
@@ -10,11 +10,6 @@
在解压后目录新建 `config.toml` 文件, 参考 [config.toml.example](https://github.com/krau/SaveAny-Bot/blob/main/config.example.toml) 编辑配置文件. 在解压后目录新建 `config.toml` 文件, 参考 [config.toml.example](https://github.com/krau/SaveAny-Bot/blob/main/config.example.toml) 编辑配置文件.
> [!TIP]
> 由于 Telegram 官方 Bot API 的限制, Bot 无法下载大于 20MB 的文件. 你需要部署一个本地的 Telegram Bot API 来解决这个问题, 然后将配置文件中的 telegram.api 改为你自己的 api 地址.
>
> 参考: [telegram-bot-api-compose](https://github.com/krau/telegram-bot-api-compose)
运行: 运行:
```bash ```bash
@@ -24,4 +19,17 @@ chmod +x saveany-bot
## 使用 ## 使用
向 Bot 发送(转发)文件, 按照提示操作. 向 Bot 发送(转发)文件, 按照提示操作.
## Bot API 版本 (v0.3.0 前)
> Bot API 版本自身不需要 API_ID 和 API_HASH, 但是部署 Telegram Bot API 服务器仍然需要.
由于 Telegram 官方 Bot API 的限制, Bot 无法下载大于 20MB 的文件. 你需要部署一个本地的 Telegram Bot API 来解决这个问题, 然后在配置文件改为你自己的 api 地址
```toml
[telegram]
api = "http://localhost:8081"
```
参考: [telegram-bot-api-compose](https://github.com/krau/telegram-bot-api-compose)

View File

@@ -3,108 +3,57 @@ package bot
import ( import (
"os" "os"
"github.com/duke-git/lancet/v2/slice" "github.com/amarnathcjd/gogram/telegram"
"github.com/krau/SaveAny-Bot/config" "github.com/krau/SaveAny-Bot/config"
"github.com/krau/SaveAny-Bot/logger" "github.com/krau/SaveAny-Bot/logger"
"github.com/mymmrac/telego"
"github.com/mymmrac/telego/telegohandler"
"github.com/mymmrac/telego/telegoutil"
) )
var ( var (
Bot *telego.Bot Client *telegram.Client
) )
func Init() { func Init() {
logger.L.Debug("Initializing bot...") logger.L.Debug("Initializing bot...")
var err error var err error
Bot, err = telego.NewBot( Client, err = telegram.NewClient(telegram.ClientConfig{
config.Cfg.Telegram.Token, AppID: config.Cfg.Telegram.AppID,
telego.WithDefaultLogger(false, true), AppHash: config.Cfg.Telegram.AppHash,
telego.WithAPIServer(config.Cfg.Telegram.API), LogLevel: telegram.LogInfo,
) })
if err != nil { if err != nil {
logger.L.Fatal("Failed to create bot: ", err) logger.L.Fatal("Failed to create telegram client: ", err)
os.Exit(1) os.Exit(1)
} }
Bot.SetMyCommands(&telego.SetMyCommandsParams{ if err := Client.LoginBot(config.Cfg.Telegram.Token); err != nil {
Commands: []telego.BotCommand{ logger.L.Fatal("Failed to login bot: ", err)
{Command: "start", Description: "开始使用"}, os.Exit(1)
{Command: "help", Description: "显示帮助"}, }
{Command: "silent", Description: "静默模式"}, logger.L.Info("Bot logged in")
{Command: "storage", Description: "设置默认存储位置"}, _, err = Client.BotsSetBotCommands(&telegram.BotCommandScopeDefault{}, "", []*telegram.BotCommand{
{Command: "save", Description: "保存文件"}, {Command: "start", Description: "开始使用"},
{Command: "clean", Description: "清除文件记录"}, {Command: "help", Description: "显示帮助"},
}, {Command: "silent", Description: "静默模式"},
{Command: "storage", Description: "设置默认存储位置"},
{Command: "save", Description: "保存所回复文件"},
}) })
logger.L.Debug("Bot initialized") if err != nil {
logger.L.Errorf("Failed to set bot commands: ", err)
}
logger.L.Info("Bot initialized")
} }
func Run() { func Run() {
if Bot == nil { if Client == nil {
Init() Init()
} }
logger.L.Info("Start polling...")
updates, err := Bot.UpdatesViaLongPolling(&telego.GetUpdatesParams{
Offset: -1,
AllowedUpdates: []string{
telego.MessageUpdates,
telego.CallbackQueryUpdates,
},
})
if err != nil {
logger.L.Fatal("Failed to start polling: ", err)
os.Exit(1)
}
botHandler, err := telegohandler.NewBotHandler(Bot, updates)
if err != nil {
logger.L.Fatal("Failed to create bot handler: ", err)
os.Exit(1)
}
defer botHandler.Stop()
defer Bot.StopLongPolling()
botHandler.Use(telegohandler.PanicRecovery()) Client.On("command:start", Start, telegram.FilterPrivate, telegram.FilterChats(config.Cfg.Telegram.Admins...))
baseGroup := botHandler.BaseGroup() Client.On("command:help", Help, telegram.FilterPrivate, telegram.FilterChats(config.Cfg.Telegram.Admins...))
Client.On("command:silent", ChangeSilentMode, telegram.FilterPrivate, telegram.FilterChats(config.Cfg.Telegram.Admins...))
Client.On("command:storage", SetDefaultStorage, telegram.FilterPrivate, telegram.FilterChats(config.Cfg.Telegram.Admins...))
Client.On("command:save", SaveCmd, telegram.FilterPrivate, telegram.FilterChats(config.Cfg.Telegram.Admins...))
Client.On(telegram.OnMessage, HandleFileMessage, telegram.FilterPrivate, telegram.FilterChats(config.Cfg.Telegram.Admins...), telegram.FilterMedia)
Client.On("callback:add", AddToQueue)
registerHandlers(baseGroup) Client.Idle()
botHandler.Start()
}
func registerHandlers(hg *telegohandler.HandlerGroup) {
msgGroup := hg.Group(telegohandler.AnyMessage())
msgGroup.Use(func(bot *telego.Bot, update telego.Update, next telegohandler.Handler) {
if !slice.Contain(config.Cfg.Telegram.Admins, update.Message.From.ID) {
bot.SendMessage(telegoutil.Message(update.Message.Chat.ChatID(), "抱歉, 该 Bot 为个人使用设计, 您可以部署自己的 SaveAnyBot 实例: https://github.com/krau/SaveAny-Bot"))
return
}
next(bot, update)
})
msgGroup.HandleMessageCtx(Start, telegohandler.CommandEqual("start"))
msgGroup.HandleMessageCtx(Help, telegohandler.CommandEqual("help"))
msgGroup.HandleMessageCtx(ChangeSilentMode, telegohandler.CommandEqual("silent"))
msgGroup.HandleMessageCtx(SetDefaultStorage, telegohandler.CommandEqual("storage"))
msgGroup.HandleMessageCtx(SaveFile, telegohandler.CommandEqual("save"))
msgGroup.HandleMessageCtx(CleanReceivedFile, telegohandler.CommandEqual("clean"))
msgGroup.HandleMessageCtx(HandleFileMessage, func(update telego.Update) bool {
return update.Message.Document != nil || update.Message.Video != nil || update.Message.Audio != nil
})
callbackGroup := hg.Group(telegohandler.AnyCallbackQueryWithMessage())
callbackGroup.Use(func(bot *telego.Bot, update telego.Update, next telegohandler.Handler) {
if !slice.Contain(config.Cfg.Telegram.Admins, update.CallbackQuery.From.ID) {
bot.AnswerCallbackQuery(telegoutil.
CallbackQuery(update.CallbackQuery.ID).
WithText("抱歉, 该 Bot 为个人使用设计, 您可以部署自己的 SaveAnyBot 实例: https://github.com/krau/SaveAny-Bot").
WithShowAlert().
WithCacheTime(60))
return
}
next(bot, update)
})
callbackGroup.HandleCallbackQueryCtx(AddToQueue, telegohandler.CallbackDataPrefix("add"))
} }

View File

@@ -3,10 +3,10 @@ package bot
import ( import (
"context" "context"
"fmt" "fmt"
"path/filepath"
"strconv" "strconv"
"strings" "strings"
"github.com/amarnathcjd/gogram/telegram"
"github.com/duke-git/lancet/v2/slice" "github.com/duke-git/lancet/v2/slice"
"github.com/gookit/goutil/maputil" "github.com/gookit/goutil/maputil"
"github.com/krau/SaveAny-Bot/dao" "github.com/krau/SaveAny-Bot/dao"
@@ -15,19 +15,18 @@ import (
"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"
"github.com/mymmrac/telego"
"github.com/mymmrac/telego/telegoutil" "github.com/mymmrac/telego/telegoutil"
) )
func Start(ctx context.Context, bot *telego.Bot, message telego.Message) { func Start(message *telegram.NewMessage) error {
if err := dao.CreateUser(message.From.ID); err != nil { if err := dao.CreateUser(message.ChatID()); err != nil {
logger.L.Errorf("Failed to create user: %s", err) logger.L.Errorf("Failed to create user: %s", err)
return return err
} }
Help(ctx, bot, message) return Help(message)
} }
func Help(ctx context.Context, bot *telego.Bot, message telego.Message) { func Help(message *telegram.NewMessage) error {
helpText := ` helpText := `
SaveAny Bot - 转存你的 Telegram 文件 SaveAny Bot - 转存你的 Telegram 文件
命令: 命令:
@@ -36,230 +35,237 @@ SaveAny Bot - 转存你的 Telegram 文件
/silent - 静默模式 /silent - 静默模式
/storage - 设置默认存储位置 /storage - 设置默认存储位置
/save - 保存文件 /save - 保存文件
/clean - 清除文件记录
静默模式: 开启后 Bot 直接保存到收到的文件到默认位置, 不再询问 静默模式: 开启后 Bot 直接保存到收到的文件到默认位置, 不再询问
` `
ReplyMessage(message, helpText) if _, err := message.Reply(helpText); err != nil {
logger.L.Errorf("Failed to send help message: %s", err)
return err
}
return nil
} }
func ChangeSilentMode(ctx context.Context, bot *telego.Bot, message telego.Message) { func ChangeSilentMode(message *telegram.NewMessage) error {
user, err := dao.GetUserByUserID(message.From.ID) user, err := dao.GetUserByUserID(message.ChatID())
if err != nil { if err != nil {
logger.L.Error(err) logger.L.Error(err)
return return err
} }
user.Silent = !user.Silent user.Silent = !user.Silent
err = dao.UpdateUser(user) err = dao.UpdateUser(user)
if err != nil { if err != nil {
logger.L.Error(err) logger.L.Error(err)
return return err
} }
ReplyMessage(message, fmt.Sprintf("已%s静默模式", map[bool]string{true: "开启", false: "关闭"}[user.Silent])) if _, err := message.Reply(fmt.Sprintf("已%s静默模式", map[bool]string{true: "开启", false: "关闭"}[user.Silent])); err != nil {
return err
}
return nil
} }
func SetDefaultStorage(ctx context.Context, bot *telego.Bot, message telego.Message) { func SetDefaultStorage(message *telegram.NewMessage) error {
if len(storage.Storages) == 0 { if len(storage.Storages) == 0 {
ReplyMessage(message, "当前无可用存储端, 请检查配置.") message.Reply("当前无可用存储端, 请检查配置.")
return return nil
} }
_, _, args := telegoutil.ParseCommand(message.Text) _, _, args := telegoutil.ParseCommand(message.Text())
availableStorages := maputil.Keys(storage.Storages) availableStorages := maputil.Keys(storage.Storages)
if len(args) == 0 { if len(args) == 0 {
text := EscapeMarkdown("请提供存储位置名称, 可用项:") text := "请提供存储位置名称, 可用项:"
for _, name := range availableStorages { for _, name := range availableStorages {
text += fmt.Sprintf("\n`%s`", name) text += fmt.Sprintf("\n`%s`", name)
} }
text += fmt.Sprintf("\n`all`") text += fmt.Sprintf("\n`all`")
bot.SendMessage(telegoutil.Message(message.Chat.ChatID(), text).WithParseMode(telego.ModeMarkdownV2)) message.Reply(text, telegram.SendOptions{ParseMode: telegram.MarkDown})
return return nil
} }
storageName := args[0] storageName := args[0]
if !slice.Contain(availableStorages, storageName) { if !slice.Contain(availableStorages, storageName) {
ReplyMessage(message, "参数错误") message.Reply("参数错误")
return return nil
} }
user, err := dao.GetUserByUserID(message.From.ID) user, err := dao.GetUserByUserID(message.ChatID())
if err != nil { if err != nil {
logger.L.Error(err) logger.L.Error(err)
return return err
} }
user.DefaultStorage = storageName user.DefaultStorage = storageName
err = dao.UpdateUser(user) err = dao.UpdateUser(user)
if err != nil { if err != nil {
logger.L.Error(err) logger.L.Error(err)
return return err
} }
ReplyMessage(message, fmt.Sprintf("已设置默认存储位置为: %s", storageName)) if _, err := message.Reply(fmt.Sprintf("已设置默认存储位置为: %s", storageName)); err != nil {
return err
}
return nil
} }
func SaveFile(ctx context.Context, bot *telego.Bot, message telego.Message) { func SaveCmd(message *telegram.NewMessage) error {
targetMessage := message.ReplyToMessage targetMessage, err := message.GetReplyMessage()
if targetMessage == nil { if err != nil {
ReplyMessage(message, "请回复要保存的文件") message.Reply("请回复要保存的文件")
return return nil
} }
if targetMessage.Document == nil && targetMessage.Video == nil && targetMessage.Audio == nil { if !targetMessage.IsMedia() {
ReplyMessage(message, "回复的消息不包含文件") message.Reply("回复的消息不包含文件")
return return nil
}
ctx = context.WithValue(ctx, "force", true)
HandleFileMessage(ctx, bot, *targetMessage)
}
func CleanReceivedFile(ctx context.Context, bot *telego.Bot, message telego.Message) {
targetMessage := message.ReplyToMessage
if targetMessage == nil {
ReplyMessage(message, "请回复要清除记录的文件")
return
}
if targetMessage.Document == nil && targetMessage.Video == nil && targetMessage.Audio == nil {
ReplyMessage(message, "回复的消息不包含文件")
return
}
var fileUniqueID string
if targetMessage.Document != nil {
fileUniqueID = targetMessage.Document.FileUniqueID
} else if targetMessage.Video != nil {
fileUniqueID = targetMessage.Video.FileUniqueID
} else if targetMessage.Audio != nil {
fileUniqueID = targetMessage.Audio.FileUniqueID
} }
if fileUniqueID == "" { msg, err := targetMessage.Reply("正在获取文件信息...")
ReplyMessage(message, "文件信息获取失败")
return
}
if err := dao.DeleteReceivedFileByFileUniqueID(fileUniqueID); err != nil {
logger.L.Error(err)
ReplyMessage(message, "删除记录失败")
return
}
ReplyMessage(message, "记录已删除")
}
func HandleFileMessage(ctx context.Context, bot *telego.Bot, message telego.Message) {
var fileID, fileName string
if message.Document != nil {
fileID = message.Document.FileID
fileName = message.Document.FileName
} else if message.Video != nil {
fileID = message.Video.FileID
fileName = message.Video.FileName
} else if message.Audio != nil {
fileID = message.Audio.FileID
fileName = message.Audio.FileName
}
if fileID == "" {
logger.L.Error("File ID is empty")
ReplyMessage(message, "文件信息获取失败")
return
}
user, err := dao.GetUserByUserID(message.From.ID)
if err != nil { if err != nil {
logger.L.Error(err) logger.L.Error(err)
return message.Reply("获取文件信息失败")
return err
} }
msg, err := ReplyMessage(message, "正在获取文件信息")
_, _, _, fileName, err := telegram.GetFileLocation(targetMessage.Media())
if err != nil { if err != nil {
logger.L.Error(err) logger.L.Error(err)
return targetMessage.Reply("获取文件信息失败")
} return err
file, err := bot.GetFile(&telego.GetFileParams{FileID: fileID})
if err != nil {
logger.L.Error(err)
ReplyMessage(message, "获取文件信息失败")
return
}
if ctx.Value("force") == nil {
receivedFile, _ := dao.GetReceivedFileByFileID(file.FileID)
if receivedFile != nil && receivedFile.Processing {
bot.EditMessageText(&telego.EditMessageTextParams{
ChatID: message.Chat.ChatID(),
MessageID: msg.MessageID,
Text: "该文件或许正在处理中, 使用 /save 命令回复此文件以强制加入队列\n使用 /clean 命令回复此文件以清除对应的记录",
})
return
}
} }
if fileName == "" { if fileName == "" {
fileName = filepath.Base(file.FilePath) logger.L.Error("Empty file name")
targetMessage.Reply("文件名为空")
return nil
} }
err = dao.AddReceivedFile(&model.ReceivedFile{ if err := dao.AddReceivedFile(&model.ReceivedFile{
FileUniqueID: file.FileUniqueID,
FileID: file.FileID,
Processing: false, Processing: false,
FileName: fileName, FileName: fileName,
FilePath: file.FilePath, ChatID: targetMessage.ChatID(),
FileSize: file.FileSize, MessageID: targetMessage.Message.ID,
MediaGroupID: message.MediaGroupID, ReplyMessageID: msg.ID,
ChatID: message.Chat.ChatID().ID, }); err != nil {
MessageID: message.MessageID, logger.L.Error(err)
ReplyMessageID: msg.MessageID, msg.Edit("保存文件信息失败")
}) return err
}
user, err := dao.GetUserByUserID(message.ChatID())
if err != nil { if err != nil {
logger.L.Error(err) logger.L.Error(err)
ReplyMessage(message, "创建任务失败") msg.Edit("获取用户信息失败")
return return err
} }
if !user.Silent { if !user.Silent {
bot.EditMessageText(&telego.EditMessageTextParams{ msg.Edit("请选择要保存的位置:", telegram.SendOptions{
ChatID: message.Chat.ChatID(), ReplyMarkup: AddTaskReplyMarkup(targetMessage.Message.ID),
MessageID: msg.MessageID,
Text: "选择要保存的位置",
ReplyMarkup: AddTaskReplyMarkup(message.MessageID),
}) })
return return nil
} }
if user.DefaultStorage == "" { if user.DefaultStorage == "" {
bot.EditMessageText(&telego.EditMessageTextParams{ msg.Edit("请先使用 /storage 命令设置默认存储位置, 或者关闭静默模式")
ChatID: message.Chat.ChatID(), return nil
MessageID: msg.MessageID,
Text: "请先使用 /storage 命令设置默认存储位置, 或者关闭静默模式",
})
return
} }
queue.AddTask(types.Task{ queue.AddTask(types.Task{
Ctx: context.TODO(), Ctx: context.TODO(),
FileID: file.FileID,
Status: types.Pending, Status: types.Pending,
FileName: fileName, FileName: fileName,
FilePath: file.FilePath,
Storage: types.StorageType(user.DefaultStorage), Storage: types.StorageType(user.DefaultStorage),
ChatID: message.Chat.ChatID().ID, ChatID: targetMessage.ChatID(),
ReplyMessageID: msg.MessageID, MessageID: targetMessage.Message.ID,
ReplyMessageID: msg.ID,
}) })
msg.Edit(fmt.Sprintf("已添加到队列: %s\n当前排队任务数: %d", fileName, queue.Len()))
return nil
} }
func AddToQueue(ctx context.Context, bot *telego.Bot, query telego.CallbackQuery) { func HandleFileMessage(message *telegram.NewMessage) error {
args := strings.Split(query.Data, " ") if !message.IsMedia() {
messageID, _ := strconv.Atoi(args[1]) return nil
receivedFile, err := dao.GetReceivedFileByChatAndMessageID(query.Message.GetChat().ID, messageID) }
user, err := dao.GetUserByUserID(message.ChatID())
if err != nil { if err != nil {
logger.L.Error(err) logger.L.Error(err)
bot.AnswerCallbackQuery(telegoutil.CallbackQuery(query.ID).WithShowAlert().WithText("获取文件信息失败").WithCacheTime(5)) return nil
return }
msg, err := message.Reply("正在获取文件信息...")
if err != nil {
logger.L.Error(err)
return err
}
_, _, _, fileName, err := telegram.GetFileLocation(message.Media())
if err != nil {
logger.L.Error(err)
message.Reply("获取文件信息失败")
return err
}
if fileName == "" {
logger.L.Error("Empty file name")
message.Reply("文件名为空")
return nil
}
if err := dao.AddReceivedFile(&model.ReceivedFile{
Processing: false,
FileName: fileName,
ChatID: message.ChatID(),
MessageID: message.Message.ID,
ReplyMessageID: msg.ID,
}); err != nil {
logger.L.Error(err)
msg.Edit("保存文件信息失败")
return err
}
if !user.Silent {
msg.Edit("请选择要保存的位置:", telegram.SendOptions{
ReplyMarkup: AddTaskReplyMarkup(message.Message.ID),
})
return nil
}
if user.DefaultStorage == "" {
msg.Edit("请先使用 /storage 命令设置默认存储位置, 或者关闭静默模式")
return nil
}
queue.AddTask(types.Task{
Ctx: context.TODO(),
Status: types.Pending,
FileName: fileName,
Storage: types.StorageType(user.DefaultStorage),
ChatID: message.ChatID(),
MessageID: message.Message.ID,
ReplyMessageID: msg.ID,
})
msg.Edit(fmt.Sprintf("已添加到队列: %s\n当前排队任务数: %d", fileName, queue.Len()))
return nil
}
func AddToQueue(query *telegram.CallbackQuery) error {
args := strings.Split(query.DataString(), " ")
messageID, _ := strconv.Atoi(args[1])
logger.L.Debug(query.ChatID, messageID)
receivedFile, err := dao.GetReceivedFileByChatAndMessageID(query.ChatID, int32(messageID))
if err != nil {
logger.L.Error(err)
query.Answer("获取文件信息失败", &telegram.CallbackOptions{
Alert: true,
CacheTime: 5,
})
return err
} }
queue.AddTask(types.Task{ queue.AddTask(types.Task{
Ctx: context.TODO(), Ctx: context.TODO(),
FileID: receivedFile.FileID,
Status: types.Pending, Status: types.Pending,
FileName: receivedFile.FileName, FileName: receivedFile.FileName,
FilePath: receivedFile.FilePath,
Storage: types.StorageType(args[2]), Storage: types.StorageType(args[2]),
ChatID: receivedFile.ChatID, ChatID: receivedFile.ChatID,
MessageID: receivedFile.MessageID,
ReplyMessageID: receivedFile.ReplyMessageID, ReplyMessageID: receivedFile.ReplyMessageID,
}) })
bot.EditMessageText(&telego.EditMessageTextParams{ query.Edit(fmt.Sprintf("已添加到队列: %s\n当前排队任务数: %d", receivedFile.FileName, queue.Len()))
Text: fmt.Sprintf("已添加到队列, 当前排队中的任务数: %d", queue.Len()), return nil
MessageID: query.Message.GetMessageID(),
ChatID: telegoutil.ID(receivedFile.ChatID),
})
} }

View File

@@ -1 +0,0 @@
package bot

View File

@@ -4,22 +4,10 @@ import (
"fmt" "fmt"
"regexp" "regexp"
"github.com/amarnathcjd/gogram/telegram"
"github.com/krau/SaveAny-Bot/storage" "github.com/krau/SaveAny-Bot/storage"
"github.com/mymmrac/telego"
"github.com/mymmrac/telego/telegoutil"
) )
func FileDownloadURL(filePath string) string {
return Bot.FileDownloadURL(filePath)
}
func ReplyMessage(replyTo telego.Message, format string, args ...any) (*telego.Message, error) {
return Bot.SendMessage(telegoutil.Messagef(replyTo.Chat.ChatID(), format, args...).
WithReplyParameters(&telego.ReplyParameters{
MessageID: replyTo.MessageID,
}))
}
var StorageDisplayNames = map[string]string{ var StorageDisplayNames = map[string]string{
"all": "全部", "all": "全部",
"local": "服务器磁盘", "local": "服务器磁盘",
@@ -27,20 +15,42 @@ var StorageDisplayNames = map[string]string{
"webdav": "WebDAV", "webdav": "WebDAV",
} }
func AddTaskReplyMarkup(messageID int) *telego.InlineKeyboardMarkup { func AddTaskReplyMarkup(messageID int32) telegram.ReplyMarkup {
storageButtons := make([]telego.InlineKeyboardButton, 0) // TODO: sort storage buttons
storageButtons := make([]telegram.KeyboardButton, 0)
for name := range storage.Storages { for name := range storage.Storages {
storageButtons = append(storageButtons, telegoutil.InlineKeyboardButton(StorageDisplayNames[string(name)]). storageButtons = append(storageButtons, &telegram.KeyboardButtonCallback{
WithCallbackData(fmt.Sprintf("add %d %s", messageID, name))) Text: StorageDisplayNames[string(name)],
Data: []byte(fmt.Sprintf("add %d %s", messageID, name)),
})
} }
if len(storageButtons) > 1 { if len(storageButtons) > 1 {
return telegoutil.InlineKeyboard(storageButtons, []telego.InlineKeyboardButton{ return &telegram.ReplyInlineMarkup{
telegoutil.InlineKeyboardButton("全部").WithCallbackData(fmt.Sprintf("add %d all", messageID)), Rows: []*telegram.KeyboardButtonRow{
}) {
Buttons: storageButtons,
},
{
Buttons: []telegram.KeyboardButton{
&telegram.KeyboardButtonCallback{
Text: "全部",
Data: []byte(fmt.Sprintf("add %d all", messageID)),
},
},
},
},
}
} }
if len(storageButtons) == 1 { if len(storageButtons) == 1 {
return telegoutil.InlineKeyboard(storageButtons) return &telegram.ReplyInlineMarkup{
Rows: []*telegram.KeyboardButtonRow{
{
Buttons: storageButtons,
},
},
}
} }
return nil return nil
} }

View File

@@ -33,6 +33,18 @@ func PurgeFile(path string) error {
return RemoveEmptyDirectories(filepath.Dir(path)) return RemoveEmptyDirectories(filepath.Dir(path))
} }
func RmFileAfter(path string, td time.Duration) {
_, err := os.Stat(path)
if err != nil {
logger.L.Errorf("Failed to create timer for %s: %s", path, err)
return
}
logger.L.Debugf("Remove file after %s: %s", td, path)
time.AfterFunc(td, func() {
PurgeFile(path)
})
}
// 递归删除空目录 // 递归删除空目录
func RemoveEmptyDirectories(dirPath string) error { func RemoveEmptyDirectories(dirPath string) error {
entries, err := os.ReadDir(dirPath) entries, err := os.ReadDir(dirPath)

View File

@@ -2,6 +2,7 @@ package common
import ( import (
"path/filepath" "path/filepath"
"time"
"github.com/imroc/req/v3" "github.com/imroc/req/v3"
"github.com/krau/SaveAny-Bot/config" "github.com/krau/SaveAny-Bot/config"
@@ -10,9 +11,9 @@ import (
var ReqClient *req.Client var ReqClient *req.Client
func initClient() { func initClient() {
ReqClient = req.NewClient().SetOutputDirectory(config.Cfg.Temp.BasePath) ReqClient = req.NewClient().SetOutputDirectory(config.Cfg.Temp.BasePath).SetTimeout(86400 * time.Second)
} }
func GetDownloadedFilePath(filename string) string { func GetCacheFilePath(filename string) string {
return filepath.Join(config.Cfg.Temp.BasePath, filename) return filepath.Join(config.Cfg.Temp.BasePath, filename)
} }

View File

@@ -1,7 +1,8 @@
[telegram] [telegram]
token = "" # Bot Token token = "" # Bot Token
admins = [777000] # 你的 user_id admins = [777000] # 你的 user_id
api = "https://api.telegram.org" api_id = 123456 # Telegram API ID
api_hash = "0123456789abcdef0123456789abcdef" # Telegram API Hash
[log] [log]
level = "DEBUG" # 日志等级 level = "DEBUG" # 日志等级

View File

@@ -8,6 +8,10 @@ import (
) )
type Config struct { type Config struct {
Threads int `toml:"threads" mapstructure:"threads"`
Workers int `toml:"workers" mapstructure:"workers"`
ChunkSize int32 `toml:"chunk_size" mapstructure:"chunk_size"`
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"`
@@ -31,9 +35,10 @@ type dbConfig struct {
} }
type telegramConfig struct { type telegramConfig struct {
Token string `toml:"token" mapstructure:"token"` Token string `toml:"token" mapstructure:"token"`
API string `toml:"api" mapstructure:"api"` AppID int32 `toml:"app_id" mapstructure:"app_id"`
Admins []int64 `toml:"admins" mapstructure:"admins"` AppHash string `toml:"app_hash" mapstructure:"app_hash"`
Admins []int64 `toml:"admins" mapstructure:"admins"`
} }
type storageConfig struct { type storageConfig struct {
@@ -71,6 +76,10 @@ func Init() {
viper.AddConfigPath(".") viper.AddConfigPath(".")
viper.SetConfigType("toml") viper.SetConfigType("toml")
viper.SetDefault("threads", 3)
viper.SetDefault("workers", 3)
viper.SetDefault("chunk_size", 50*1024*1024)
viper.SetDefault("temp.base_path", "cache/") viper.SetDefault("temp.base_path", "cache/")
viper.SetDefault("temp.cache_ttl", 3600) viper.SetDefault("temp.cache_ttl", 3600)

View File

@@ -3,64 +3,43 @@ package core
import ( import (
"context" "context"
"errors" "errors"
"fmt" "os"
"time" "time"
"github.com/imroc/req/v3" "github.com/amarnathcjd/gogram/telegram"
"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/dao"
"github.com/krau/SaveAny-Bot/logger" "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"
"github.com/mymmrac/telego"
"github.com/mymmrac/telego/telegoutil"
) )
func processPendingTask(task types.Task) error { func processPendingTask(task types.Task) error {
fileRecord, err := dao.GetReceivedFileByFileID(task.FileID) os.MkdirAll(config.Cfg.Temp.BasePath, os.ModePerm)
message, err := bot.Client.GetMessageByID(task.ChatID, task.MessageID)
if err != nil { if err != nil {
return err return err
} }
fileRecord.Processing = true logger.L.Debugf("Start downloading file: %s", task.FileName)
if err := dao.UpdateReceivedFile(fileRecord); err != nil { bot.Client.EditMessage(task.ChatID, task.ReplyMessageID, "正在下载文件...")
return err dest, err := message.Download(&telegram.DownloadOptions{
} FileName: common.GetCacheFilePath(task.FileName),
Threads: config.Cfg.Threads,
_, err = common.ReqClient.R().SetOutputFile(task.FileName).SetDownloadCallbackWithInterval(func(info req.DownloadInfo) { ChunkSize: config.Cfg.ChunkSize,
if info.Response == nil || info.Response.Response == nil || info.Response.Response.StatusCode != 200 { // ProgressCallback: func(totalBytes, downloadedBytes int64) {},
return
}
process := fmt.Sprintf("%.2f%%", float64(info.DownloadedSize)/float64(info.Response.ContentLength)*100.0)
bot.Bot.EditMessageText(&telego.EditMessageTextParams{
ChatID: telegoutil.ID(task.ChatID),
MessageID: task.ReplyMessageID,
Text: "正在下载文件: " + process,
})
}, time.Second*time.Duration(3*func() int {
if queue.Len() > 0 {
return queue.Len()
}
return 1
}())).Get(bot.FileDownloadURL(task.FilePath))
if err != nil {
return err
}
bot.Bot.EditMessageText(&telego.EditMessageTextParams{
ChatID: telegoutil.ID(task.ChatID),
MessageID: task.ReplyMessageID,
Text: "下载完成, 正在转存...",
}) })
if err != nil {
return err
}
defer func() { defer func() {
if config.Cfg.Temp.CacheTTL > 0 { if config.Cfg.Temp.CacheTTL > 0 {
common.PurgeFileAfter(common.GetDownloadedFilePath(task.FileName), common.RmFileAfter(dest, time.Duration(config.Cfg.Temp.CacheTTL)*time.Second)
time.Duration(config.Cfg.Temp.CacheTTL)*time.Second)
} else { } else {
if err := common.PurgeFile(common.GetDownloadedFilePath(task.FileName)); err != nil { if err := os.Remove(dest); err != nil {
logger.L.Errorf("Failed to purge file: %s", err) logger.L.Errorf("Failed to purge file: %s", err)
} }
} }
@@ -69,9 +48,11 @@ func processPendingTask(task types.Task) error {
task.StoragePath = task.FileName task.StoragePath = task.FileName
} }
if err := storage.Save(task.Storage, task.Ctx, common.GetDownloadedFilePath(task.FileName), task.StoragePath); err != nil { bot.Client.EditMessage(task.ChatID, task.ReplyMessageID, "下载完成, 正在转存文件...")
if err := storage.Save(task.Storage, task.Ctx, dest, task.StoragePath); err != nil {
return err return err
} }
return nil return nil
} }
@@ -98,24 +79,10 @@ 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()) logger.L.Infof("Task succeeded: %s", task.String())
bot.Bot.EditMessageText(&telego.EditMessageTextParams{ bot.Client.EditMessage(task.ChatID, task.ReplyMessageID, "文件保存成功")
ChatID: telegoutil.ID(task.ChatID),
MessageID: task.ReplyMessageID,
Text: "文件转存完成",
})
if err := dao.DeleteReceivedFileByFileID(task.FileID); err != nil {
logger.L.Errorf("Failed to delete received file: %s", err)
}
case types.Failed: case types.Failed:
logger.L.Errorf("Task failed: %s", task.String()) logger.L.Errorf("Task failed: %s", task.String())
bot.Bot.EditMessageText(&telego.EditMessageTextParams{ bot.Client.EditMessage(task.ChatID, task.ReplyMessageID, "文件保存失败")
ChatID: telegoutil.ID(task.ChatID),
MessageID: task.ReplyMessageID,
Text: "文件转存失败:" + "\n" + task.Error.Error(),
})
if err := dao.DeleteReceivedFileByFileID(task.FileID); err != nil {
logger.L.Errorf("Failed to delete received file: %s", err)
}
case types.Canceled: case types.Canceled:
logger.L.Infof("Task canceled: %s", task.String()) logger.L.Infof("Task canceled: %s", task.String())
default: default:
@@ -128,8 +95,8 @@ func worker(queue *queue.TaskQueue, semaphore chan struct{}) {
func Run() { func Run() {
logger.L.Info("Start processing tasks...") logger.L.Info("Start processing tasks...")
semaphore := make(chan struct{}, 3) semaphore := make(chan struct{}, config.Cfg.Workers)
for i := 0; i < 3; i++ { for i := 0; i < config.Cfg.Workers; i++ {
go worker(queue.Queue, semaphore) go worker(queue.Queue, semaphore)
} }
} }

View File

@@ -6,25 +6,7 @@ func AddReceivedFile(receivedFile *model.ReceivedFile) error {
return db.Create(receivedFile).Error return db.Create(receivedFile).Error
} }
func GetReceivedFileByFileID(fileID string) (*model.ReceivedFile, error) { func GetReceivedFileByChatAndMessageID(chatID int64, messageID int32) (*model.ReceivedFile, error) {
var receivedFile model.ReceivedFile
err := db.Where("file_id = ?", fileID).First(&receivedFile).Error
if err != nil {
return nil, err
}
return &receivedFile, nil
}
func GetReceivedFileByFileUniqueID(fileUniqueID string) (*model.ReceivedFile, error) {
var receivedFile model.ReceivedFile
err := db.Where("file_unique_id = ?", fileUniqueID).First(&receivedFile).Error
if err != nil {
return nil, err
}
return &receivedFile, nil
}
func GetReceivedFileByChatAndMessageID(chatID int64, messageID int) (*model.ReceivedFile, error) {
var receivedFile model.ReceivedFile var receivedFile model.ReceivedFile
err := db.Where("chat_id = ? AND message_id = ?", chatID, messageID).First(&receivedFile).Error err := db.Where("chat_id = ? AND message_id = ?", chatID, messageID).First(&receivedFile).Error
if err != nil { if err != nil {
@@ -33,23 +15,10 @@ func GetReceivedFileByChatAndMessageID(chatID int64, messageID int) (*model.Rece
return &receivedFile, nil return &receivedFile, nil
} }
func GetReceivedFilesByMediaGroupID(mediaGroupID string) ([]model.ReceivedFile, error) {
var receivedFiles []model.ReceivedFile
err := db.Where("media_group_id = ?", mediaGroupID).Find(&receivedFiles).Error
if err != nil {
return nil, err
}
return receivedFiles, nil
}
func UpdateReceivedFile(receivedFile *model.ReceivedFile) error { func UpdateReceivedFile(receivedFile *model.ReceivedFile) error {
return db.Save(receivedFile).Error return db.Save(receivedFile).Error
} }
func DeleteReceivedFileByFileID(fileID string) error { func DeleteReceivedFile(receivedFile *model.ReceivedFile) error {
return db.Where("file_id = ?", fileID).Delete(&model.ReceivedFile{}).Error return db.Delete(receivedFile).Error
}
func DeleteReceivedFileByFileUniqueID(fileUniqueID string) error {
return db.Where("file_unique_id = ?", fileUniqueID).Delete(&model.ReceivedFile{}).Error
} }

2
go.mod
View File

@@ -3,6 +3,7 @@ module github.com/krau/SaveAny-Bot
go 1.23.2 go 1.23.2
require ( require (
github.com/amarnathcjd/gogram v0.0.0-20241008120348-4ce400474c46
github.com/blang/semver v3.5.1+incompatible github.com/blang/semver v3.5.1+incompatible
github.com/gookit/slog v0.5.6 github.com/gookit/slog v0.5.6
github.com/imroc/req/v3 v3.46.1 github.com/imroc/req/v3 v3.46.1
@@ -30,6 +31,7 @@ require (
github.com/jinzhu/now v1.1.5 // indirect github.com/jinzhu/now v1.1.5 // indirect
github.com/mattn/go-isatty v0.0.17 // indirect github.com/mattn/go-isatty v0.0.17 // indirect
github.com/onsi/ginkgo/v2 v2.20.2 // indirect github.com/onsi/ginkgo/v2 v2.20.2 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/quic-go/qpack v0.5.1 // indirect github.com/quic-go/qpack v0.5.1 // indirect
github.com/quic-go/quic-go v0.47.0 // indirect github.com/quic-go/quic-go v0.47.0 // indirect
github.com/refraction-networking/utls v1.6.7 // indirect github.com/refraction-networking/utls v1.6.7 // indirect

4
go.sum
View File

@@ -1,3 +1,5 @@
github.com/amarnathcjd/gogram v0.0.0-20241008120348-4ce400474c46 h1:k3VDOsCB/k/LoIdgItpl/c+lRinqUsXSxeAE+/1Sp1E=
github.com/amarnathcjd/gogram v0.0.0-20241008120348-4ce400474c46/go.mod h1:MPzWyqnIwVK/tji8O5pmumdoPoGHqemsIJyr7xedoE8=
github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA= github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA=
github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA= github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA=
github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ= github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ=
@@ -111,6 +113,8 @@ github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k=
github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY= github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY=
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=

View File

@@ -6,18 +6,11 @@ import (
type ReceivedFile struct { type ReceivedFile struct {
gorm.Model gorm.Model
// FileUniqueID 和 FileID 在数据库中均不具有唯一性
// 如需确定唯一一行, 使用 ChatID + MessageID
FileUniqueID string `gorm:"index"`
FileID string `gorm:"index"`
Processing bool Processing bool
FileName string FileName string
FilePath string
FileSize int64
MediaGroupID string
ChatID int64 ChatID int64
MessageID int MessageID int32
ReplyMessageID int ReplyMessageID int32
} }
type User struct { type User struct {

View File

@@ -71,7 +71,7 @@ func refreshToken(client *req.Client) {
func (a *Alist) Init() { func (a *Alist) Init() {
basePath = config.Cfg.Storage.Alist.BasePath basePath = config.Cfg.Storage.Alist.BasePath
baseUrl = config.Cfg.Storage.Alist.URL baseUrl = config.Cfg.Storage.Alist.URL
reqClient = req.C().SetTLSHandshakeTimeout(time.Second * 10).SetBaseURL(baseUrl) reqClient = req.C().SetTLSHandshakeTimeout(time.Second * 10).SetBaseURL(baseUrl).SetTimeout(time.Hour * 24)
loginReq = &loginRequset{ loginReq = &loginRequset{
Username: config.Cfg.Storage.Alist.Username, Username: config.Cfg.Storage.Alist.Username,
Password: config.Cfg.Storage.Alist.Password, Password: config.Cfg.Storage.Alist.Password,

View File

@@ -6,6 +6,7 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"time"
"github.com/krau/SaveAny-Bot/config" "github.com/krau/SaveAny-Bot/config"
"github.com/krau/SaveAny-Bot/logger" "github.com/krau/SaveAny-Bot/logger"
@@ -27,6 +28,7 @@ func (w *Webdav) Init() {
logger.L.Fatalf("Failed to connect to webdav server: %v", err) logger.L.Fatalf("Failed to connect to webdav server: %v", err)
os.Exit(1) os.Exit(1)
} }
Client.SetTimeout(24 * time.Hour)
} }
func (w *Webdav) Save(ctx context.Context, filePath, storagePath string) error { func (w *Webdav) Save(ctx context.Context, filePath, storagePath string) error {

View File

@@ -24,17 +24,15 @@ var StorageTypes = []StorageType{Local, Alist, Webdav, StorageAll}
type Task struct { type Task struct {
Ctx context.Context Ctx context.Context
FileID string
Error error Error error
Status TaskStatus Status TaskStatus
FilePath string // telegram File object's FilePath
FileName string FileName string
Storage StorageType Storage StorageType
StoragePath string StoragePath string
// For track progress MessageID int32
ChatID int64 ChatID int64
ReplyMessageID int ReplyMessageID int32
} }
func (t *Task) String() string { func (t *Task) String() string {