refactor: refactor task logic for better scalability (#76)

* refactor: a big refactor. wip

* refactor: port handle file

* refactor: place all handlers

* fix: task info nil pointer

* feat: enhance task progress tracking and context management

* feat: cancel task

* feat: stream mode

* feat: silent mode

* feat: dir cmd

* refactor: remove unused old file

* feat: rule cmd

* feat: handle silent mode

* feat: batch task

* fix: batch task progress and temp file cleanup

* refactor: update file creation and cleanup methods for better resource management

* feat: add save command with silent mode handling

* feat: message link

* feat: update message prompts to include file count in storage selection

* feat: slient save links

* refactor: reduce dup code

* feat: rule type

* feat: chose dir

* feat: refactor file handling and storage rules, improve error handling and logging

* feat: rule mode

* feat: telegraph pics

* fix: tphpics nil pointer and inaccurate dirpath

* feat: silent save telegraph

* feat: add suffix to avoid file overwrite

* feat: new storage telegram

* chore: tidy go mod
This commit is contained in:
Krau
2025-06-15 23:57:49 +08:00
committed by GitHub
parent 280745cae3
commit 900823cdb9
150 changed files with 5730 additions and 3923 deletions

View File

@@ -63,9 +63,9 @@ jobs:
README.md README.md
ldflags: >- ldflags: >-
-s -w -s -w
-X "github.com/krau/SaveAny-Bot/common.Version=${{ env.VERSION }}" -X "github.com/krau/SaveAny-Bot/pkg/consts.Version=${{ env.VERSION }}"
-X "github.com/krau/SaveAny-Bot/common.BuildTime=${{ format(github.event.repository.updated_at, 'yyyy-MM-dd HH:mm:ss') }}" -X "github.com/krau/SaveAny-Bot/pkg/consts.BuildTime=${{ format(github.event.repository.updated_at, 'yyyy-MM-dd HH:mm:ss') }}"
-X "github.com/krau/SaveAny-Bot/common.GitCommit=${{ github.sha }}" -X "github.com/krau/SaveAny-Bot/pkg/consts.GitCommit=${{ github.sha }}"
binary_name: saveany-bot binary_name: saveany-bot
env: env:
VERSION: ${{ env.VERSION }} VERSION: ${{ env.VERSION }}

4
.gitignore vendored
View File

@@ -3,7 +3,7 @@ logs/
tmp/ tmp/
data/ data/
downloads/ downloads/
cache/
session.* session.*
cache.db cache.db
.vscode/ .vscode/
temp/

View File

@@ -1,216 +0,0 @@
package bot
import (
"errors"
"fmt"
"path"
"strconv"
"strings"
"github.com/celestix/gotgproto/dispatcher"
"github.com/celestix/gotgproto/ext"
"github.com/duke-git/lancet/v2/slice"
"github.com/gotd/td/telegram/message/entity"
"github.com/gotd/td/telegram/message/styling"
"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/queue"
"github.com/krau/SaveAny-Bot/types"
"github.com/krau/SaveAny-Bot/userclient"
"gorm.io/gorm"
)
func AddToQueue(ctx *ext.Context, update *ext.Update) error {
if !slice.Contain(config.Cfg.GetUsersID(), update.CallbackQuery.UserID) {
ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{
QueryID: update.CallbackQuery.QueryID,
Alert: true,
Message: "你没有权限",
CacheTime: 5,
})
return dispatcher.EndGroups
}
args := strings.Split(string(update.CallbackQuery.Data), " ")
addToDir := args[0] == "add_to_dir" // 已经选择了路径
cbDataId, _ := strconv.Atoi(args[1])
cbData, err := dao.GetCallbackData(uint(cbDataId))
if err != nil {
common.Log.Errorf("获取回调数据失败: %s", err)
ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{
QueryID: update.CallbackQuery.QueryID,
Alert: true,
Message: "获取回调数据失败",
CacheTime: 5,
})
return dispatcher.EndGroups
}
data := strings.Split(cbData, " ")
fileChatID, _ := strconv.Atoi(data[0])
fileMessageID, _ := strconv.Atoi(data[1])
storageName := data[2]
dirIdInt, _ := strconv.Atoi(data[3])
dirId := uint(dirIdInt)
user, err := dao.GetUserByChatID(update.CallbackQuery.UserID)
if err != nil {
common.Log.Errorf("获取用户失败: %s", err)
ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{
QueryID: update.CallbackQuery.QueryID,
Alert: true,
Message: "获取用户失败",
CacheTime: 5,
})
return dispatcher.EndGroups
}
if !addToDir {
dirs, err := dao.GetDirsByUserIDAndStorageName(user.ID, storageName)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
common.Log.Errorf("获取路径失败: %s", err)
ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{
QueryID: update.CallbackQuery.QueryID,
Alert: true,
Message: "获取路径失败",
CacheTime: 5,
})
return dispatcher.EndGroups
}
if len(dirs) != 0 {
markup, err := getSelectDirMarkup(fileChatID, fileMessageID, storageName, dirs)
if err != nil {
common.Log.Errorf("获取路径失败: %s", err)
ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{
QueryID: update.CallbackQuery.QueryID,
Alert: true,
Message: "获取路径失败",
CacheTime: 5,
})
return dispatcher.EndGroups
}
_, err = ctx.EditMessage(update.EffectiveChat().GetID(), &tg.MessagesEditMessageRequest{
ID: update.CallbackQuery.GetMsgID(),
Message: "请选择要保存到的路径",
ReplyMarkup: markup,
})
if err != nil {
common.Log.Errorf("编辑消息失败: %s", err)
}
return dispatcher.EndGroups
}
}
common.Log.Tracef("Got add to queue: chatID: %d, messageID: %d, storage: %s", fileChatID, fileMessageID, storageName)
record, err := dao.GetReceivedFileByChatAndMessageID(int64(fileChatID), fileMessageID)
if err != nil {
common.Log.Errorf("获取记录失败: %s", err)
ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{
QueryID: update.CallbackQuery.QueryID,
Alert: true,
Message: "查询记录失败",
CacheTime: 5,
})
return dispatcher.EndGroups
}
if update.CallbackQuery.MsgID != record.ReplyMessageID {
record.ReplyMessageID = update.CallbackQuery.MsgID
if _, err := dao.SaveReceivedFile(record); err != nil {
common.Log.Errorf("更新记录失败: %s", err)
}
}
var dir *dao.Dir
if addToDir && dirId != 0 {
dir, err = dao.GetDirByID(dirId)
if err != nil {
common.Log.Errorf("获取路径失败: %s", err)
ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{
QueryID: update.CallbackQuery.QueryID,
Alert: true,
Message: "获取路径失败",
CacheTime: 5,
})
return dispatcher.EndGroups
}
}
var task types.Task
if record.IsTelegraph {
task = types.Task{
Ctx: ctx,
Status: types.Pending,
IsTelegraph: true,
TelegraphURL: record.TelegraphURL,
StorageName: storageName,
FileChatID: record.ChatID,
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 {
var file *types.File
var err error
if record.UseUserClient && userclient.UC != nil {
uctx := userclient.UC.CreateContext()
file, err = FileFromMessage(uctx, record.ChatID, record.MessageID, 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,
UseUserClient: record.UseUserClient,
FileDBID: record.ID,
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)
}
}
queue.AddTask(&task)
entityBuilder := entity.Builder{}
var entities []tg.MessageEntityClass
text := fmt.Sprintf("已添加到任务队列\n文件名: %s\n当前排队任务数: %d", record.FileName, queue.Len())
if err := styling.Perform(&entityBuilder,
styling.Plain("已添加到任务队列\n文件名: "),
styling.Code(record.FileName),
styling.Plain("\n当前排队任务数: "),
styling.Bold(strconv.Itoa(queue.Len())),
); err != nil {
common.Log.Errorf("Failed to build entity: %s", err)
} else {
text, entities = entityBuilder.Complete()
}
ctx.EditMessage(update.EffectiveChat().GetID(), &tg.MessagesEditMessageRequest{
Message: text,
Entities: entities,
ID: record.ReplyMessageID,
})
return dispatcher.EndGroups
}

View File

@@ -1,27 +0,0 @@
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

@@ -1,110 +0,0 @@
package bot
import (
"fmt"
"strconv"
"strings"
"github.com/celestix/gotgproto/dispatcher"
"github.com/celestix/gotgproto/ext"
"github.com/gotd/td/telegram/message/styling"
"github.com/krau/SaveAny-Bot/common"
"github.com/krau/SaveAny-Bot/dao"
"github.com/krau/SaveAny-Bot/storage"
)
func sendDirHelp(ctx *ext.Context, update *ext.Update, userChatID int64) error {
dirs, err := dao.GetUserDirsByChatID(userChatID)
if err != nil {
common.Log.Errorf("获取用户路径失败: %s", err)
ctx.Reply(update, ext.ReplyTextString("获取用户路径失败"), nil)
return dispatcher.EndGroups
}
ctx.Reply(update, ext.ReplyTextStyledTextArray(
[]styling.StyledTextOption{
styling.Bold("使用方法: /dir <操作> <参数...>"),
styling.Plain("\n\n可用操作:\n"),
styling.Code("add"),
styling.Plain(" <存储名> <路径> - 添加路径\n"),
styling.Code("del"),
styling.Plain(" <路径ID> - 删除路径\n"),
styling.Plain("\n添加路径示例:\n"),
styling.Code("/dir add local1 path/to/dir"),
styling.Plain("\n\n删除路径示例:\n"),
styling.Code("/dir del 3"),
styling.Plain("\n\n当前已添加的路径:\n"),
styling.Blockquote(func() string {
var sb strings.Builder
for _, dir := range dirs {
sb.WriteString(fmt.Sprintf("%d: ", dir.ID))
sb.WriteString(dir.StorageName)
sb.WriteString(" - ")
sb.WriteString(dir.Path)
sb.WriteString("\n")
}
return sb.String()
}(), true),
},
), nil)
return dispatcher.EndGroups
}
func dirCmd(ctx *ext.Context, update *ext.Update) error {
args := strings.Split(update.EffectiveMessage.Text, " ")
if len(args) < 2 {
return sendDirHelp(ctx, update, update.GetUserChat().GetID())
}
user, err := dao.GetUserByChatID(update.GetUserChat().GetID())
if err != nil {
common.Log.Errorf("获取用户失败: %s", err)
ctx.Reply(update, ext.ReplyTextString("获取用户失败"), nil)
return dispatcher.EndGroups
}
switch args[1] {
case "add":
// /dir add local1 path/to/dir
if len(args) < 4 {
return sendDirHelp(ctx, update, update.GetUserChat().GetID())
}
return addDir(ctx, update, user, args[2], args[3])
case "del":
// /dir del 3
if len(args) < 3 {
return sendDirHelp(ctx, update, update.GetUserChat().GetID())
}
dirID, err := strconv.Atoi(args[2])
if err != nil {
ctx.Reply(update, ext.ReplyTextString("路径ID无效"), nil)
return dispatcher.EndGroups
}
return delDir(ctx, update, dirID)
default:
ctx.Reply(update, ext.ReplyTextString("未知操作"), nil)
return dispatcher.EndGroups
}
}
func addDir(ctx *ext.Context, update *ext.Update, user *dao.User, storageName, path string) error {
if _, err := storage.GetStorageByUserIDAndName(user.ChatID, storageName); err != nil {
ctx.Reply(update, ext.ReplyTextString(err.Error()), nil)
return dispatcher.EndGroups
}
if err := dao.CreateDirForUser(user.ID, storageName, path); err != nil {
common.Log.Errorf("创建路径失败: %s", err)
ctx.Reply(update, ext.ReplyTextString("创建路径失败"), nil)
return dispatcher.EndGroups
}
ctx.Reply(update, ext.ReplyTextString("路径添加成功"), nil)
return dispatcher.EndGroups
}
func delDir(ctx *ext.Context, update *ext.Update, dirID int) error {
if err := dao.DeleteDirByID(uint(dirID)); err != nil {
common.Log.Errorf("删除路径失败: %s", err)
ctx.Reply(update, ext.ReplyTextString("删除路径失败"), nil)
return dispatcher.EndGroups
}
ctx.Reply(update, ext.ReplyTextString("路径删除成功"), nil)
return dispatcher.EndGroups
}

View File

@@ -1,86 +0,0 @@
package bot
import (
"fmt"
"github.com/celestix/gotgproto/dispatcher"
"github.com/celestix/gotgproto/ext"
"github.com/gotd/td/tg"
"github.com/krau/SaveAny-Bot/common"
"github.com/krau/SaveAny-Bot/dao"
"github.com/krau/SaveAny-Bot/types"
)
func handleFileMessage(ctx *ext.Context, update *ext.Update) error {
common.Log.Trace("Got media: ", update.EffectiveMessage.Media.TypeName())
supported, err := supportedMediaFilter(update.EffectiveMessage.Message)
if err != nil {
return err
}
if !supported {
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
// }
msg, err := ctx.Reply(update, ext.ReplyTextString("正在获取文件信息..."), nil)
if err != nil {
common.Log.Errorf("回复失败: %s", err)
return dispatcher.EndGroups
}
media := update.EffectiveMessage.Media
file, err := FileFromMedia(media, "")
if err != nil {
common.Log.Errorf("获取文件失败: %s", err)
ctx.Reply(update, ext.ReplyTextString(fmt.Sprintf("获取文件失败: %s", err)), nil)
return dispatcher.EndGroups
}
if file.FileName == "" {
file.FileName = GenFileNameFromMessage(*update.EffectiveMessage.Message, file)
}
record, err := dao.SaveReceivedFile(&dao.ReceivedFile{
Processing: false,
FileName: file.FileName,
ChatID: update.EffectiveChat().GetID(),
MessageID: update.EffectiveMessage.ID,
ReplyMessageID: msg.ID,
ReplyChatID: update.GetUserChat().GetID(),
})
if err != nil {
common.Log.Errorf("添加接收的文件失败: %s", err)
if _, err := ctx.EditMessage(update.EffectiveChat().GetID(), &tg.MessagesEditMessageRequest{
Message: fmt.Sprintf("添加接收的文件失败: %s", err),
ID: msg.ID,
}); err != nil {
common.Log.Errorf("编辑消息失败: %s", err)
}
return dispatcher.EndGroups
}
if !user.Silent || user.DefaultStorage == "" {
return ProvideSelectMessage(ctx, update, file.FileName, update.EffectiveChat().GetID(), update.EffectiveMessage.ID, msg.ID)
}
return HandleSilentAddTask(ctx, update, user, &types.Task{
Ctx: ctx,
Status: types.Pending,
FileDBID: record.ID,
File: file,
StorageName: user.DefaultStorage,
FileChatID: update.EffectiveChat().GetID(),
ReplyMessageID: msg.ID,
ReplyChatID: update.GetUserChat().GetID(),
FileMessageID: update.EffectiveMessage.ID,
UserID: user.ChatID,
})
}

View File

@@ -1,235 +0,0 @@
package bot
import (
"errors"
"fmt"
"net/url"
"regexp"
"strconv"
"strings"
"github.com/celestix/gotgproto/dispatcher"
"github.com/celestix/gotgproto/ext"
"github.com/gotd/td/tg"
"github.com/krau/SaveAny-Bot/common"
"github.com/krau/SaveAny-Bot/dao"
"github.com/krau/SaveAny-Bot/types"
"github.com/krau/SaveAny-Bot/userclient"
)
var (
linkRegexString = `https?://t\.me/(?:c/\d+|[a-zA-Z0-9_]+)/\d+(?:\?[^\s]*)?`
linkRegex = regexp.MustCompile(linkRegexString)
)
type parseResult struct {
ChatID int64
MessageID int
Files []*types.File
UserClient bool
}
func parseLink(ctx *ext.Context, link string) (*parseResult, error) {
u, err := url.Parse(link)
if err != nil {
return nil, fmt.Errorf("无法解析链接: %s", err)
}
strSlice := strings.Split(u.Path, "/")
if len(strSlice) < 3 {
return nil, fmt.Errorf("链接格式错误: %s", link)
}
messageID, err := strconv.Atoi(strSlice[len(strSlice)-1])
if err != nil {
return nil, fmt.Errorf("无法解析消息 ID: %s", err)
}
var chatID int64
if len(strSlice) == 3 {
chatUsername := strSlice[1]
peer := ctx.PeerStorage.GetPeerByUsername(chatUsername)
if peer != nil {
chatID = peer.ID
} else {
linkChat, err := ctx.ResolveUsername(chatUsername)
if err != nil {
return nil, fmt.Errorf("解析用户名失败: %s", err)
}
if linkChat == nil {
return nil, fmt.Errorf("找不到该聊天: %s", chatUsername)
}
chatID = linkChat.GetID()
}
} else if len(strSlice) == 4 {
chatIDInt, err := strconv.Atoi(strSlice[2])
if err != nil {
return nil, fmt.Errorf("无法解析 Chat ID: %s", err)
}
chatID = int64(chatIDInt)
} else {
return nil, errors.New("链接格式不正确,无法解析 Chat ID")
}
if chatID == 0 || messageID == 0 {
return nil, fmt.Errorf("链接中缺少 Chat ID 或 Message ID: %s", link)
}
msg, _, err := tryFetchMessage(ctx, chatID, messageID)
if err != nil {
return nil, fmt.Errorf("获取消息失败: %s", err)
}
mediaGroup, isGroup := msg.GetGroupedID()
if u.Query().Has("single") || !isGroup || (mediaGroup == 0) || userclient.UC == nil {
file, useUserClient, err := tryFetchFileFromMessage(ctx, chatID, messageID, "")
if err != nil {
return nil, fmt.Errorf("获取文件失败: %s", err)
}
if file.FileName == "" {
file.FileName = GenFileNameFromMessage(*msg, file)
}
return &parseResult{
ChatID: chatID,
MessageID: messageID,
Files: []*types.File{file},
UserClient: useUserClient,
}, nil
}
groupMessages, isUserClient, err := tryGetMediaGroup(chatID, messageID, mediaGroup)
if err != nil {
return nil, fmt.Errorf("获取媒体组消息失败: %s", err)
}
var files []*types.File
for _, groupMsg := range groupMessages {
file, err := FileFromMedia(groupMsg.Media, "")
if err != nil {
return nil, fmt.Errorf("获取媒体文件失败: %s", err)
}
if file.FileName == "" {
file.FileName = GenFileNameFromMessage(*groupMsg, file)
}
files = append(files, file)
}
return &parseResult{
ChatID: chatID,
MessageID: messageID,
Files: files,
UserClient: isUserClient,
}, nil
}
// use passed ctx client to fetch file from message,
//
// if failed try using userclient
func tryFetchFileFromMessage(ctx *ext.Context, chatID int64, messageID int, fileName string) (*types.File, bool, error) {
file, err := FileFromMessage(ctx, chatID, messageID, fileName)
if err == nil {
return file, false, nil
}
if (strings.Contains(err.Error(), "peer not found") || strings.Contains(err.Error(), "unexpected message type")) && userclient.UC != nil {
common.Log.Warnf("无法获取文件 %d:%d, 尝试使用 userbot: %s", chatID, messageID, err)
uctx := userclient.GetCtx()
peer := uctx.PeerStorage.GetInputPeerById(chatID)
if peer == nil {
return nil, true, fmt.Errorf("failed to get peer for chat %d: %w", chatID, err)
}
msg, err := GetSingleHistoryMessage(uctx, uctx.Raw, peer, messageID)
if err != nil {
return nil, true, err
}
file, err = FileFromMedia(msg.Media, fileName)
if err != nil {
return nil, true, fmt.Errorf("failed to get file from userbot message %d:%d: %w", chatID, messageID, err)
}
return file, true, nil
}
return nil, false, err
}
func tryGetMediaGroup(chatID int64, messageID int, mediaGroupID int64) ([]*tg.Message, bool, error) {
if userclient.UC != nil {
uctx := userclient.GetCtx()
messages, err := GetMediaGroup(uctx, chatID, messageID, mediaGroupID)
if err != nil {
return nil, true, fmt.Errorf("failed to get media group from userbot: %w", err)
}
return messages, true, nil
}
return nil, false, errors.New("userclient is not available, cannot fetch media group")
}
func tryFetchMessage(ctx *ext.Context, chatID int64, messageID int) (*tg.Message, bool, error) {
msg, err := GetTGMessage(ctx, chatID, messageID)
if err == nil {
return msg, false, nil
}
if userclient.UC != nil && (strings.Contains(err.Error(), "peer not found") || strings.Contains(err.Error(), "unexpected message type")) {
common.Log.Warnf("无法获取消息 %d:%d, 尝试使用 userbot: %s", chatID, messageID, err)
uctx := userclient.GetCtx()
msg, err := GetTGMessage(uctx, chatID, messageID)
if err == nil {
return msg, true, nil
}
return nil, true, fmt.Errorf("获取消息失败: %w", err)
}
return nil, false, fmt.Errorf("获取消息失败: %s", err)
}
func handleLinkMessage(ctx *ext.Context, update *ext.Update) error {
common.Log.Trace("Got link message")
link := linkRegex.FindString(update.EffectiveMessage.Text)
if link == "" {
return dispatcher.ContinueGroups
}
result, err := parseLink(ctx, link)
if err != nil {
common.Log.Errorf("解析链接失败: %s", err)
ctx.Reply(update, ext.ReplyTextString("解析链接失败"), nil)
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
}
replied, err := ctx.Reply(update, ext.ReplyTextString("正在获取文件..."), nil)
if err != nil {
common.Log.Errorf("回复失败: %s", err)
return dispatcher.EndGroups
}
// TODO: handle group files
receivedFile := &dao.ReceivedFile{
Processing: false,
FileName: result.Files[0].FileName,
ChatID: result.ChatID,
MessageID: result.MessageID,
ReplyMessageID: replied.ID,
ReplyChatID: update.GetUserChat().GetID(),
UseUserClient: result.UserClient,
}
record, err := dao.SaveReceivedFile(receivedFile)
if err != nil {
common.Log.Errorf("保存接收的文件失败: %s", err)
ctx.EditMessage(update.EffectiveChat().GetID(), &tg.MessagesEditMessageRequest{
Message: "无法保存文件: " + err.Error(),
ID: replied.ID,
})
return dispatcher.EndGroups
}
file := result.Files[0]
if !user.Silent || user.DefaultStorage == "" {
return ProvideSelectMessage(ctx, update, file.FileName, result.ChatID, result.MessageID, replied.ID)
}
return HandleSilentAddTask(ctx, update, user, &types.Task{
Ctx: ctx,
Status: types.Pending,
FileDBID: record.ID,
UseUserClient: result.UserClient,
File: file,
StorageName: user.DefaultStorage,
UserID: user.ChatID,
FileChatID: result.ChatID,
FileMessageID: result.MessageID,
ReplyMessageID: replied.ID,
ReplyChatID: update.GetUserChat().GetID(),
})
}

View File

@@ -1,141 +0,0 @@
package bot
import (
"fmt"
"strconv"
"strings"
"github.com/celestix/gotgproto/dispatcher"
"github.com/celestix/gotgproto/ext"
"github.com/duke-git/lancet/v2/slice"
"github.com/gotd/td/telegram/message/styling"
"github.com/krau/SaveAny-Bot/common"
"github.com/krau/SaveAny-Bot/dao"
"github.com/krau/SaveAny-Bot/types"
)
func sendRuleHelp(ctx *ext.Context, update *ext.Update, userChatID int64) error {
user, err := dao.GetUserByChatID(userChatID)
if err != nil {
common.Log.Errorf("获取用户规则失败: %s", err)
ctx.Reply(update, ext.ReplyTextString("获取用户规则失败"), nil)
return dispatcher.EndGroups
}
ctx.Reply(update, ext.ReplyTextStyledTextArray(
[]styling.StyledTextOption{
styling.Bold("使用方法: /rule <操作> <参数...>"),
styling.Bold(fmt.Sprintf("\n当前已%s规则模式", map[bool]string{true: "启用", false: "禁用"}[user.ApplyRule])),
styling.Plain("\n\n可用操作:\n"),
styling.Code("switch"),
styling.Plain(" - 开关规则模式\n"),
styling.Code("add"),
styling.Plain(" <类型> <数据> <存储名> <路径> - 添加规则\n"),
styling.Code("del"),
styling.Plain(" <规则ID> - 删除规则\n"),
styling.Plain("\n当前已添加的规则:\n"),
styling.Blockquote(func() string {
var sb strings.Builder
for _, rule := range user.Rules {
ruleText := fmt.Sprintf("%s %s %s %s", rule.Type, rule.Data, rule.StorageName, rule.DirPath)
sb.WriteString(fmt.Sprintf("%d: %s\n", rule.ID, ruleText))
}
return sb.String()
}(), true),
},
), nil)
return dispatcher.EndGroups
}
func ruleCmd(ctx *ext.Context, update *ext.Update) error {
args := strings.Split(update.EffectiveMessage.Text, " ")
if len(args) < 2 {
return sendRuleHelp(ctx, update, update.GetUserChat().GetID())
}
user, err := dao.GetUserByChatID(update.GetUserChat().GetID())
if err != nil {
common.Log.Errorf("获取用户失败: %s", err)
ctx.Reply(update, ext.ReplyTextString("获取用户失败"), nil)
return dispatcher.EndGroups
}
switch args[1] {
case "switch":
// /rule switch
return switchApplyRule(ctx, update, user)
case "add":
// /rule add <type> <data> <storage> <dirpath>
if len(args) < 6 {
return sendRuleHelp(ctx, update, user.ChatID)
}
return addRule(ctx, update, user, args)
case "del":
// /rule del <id>
if len(args) < 3 {
return sendRuleHelp(ctx, update, user.ChatID)
}
ruleID := args[2]
id, err := strconv.Atoi(ruleID)
if err != nil {
ctx.Reply(update, ext.ReplyTextString("无效的规则ID"), nil)
return dispatcher.EndGroups
}
if err := dao.DeleteRule(uint(id)); err != nil {
common.Log.Errorf("删除规则失败: %s", err)
ctx.Reply(update, ext.ReplyTextString("删除规则失败"), nil)
return dispatcher.EndGroups
}
ctx.Reply(update, ext.ReplyTextString("删除规则成功"), nil)
return dispatcher.EndGroups
default:
return sendRuleHelp(ctx, update, user.ChatID)
}
}
func switchApplyRule(ctx *ext.Context, update *ext.Update, user *dao.User) error {
applyRule := !user.ApplyRule
if err := dao.UpdateUserApplyRule(user.ChatID, applyRule); err != nil {
common.Log.Errorf("更新用户失败: %s", err)
ctx.Reply(update, ext.ReplyTextString("更新用户失败"), nil)
return dispatcher.EndGroups
}
if applyRule {
ctx.Reply(update, ext.ReplyTextString("已启用规则模式"), nil)
} else {
ctx.Reply(update, ext.ReplyTextString("已禁用规则模式"), nil)
}
return dispatcher.EndGroups
}
func addRule(ctx *ext.Context, update *ext.Update, user *dao.User, args []string) error {
// /rule add <type> <data> <storage> <dirpath>
ruleType := args[2]
ruleData := args[3]
storageName := args[4]
dirPath := args[5]
if !slice.Contain(types.RuleTypes, types.RuleType(ruleType)) {
var ruleTypesStylingArray []styling.StyledTextOption
ruleTypesStylingArray = append(ruleTypesStylingArray, styling.Bold("无效的规则类型, 可用类型:\n"))
for i, ruleType := range types.RuleTypes {
ruleTypesStylingArray = append(ruleTypesStylingArray, styling.Code(string(ruleType)))
if i != len(types.RuleTypes)-1 {
ruleTypesStylingArray = append(ruleTypesStylingArray, styling.Plain(", "))
}
}
ctx.Reply(update, ext.ReplyTextStyledTextArray(ruleTypesStylingArray), nil)
return dispatcher.EndGroups
}
rule := &dao.Rule{
Type: ruleType,
Data: ruleData,
StorageName: storageName,
DirPath: dirPath,
UserID: user.ID,
}
if err := dao.CreateRule(rule); err != nil {
common.Log.Errorf("添加规则失败: %s", err)
ctx.Reply(update, ext.ReplyTextString("添加规则失败"), nil)
return dispatcher.EndGroups
}
ctx.Reply(update, ext.ReplyTextString("添加规则成功"), nil)
return dispatcher.EndGroups
}

View File

@@ -1,266 +0,0 @@
package bot
import (
"fmt"
"strconv"
"strings"
"github.com/celestix/gotgproto/dispatcher"
"github.com/celestix/gotgproto/ext"
"github.com/gotd/td/tg"
"github.com/krau/SaveAny-Bot/common"
"github.com/krau/SaveAny-Bot/dao"
"github.com/krau/SaveAny-Bot/queue"
"github.com/krau/SaveAny-Bot/storage"
"github.com/krau/SaveAny-Bot/types"
)
func sendSaveHelp(ctx *ext.Context, update *ext.Update) error {
helpText := `
使用方法:
1. 使用该命令回复要保存的文件, 可选文件名参数.
示例:
/save custom_file_name.mp4
2. 设置默认存储后, 发送 /save <频道ID/用户名> <消息ID范围> 来批量保存文件. 遵从存储规则, 若未匹配到任何规则则使用默认存储.
示例:
/save @moreacg 114-514
`
ctx.Reply(update, ext.ReplyTextString(helpText), nil)
return dispatcher.EndGroups
}
func saveCmd(ctx *ext.Context, update *ext.Update) error {
args := strings.Split(update.EffectiveMessage.Text, " ")
if len(args) >= 3 {
return handleBatchSave(ctx, update, args[1:])
}
replyToMsgID := func() int {
res, ok := update.EffectiveMessage.GetReplyTo()
if !ok || res == nil {
return 0
}
replyHeader, ok := res.(*tg.MessageReplyHeader)
if !ok {
return 0
}
replyToMsgID, ok := replyHeader.GetReplyToMsgID()
if !ok {
return 0
}
return replyToMsgID
}()
if replyToMsgID == 0 {
return sendSaveHelp(ctx, update)
}
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
// }
msg, err := GetTGMessage(ctx, update.EffectiveChat().GetID(), replyToMsgID)
if err != nil {
common.Log.Errorf("获取消息失败: %s", err)
ctx.Reply(update, ext.ReplyTextString("无法获取消息"), nil)
return dispatcher.EndGroups
}
supported, _ := supportedMediaFilter(msg)
if !supported {
ctx.Reply(update, ext.ReplyTextString("不支持的消息类型或消息中没有文件"), nil)
return dispatcher.EndGroups
}
replied, err := ctx.Reply(update, ext.ReplyTextString("正在获取文件信息..."), nil)
if err != nil {
common.Log.Errorf("回复失败: %s", err)
return dispatcher.EndGroups
}
cmdText := update.EffectiveMessage.Text
customFileName := strings.TrimSpace(strings.TrimPrefix(cmdText, "/save"))
file, err := FileFromMessage(ctx, update.EffectiveChat().GetID(), msg.ID, customFileName)
if err != nil {
common.Log.Errorf("获取文件失败: %s", err)
ctx.EditMessage(update.EffectiveChat().GetID(), &tg.MessagesEditMessageRequest{
Message: fmt.Sprintf("获取文件失败: %s", err),
ID: replied.ID,
})
return dispatcher.EndGroups
}
if file.FileName == "" {
file.FileName = GenFileNameFromMessage(*msg, file)
}
receivedFile := &dao.ReceivedFile{
Processing: false,
FileName: file.FileName,
ChatID: update.EffectiveChat().GetID(),
MessageID: replyToMsgID,
ReplyMessageID: replied.ID,
ReplyChatID: update.GetUserChat().GetID(),
}
record, err := dao.SaveReceivedFile(receivedFile)
if err != nil {
common.Log.Errorf("保存接收的文件失败: %s", err)
if _, err := ctx.EditMessage(update.EffectiveChat().GetID(), &tg.MessagesEditMessageRequest{
Message: fmt.Sprintf("保存接收的文件失败: %s", err),
ID: replied.ID,
}); err != nil {
common.Log.Errorf("编辑消息失败: %s", err)
}
return dispatcher.EndGroups
}
if !user.Silent || user.DefaultStorage == "" {
return ProvideSelectMessage(ctx, update, file.FileName, update.EffectiveChat().GetID(), msg.ID, replied.ID)
}
return HandleSilentAddTask(ctx, update, user, &types.Task{
Ctx: ctx,
Status: types.Pending,
FileDBID: record.ID,
File: file,
StorageName: user.DefaultStorage,
FileChatID: update.EffectiveChat().GetID(),
ReplyMessageID: replied.ID,
ReplyChatID: update.GetUserChat().GetID(),
FileMessageID: msg.ID,
UserID: user.ChatID,
})
}
func handleBatchSave(ctx *ext.Context, update *ext.Update, args []string) error {
// args: [0] = @channel, [1] = 114-514
chatArg := args[0]
var chatID int64
var err error
msgIdSlice := strings.Split(args[1], "-")
if len(msgIdSlice) != 2 {
ctx.Reply(update, ext.ReplyTextString("无效的消息ID范围"), nil)
return dispatcher.EndGroups
}
minMsgID, minerr := strconv.ParseInt(msgIdSlice[0], 10, 64)
maxMsgID, maxerr := strconv.ParseInt(msgIdSlice[1], 10, 64)
if minerr != nil || maxerr != nil {
ctx.Reply(update, ext.ReplyTextString("无效的消息ID范围"), nil)
return dispatcher.EndGroups
}
if minMsgID > maxMsgID || minMsgID <= 0 || maxMsgID <= 0 {
ctx.Reply(update, ext.ReplyTextString("无效的消息ID范围"), nil)
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
}
if user.DefaultStorage == "" {
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
}
if strings.HasPrefix(chatArg, "@") {
chatUsername := strings.TrimPrefix(chatArg, "@")
chat, err := ctx.ResolveUsername(chatUsername)
if err != nil {
common.Log.Errorf("解析频道用户名失败: %s", err)
ctx.Reply(update, ext.ReplyTextString("解析频道用户名失败"), nil)
return dispatcher.EndGroups
}
if chat == nil {
ctx.Reply(update, ext.ReplyTextString("无法找到聊天"), nil)
return dispatcher.EndGroups
}
chatID = chat.GetID()
} else {
chatID, err = strconv.ParseInt(chatArg, 10, 64)
if err != nil {
ctx.Reply(update, ext.ReplyTextString("无效的频道ID或用户名"), nil)
return dispatcher.EndGroups
}
}
if chatID == 0 {
ctx.Reply(update, ext.ReplyTextString("无效的频道ID或用户名"), nil)
return dispatcher.EndGroups
}
replied, err := ctx.Reply(update, ext.ReplyTextString("正在批量保存..."), nil)
if err != nil {
common.Log.Errorf("回复失败: %s", err)
return dispatcher.EndGroups
}
total := maxMsgID - minMsgID + 1
successadd := 0
failedGetFile := 0
failedGetMsg := 0
failedSaveDB := 0
for i := minMsgID; i <= maxMsgID; i++ {
file, err := FileFromMessage(ctx, chatID, int(i), "")
if err != nil {
common.Log.Errorf("获取文件失败: %s", err)
failedGetFile++
continue
}
if file.FileName == "" {
message, err := GetTGMessage(ctx, chatID, int(i))
if err != nil {
common.Log.Errorf("获取消息失败: %s", err)
failedGetMsg++
continue
}
file.FileName = GenFileNameFromMessage(*message, file)
}
receivedFile := &dao.ReceivedFile{
Processing: false,
FileName: file.FileName,
ChatID: chatID,
MessageID: int(i),
ReplyChatID: update.GetUserChat().GetID(),
ReplyMessageID: 0,
}
record, err := dao.SaveReceivedFile(receivedFile)
if err != nil {
common.Log.Errorf("保存接收的文件失败: %s", err)
failedSaveDB++
continue
}
task := &types.Task{
Ctx: ctx,
Status: types.Pending,
FileDBID: record.ID,
File: file,
StorageName: user.DefaultStorage,
FileChatID: chatID,
FileMessageID: int(i),
UserID: user.ChatID,
ReplyMessageID: 0,
ReplyChatID: update.GetUserChat().GetID(),
}
queue.AddTask(task)
successadd++
}
ctx.EditMessage(update.EffectiveChat().GetID(), &tg.MessagesEditMessageRequest{
Message: fmt.Sprintf("批量添加任务完成\n成功添加: %d/%d\n获取文件失败: %d\n获取消息失败: %d\n保存数据库失败: %d", successadd, total, failedGetFile, failedGetMsg, failedSaveDB),
ID: replied.ID,
})
return dispatcher.EndGroups
}

View File

@@ -1,95 +0,0 @@
package bot
import (
"fmt"
"strconv"
"strings"
"github.com/celestix/gotgproto/dispatcher"
"github.com/celestix/gotgproto/ext"
tgtypes "github.com/celestix/gotgproto/types"
"github.com/gotd/td/tg"
)
func copyMediaToChat(ctx *ext.Context, msg *tg.Message, chatID int64) (*tgtypes.Message, error) {
media, ok := msg.GetMedia()
if !ok {
return nil, fmt.Errorf("获取媒体失败")
}
req := &tg.MessagesSendMediaRequest{
InvertMedia: msg.InvertMedia,
Message: msg.Message,
}
switch m := media.(type) {
case *tg.MessageMediaDocument:
document, ok := m.Document.AsNotEmpty()
if !ok {
return nil, ErrEmptyDocument
}
inputMedia := &tg.InputMediaDocument{
ID: document.AsInput(),
}
inputMedia.SetFlags()
req.Media = inputMedia
case *tg.MessageMediaPhoto:
photo, ok := m.Photo.AsNotEmpty()
if !ok {
return nil, ErrEmptyPhoto
}
inputMedia := &tg.InputMediaPhoto{
ID: photo.AsInput(),
}
inputMedia.SetFlags()
req.Media = inputMedia
default:
return nil, fmt.Errorf("不支持的媒体类型: %T", media)
}
req.SetEntities(msg.Entities)
req.SetFlags()
return ctx.SendMedia(chatID, req)
}
func sendFileToTelegram(ctx *ext.Context, update *ext.Update) error {
args := strings.Split(string(update.CallbackQuery.Data), " ")
if len(args) < 3 {
ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{
QueryID: update.CallbackQuery.QueryID,
Alert: true,
Message: "参数错误",
CacheTime: 5,
})
return dispatcher.EndGroups
}
fileChatID, _ := strconv.Atoi(args[1])
fileMessageID, _ := strconv.Atoi(args[2])
fileMessage, err := GetTGMessage(ctx, int64(fileChatID), fileMessageID)
if err != nil {
ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{
QueryID: update.CallbackQuery.QueryID,
Alert: true,
Message: "无法获取文件消息",
CacheTime: 5,
})
return dispatcher.EndGroups
}
_, err = copyMediaToChat(ctx, fileMessage, update.EffectiveChat().GetID())
if err != nil {
ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{
QueryID: update.CallbackQuery.QueryID,
Alert: true,
Message: fmt.Sprintf("发送文件失败: %s", err),
CacheTime: 5,
})
} else {
ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{
QueryID: update.CallbackQuery.QueryID,
})
}
return dispatcher.EndGroups
}

View File

@@ -1,30 +0,0 @@
package bot
import (
"fmt"
"github.com/celestix/gotgproto/dispatcher"
"github.com/celestix/gotgproto/ext"
"github.com/krau/SaveAny-Bot/common"
"github.com/krau/SaveAny-Bot/dao"
)
func silent(ctx *ext.Context, update *ext.Update) error {
user, err := dao.GetUserByChatID(update.GetUserChat().GetID())
if err != nil {
common.Log.Errorf("获取用户失败: %s", err)
return dispatcher.EndGroups
}
if !user.Silent && user.DefaultStorage == "" {
ctx.Reply(update, ext.ReplyTextString("请先使用 /storage 设置默认存储位置"), nil)
return dispatcher.EndGroups
}
user.Silent = !user.Silent
if err := dao.UpdateUser(user); err != nil {
common.Log.Errorf("更新用户失败: %s", err)
ctx.Reply(update, ext.ReplyTextString("更新用户失败"), nil)
return dispatcher.EndGroups
}
ctx.Reply(update, ext.ReplyTextString(fmt.Sprintf("已%s静默模式", map[bool]string{true: "开启", false: "关闭"}[user.Silent])), nil)
return dispatcher.EndGroups
}

View File

@@ -1,99 +0,0 @@
package bot
import (
"fmt"
"strconv"
"strings"
"github.com/celestix/gotgproto/dispatcher"
"github.com/celestix/gotgproto/ext"
"github.com/gotd/td/tg"
"github.com/krau/SaveAny-Bot/common"
"github.com/krau/SaveAny-Bot/dao"
"github.com/krau/SaveAny-Bot/storage"
)
func storageCmd(ctx *ext.Context, update *ext.Update) error {
userChatID := update.GetUserChat().GetID()
storages := storage.GetUserStorages(userChatID)
if len(storages) == 0 {
ctx.Reply(update, ext.ReplyTextString("无可用的存储"), nil)
return dispatcher.EndGroups
}
markup, err := getSetDefaultStorageMarkup(userChatID, storages)
if err != nil {
common.Log.Errorf("Failed to get markup: %s", err)
ctx.Reply(update, ext.ReplyTextString("获取存储位置失败"), nil)
return dispatcher.EndGroups
}
ctx.Reply(update, ext.ReplyTextString("请选择要设为默认的存储位置"), &ext.ReplyOpts{
Markup: markup,
})
return dispatcher.EndGroups
}
func setDefaultStorage(ctx *ext.Context, update *ext.Update) error {
args := strings.Split(string(update.CallbackQuery.Data), " ")
userID, _ := strconv.Atoi(args[1])
if userID != int(update.CallbackQuery.GetUserID()) {
ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{
QueryID: update.CallbackQuery.QueryID,
Alert: true,
Message: "你没有权限",
CacheTime: 5,
})
return dispatcher.EndGroups
}
cbDataId, _ := strconv.Atoi(args[2])
storageName, err := dao.GetCallbackData(uint(cbDataId))
if err != nil {
common.Log.Errorf("获取回调数据失败: %s", err)
ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{
QueryID: update.CallbackQuery.QueryID,
Alert: true,
Message: "获取回调数据失败",
CacheTime: 5,
})
return dispatcher.EndGroups
}
selectedStorage, err := storage.GetStorageByName(storageName)
if err != nil {
common.Log.Errorf("获取指定存储失败: %s", err)
ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{
QueryID: update.CallbackQuery.QueryID,
Alert: true,
Message: "获取指定存储失败",
CacheTime: 5,
})
return dispatcher.EndGroups
}
user, err := dao.GetUserByChatID(int64(userID))
if err != nil {
common.Log.Errorf("Failed to get user: %s", err)
ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{
QueryID: update.CallbackQuery.QueryID,
Alert: true,
Message: "获取用户失败",
CacheTime: 5,
})
return dispatcher.EndGroups
}
user.DefaultStorage = storageName
if err := dao.UpdateUser(user); err != nil {
common.Log.Errorf("Failed to update user: %s", err)
ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{
QueryID: update.CallbackQuery.QueryID,
Alert: true,
Message: "更新用户失败",
CacheTime: 5,
})
return dispatcher.EndGroups
}
ctx.EditMessage(update.EffectiveChat().GetID(), &tg.MessagesEditMessageRequest{
Message: fmt.Sprintf("已将 %s (%s) 设为默认存储位置", selectedStorage.Name(), selectedStorage.Type()),
ID: update.CallbackQuery.GetMsgID(),
})
return dispatcher.EndGroups
}

View File

@@ -1,114 +0,0 @@
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

@@ -1,41 +0,0 @@
package bot
import (
"github.com/celestix/gotgproto/dispatcher"
"github.com/celestix/gotgproto/dispatcher/handlers"
"github.com/celestix/gotgproto/dispatcher/handlers/filters"
"github.com/celestix/gotgproto/ext"
"github.com/krau/SaveAny-Bot/common"
)
func RegisterHandlers(disp dispatcher.Dispatcher) {
disp.AddHandler(handlers.NewMessage(filters.Message.ChatType(filters.ChatTypeChannel), func(ctx *ext.Context, u *ext.Update) error {
return dispatcher.EndGroups
}))
disp.AddHandler(handlers.NewMessage(filters.Message.ChatType(filters.ChatTypeChat), func(ctx *ext.Context, u *ext.Update) error {
return dispatcher.EndGroups
}))
disp.AddHandler(handlers.NewMessage(filters.Message.All, checkPermission))
disp.AddHandler(handlers.NewCommand("start", start))
disp.AddHandler(handlers.NewCommand("help", help))
disp.AddHandler(handlers.NewCommand("silent", silent))
disp.AddHandler(handlers.NewCommand("storage", storageCmd))
disp.AddHandler(handlers.NewCommand("save", saveCmd))
disp.AddHandler(handlers.NewCommand("dir", dirCmd))
disp.AddHandler(handlers.NewCommand("rule", ruleCmd))
linkRegexFilter, err := filters.Message.Regex(linkRegexString)
if err != nil {
common.Log.Panicf("创建正则表达式过滤器失败: %s", err)
}
disp.AddHandler(handlers.NewMessage(linkRegexFilter, handleLinkMessage))
telegraphUrlRegexFilter, err := filters.Message.Regex(TelegraphUrlRegexString)
if err != nil {
common.Log.Panicf("创建 Telegraph URL 正则表达式过滤器失败: %s", err)
}
disp.AddHandler(handlers.NewMessage(telegraphUrlRegexFilter, handleTelegraph))
disp.AddHandler(handlers.NewCallbackQuery(filters.CallbackQuery.Prefix("add"), AddToQueue))
disp.AddHandler(handlers.NewCallbackQuery(filters.CallbackQuery.Prefix("set_default"), setDefaultStorage))
disp.AddHandler(handlers.NewCallbackQuery(filters.CallbackQuery.Prefix("cancel"), cancelTask))
disp.AddHandler(handlers.NewCallbackQuery(filters.CallbackQuery.Prefix("send_here"), sendFileToTelegram))
disp.AddHandler(handlers.NewMessage(filters.Message.Media, handleFileMessage))
}

View File

@@ -1,37 +0,0 @@
package bot
import (
"time"
"github.com/celestix/gotgproto/dispatcher"
"github.com/celestix/gotgproto/ext"
"github.com/duke-git/lancet/v2/slice"
"github.com/gotd/contrib/middleware/floodwait"
"github.com/gotd/contrib/middleware/ratelimit"
"github.com/gotd/td/telegram"
"github.com/krau/SaveAny-Bot/config"
"golang.org/x/time/rate"
)
func FloodWaitMiddleware() []telegram.Middleware {
waiter := floodwait.NewSimpleWaiter().WithMaxRetries(uint(config.Cfg.Telegram.FloodRetry))
ratelimiter := ratelimit.New(rate.Every(time.Millisecond*100), 5)
return []telegram.Middleware{
waiter,
ratelimiter,
}
}
const noPermissionText string = `
您不在白名单中, 无法使用此 Bot.
您可以部署自己的实例: https://github.com/krau/SaveAny-Bot
`
func checkPermission(ctx *ext.Context, update *ext.Update) error {
userID := update.GetUserChat().GetID()
if !slice.Contain(config.Cfg.GetUsersID(), userID) {
ctx.Reply(update, ext.ReplyTextString(noPermissionText), nil)
return dispatcher.EndGroups
}
return dispatcher.ContinueGroups
}

View File

@@ -1,450 +0,0 @@
package bot
import (
"context"
"errors"
"fmt"
"strconv"
"strings"
"time"
"github.com/celestix/gotgproto/dispatcher"
"github.com/celestix/gotgproto/ext"
"github.com/gabriel-vasile/mimetype"
"github.com/gotd/td/telegram/message/entity"
"github.com/gotd/td/telegram/message/styling"
"github.com/gotd/td/telegram/query"
"github.com/gotd/td/tg"
"github.com/krau/SaveAny-Bot/common"
"github.com/krau/SaveAny-Bot/dao"
"github.com/krau/SaveAny-Bot/queue"
"github.com/krau/SaveAny-Bot/storage"
"github.com/krau/SaveAny-Bot/types"
)
var (
ErrEmptyDocument = errors.New("document is empty")
ErrEmptyPhoto = errors.New("photo is empty")
ErrEmptyPhotoSize = errors.New("photo size is empty")
ErrEmptyPhotoSizes = errors.New("photo size slice is empty")
ErrNoStorages = errors.New("no available storage")
ErrEmptyMessage = errors.New("message is empty")
)
func supportedMediaFilter(m *tg.Message) (bool, error) {
if not := m.Media == nil; not {
return false, dispatcher.EndGroups
}
switch m.Media.(type) {
case *tg.MessageMediaDocument:
return true, nil
case *tg.MessageMediaPhoto:
return true, nil
default:
return false, nil
}
}
func getSelectStorageMarkup(userChatID int64, fileChatID, fileMessageID int) (*tg.ReplyInlineMarkup, error) {
user, err := dao.GetUserByChatID(userChatID)
if err != nil {
return nil, fmt.Errorf("failed to get user by chat ID: %d, error: %w", userChatID, err)
}
storages := storage.GetUserStorages(user.ChatID)
// if len(storages) == 0 {
// return nil, ErrNoStorages
// }
buttons := make([]tg.KeyboardButtonClass, 0)
for _, storage := range storages {
cbData := fmt.Sprintf("%d %d %s 0", fileChatID, fileMessageID, storage.Name()) // 0 for empty dir id
cbDataId, err := dao.CreateCallbackData(cbData)
if err != nil {
return nil, fmt.Errorf("failed to create callback data: %w", err)
}
buttons = append(buttons, &tg.KeyboardButtonCallback{
Text: storage.Name(),
Data: fmt.Appendf(nil, "add %d", cbDataId),
})
}
markup := &tg.ReplyInlineMarkup{}
for i := 0; i < len(buttons); i += 3 {
row := tg.KeyboardButtonRow{}
row.Buttons = buttons[i:min(i+3, len(buttons))]
markup.Rows = append(markup.Rows, row)
}
markup.Rows = append(markup.Rows, tg.KeyboardButtonRow{
Buttons: []tg.KeyboardButtonClass{
&tg.KeyboardButtonCallback{
Text: "发送到当前聊天",
Data: []byte(fmt.Sprintf("send_here %d %d", fileChatID, fileMessageID)),
},
},
})
return markup, nil
}
func getSelectDirMarkup(fileChatID, fileMessageID int, storageName string, dirs []dao.Dir) (*tg.ReplyInlineMarkup, error) {
buttons := make([]tg.KeyboardButtonClass, 0)
for _, dir := range dirs {
if dir.ID == 0 || dir.StorageName != storageName {
return nil, fmt.Errorf("unexpected dir: %v", dir)
}
cbDataId, err := dao.CreateCallbackData(fmt.Sprintf("%d %d %s %d", fileChatID, fileMessageID, storageName, dir.ID))
if err != nil {
return nil, fmt.Errorf("failed to create callback data: %w", err)
}
buttons = append(buttons, &tg.KeyboardButtonCallback{
Text: dir.Path,
Data: []byte(fmt.Sprintf("add_to_dir %d", cbDataId)),
})
}
markup := &tg.ReplyInlineMarkup{}
for i := 0; i < len(buttons); i += 3 {
row := tg.KeyboardButtonRow{}
row.Buttons = buttons[i:min(i+3, len(buttons))]
markup.Rows = append(markup.Rows, row)
}
return markup, nil
}
func getSetDefaultStorageMarkup(userChatID int64, storages []storage.Storage) (*tg.ReplyInlineMarkup, error) {
buttons := make([]tg.KeyboardButtonClass, 0)
for _, storage := range storages {
cbDataId, err := dao.CreateCallbackData(storage.Name())
if err != nil {
return nil, fmt.Errorf("failed to create callback data: %w", err)
}
buttons = append(buttons, &tg.KeyboardButtonCallback{
Text: storage.Name(),
Data: []byte(fmt.Sprintf("set_default %d %d", userChatID, cbDataId)),
})
}
markup := &tg.ReplyInlineMarkup{}
for i := 0; i < len(buttons); i += 3 {
row := tg.KeyboardButtonRow{}
row.Buttons = buttons[i:min(i+3, len(buttons))]
markup.Rows = append(markup.Rows, row)
}
return markup, nil
}
func FileFromMedia(media tg.MessageMediaClass, customFileName string) (*types.File, error) {
switch media := media.(type) {
case *tg.MessageMediaDocument:
document, ok := media.Document.AsNotEmpty()
if !ok {
return nil, ErrEmptyDocument
}
if customFileName != "" {
return &types.File{
Location: document.AsInputDocumentFileLocation(),
FileSize: document.Size,
FileName: customFileName,
}, nil
}
fileName := ""
for _, attribute := range document.Attributes {
if name, ok := attribute.(*tg.DocumentAttributeFilename); ok {
fileName = name.GetFileName()
break
}
}
return &types.File{
Location: document.AsInputDocumentFileLocation(),
FileSize: document.Size,
FileName: fileName,
}, nil
case *tg.MessageMediaPhoto:
photo, ok := media.Photo.AsNotEmpty()
if !ok {
return nil, ErrEmptyPhoto
}
sizes := photo.Sizes
if len(sizes) == 0 {
return nil, ErrEmptyPhotoSizes
}
photoSize := sizes[len(sizes)-1]
size, ok := photoSize.AsNotEmpty()
if !ok {
return nil, ErrEmptyPhotoSize
}
location := new(tg.InputPhotoFileLocation)
location.ID = photo.GetID()
location.AccessHash = photo.GetAccessHash()
location.FileReference = photo.GetFileReference()
location.ThumbSize = size.GetType()
fileName := customFileName
if fileName == "" {
fileName = fmt.Sprintf("photo_%s_%d.jpg", time.Now().Format("2006-01-02_15-04-05"), photo.GetID())
}
return &types.File{
Location: location,
FileSize: 0,
FileName: fileName,
}, nil
}
return nil, fmt.Errorf("unexpected type %T", media)
}
func FileFromMessage(ctx *ext.Context, chatID int64, messageID int, customFileName string) (*types.File, error) {
key := fmt.Sprintf("file:%d:%d", chatID, messageID)
cachedFile, err := common.CacheGet[*types.File](ctx, key)
if err == nil {
if customFileName != "" {
cachedFile.FileName = customFileName
}
return cachedFile, nil
}
common.Log.Debugf("Getting file: %s", key)
message, err := GetTGMessage(ctx, chatID, messageID)
if err != nil {
return nil, err
}
file, err := FileFromMedia(message.Media, customFileName)
if err != nil {
return nil, err
}
if err := common.CacheSet(ctx, key, file); err != nil {
common.Log.Errorf("Failed to cache file: %s", err)
}
return file, nil
}
func GetTGMessage(ctx *ext.Context, chatId int64, messageID int) (*tg.Message, error) {
key := fmt.Sprintf("message:%d:%d", chatId, messageID)
cacheMessage, err := common.CacheGet[*tg.Message](ctx, key)
if err == nil {
return cacheMessage, nil
}
common.Log.Debugf("Fetching message: %d:%d", chatId, messageID)
messages, err := ctx.GetMessages(chatId, []tg.InputMessageClass{&tg.InputMessageID{ID: messageID}})
if err != nil {
return nil, err
}
if len(messages) == 0 {
return nil, ErrEmptyMessage
}
msg := messages[0]
tgMessage, ok := msg.(*tg.Message)
if !ok {
return nil, fmt.Errorf("unexpected message type: %T", msg)
}
if err := common.CacheSet(ctx, key, tgMessage); err != nil {
common.Log.Errorf("Failed to cache message: %s", err)
}
return tgMessage, nil
}
// Userbot only
//
// https://github.com/iyear/tdl/blob/fbb396da774ba544e527c3ef41c44921ad74ee98/core/util/tutil/tutil.go#L174
func GetSingleHistoryMessage(ctx context.Context, client *tg.Client, peer tg.InputPeerClass, msg int) (*tg.Message, error) {
it := query.Messages(client).GetHistory(peer).OffsetID(msg + 1).BatchSize(1).Iter()
if !it.Next(ctx) {
return nil, fmt.Errorf("failed to get message %d from %s: %w", msg, peer, it.Err())
}
m, ok := it.Value().Msg.(*tg.Message)
if !ok {
return nil, fmt.Errorf("invalid message %d", msg)
}
if m.GetID() != msg {
return nil, fmt.Errorf("the message %d/%d may be deleted", GetInputPeerID(peer), msg)
}
return m, nil
}
// Userbot only
func GetHistoryMessages(ctx context.Context, client *tg.Client, peer tg.InputPeerClass, startID, limit int) ([]*tg.Message, error) {
endID := startID + limit - 1
msgs, err := client.MessagesGetHistory(ctx, &tg.MessagesGetHistoryRequest{
Peer: peer,
OffsetID: startID,
Limit: limit,
AddOffset: startID - endID,
})
if err != nil {
return nil, fmt.Errorf("failed to get history messages: %w", err)
}
var msgClass []tg.MessageClass
switch msgsv := msgs.(type) {
case *tg.MessagesMessages:
msgClass = msgsv.GetMessages()
case *tg.MessagesMessagesSlice:
msgClass = msgsv.GetMessages()
case *tg.MessagesChannelMessages:
msgClass = msgsv.GetMessages()
default:
return nil, fmt.Errorf("unexpected messages type: %T", msgs)
}
messageBatch := make([]*tg.Message, 0, 100)
for _, msg := range msgClass {
msgNotEmpty, ok := msg.AsNotEmpty()
if !ok {
continue
}
switch msgNotEmptyV := msgNotEmpty.(type) {
case *tg.Message:
messageBatch = append(messageBatch, msgNotEmptyV)
default:
common.Log.Warnf("Unexpected message type: %T, skipping", msgNotEmptyV)
continue
}
}
if len(messageBatch) == 0 {
return nil, fmt.Errorf("no messages found for peer %s with startID %d and limit %d", peer, startID, limit)
}
return messageBatch, nil
}
func GetMediaGroup(ctx *ext.Context, chatID int64, messageID int, groupID int64) ([]*tg.Message, error) {
peer := ctx.PeerStorage.GetInputPeerById(chatID)
if peer == nil {
return nil, fmt.Errorf("无法获取聊天 %d 的输入 Peer", chatID)
}
messages, err := GetHistoryMessages(ctx, ctx.Raw, peer, messageID-9, 20)
if err != nil {
return nil, fmt.Errorf("获取消息失败: %s", err)
}
var groupMessages []*tg.Message
for _, msg := range messages {
gID, isGroup := msg.GetGroupedID()
if isGroup && gID == groupID {
groupMessages = append(groupMessages, msg)
}
}
if len(groupMessages) == 0 || (len(groupMessages) == 1 && groupMessages[0].ID == messageID) {
return nil, fmt.Errorf("未找到媒体组 %d 中的消息", groupID)
}
return groupMessages, nil
}
func GetInputPeerID(peer tg.InputPeerClass) int64 {
switch p := peer.(type) {
case *tg.InputPeerUser:
return p.UserID
case *tg.InputPeerChat:
return p.ChatID
case *tg.InputPeerChannel:
return p.ChannelID
}
return 0
}
func ProvideSelectMessage(ctx *ext.Context, update *ext.Update, fileName string, chatID int64, fileMsgID, toEditMsgID int) error {
entityBuilder := entity.Builder{}
var entities []tg.MessageEntityClass
text := fmt.Sprintf("文件名: %s\n请选择存储位置", fileName)
if err := styling.Perform(&entityBuilder,
styling.Plain("文件名: "),
styling.Code(fileName),
styling.Plain("\n请选择存储位置"),
); err != nil {
common.Log.Errorf("Failed to build entity: %s", err)
} else {
text, entities = entityBuilder.Complete()
}
markup, err := getSelectStorageMarkup(update.GetUserChat().GetID(), int(chatID), fileMsgID)
if errors.Is(err, ErrNoStorages) {
common.Log.Errorf("Failed to get select storage markup: %s", err)
ctx.EditMessage(update.EffectiveChat().GetID(), &tg.MessagesEditMessageRequest{
Message: "无可用存储",
ID: toEditMsgID,
})
return dispatcher.EndGroups
} else if err != nil {
common.Log.Errorf("Failed to get select storage markup: %s", err)
ctx.EditMessage(update.EffectiveChat().GetID(), &tg.MessagesEditMessageRequest{
Message: "无法获取存储",
ID: toEditMsgID,
})
return dispatcher.EndGroups
}
_, err = ctx.EditMessage(update.EffectiveChat().GetID(), &tg.MessagesEditMessageRequest{
Message: text,
Entities: entities,
ReplyMarkup: markup,
ID: toEditMsgID,
})
if err != nil {
common.Log.Errorf("Failed to reply: %s", err)
}
return dispatcher.EndGroups
}
func HandleSilentAddTask(ctx *ext.Context, update *ext.Update, user *dao.User, task *types.Task) error {
if user.DefaultStorage == "" {
ctx.EditMessage(update.EffectiveChat().GetID(), &tg.MessagesEditMessageRequest{
Message: "请先使用 /storage 设置默认存储位置",
ID: task.ReplyMessageID,
})
return dispatcher.EndGroups
}
queue.AddTask(task)
ctx.EditMessage(update.EffectiveChat().GetID(), &tg.MessagesEditMessageRequest{
Message: fmt.Sprintf("已添加到队列: %s\n当前排队任务数: %d", task.FileName(), queue.Len()),
ID: task.ReplyMessageID,
})
return dispatcher.EndGroups
}
func GenFileNameFromMessage(message tg.Message, file *types.File) string {
if file.FileName != "" {
return file.FileName
}
fileName := genFileNameFromMessageText(message, file)
media, ok := message.GetMedia()
if !ok {
return fileName
}
ext, ok := extraMediaExt(media)
if ok {
return fileName + ext
}
return fileName
}
func genFileNameFromMessageText(message tg.Message, file *types.File) string {
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()))
}
// 删除换行和特殊字符
text = strings.Map(func(r rune) rune {
if r == '\n' || r == '\r' || r == '\t' || r == ' ' {
return '_'
}
return r
}, text)
runes := []rune(text)
return string(runes[:min(128, len(runes))])
}
func extraMediaExt(media tg.MessageMediaClass) (string, bool) {
switch media := media.(type) {
case *tg.MessageMediaDocument:
doc, ok := media.Document.AsNotEmpty()
if !ok {
return "", false
}
ext := mimetype.Lookup(doc.MimeType).Extension()
if ext == "" {
return "", false
}
return ext, true
case *tg.MessageMediaPhoto:
return ".jpg", true
}
return "", false
}

View File

@@ -6,12 +6,16 @@ import (
"time" "time"
"github.com/celestix/gotgproto" "github.com/celestix/gotgproto"
"github.com/celestix/gotgproto/dispatcher"
"github.com/celestix/gotgproto/ext"
"github.com/celestix/gotgproto/sessionMaker" "github.com/celestix/gotgproto/sessionMaker"
"github.com/glebarez/sqlite" "github.com/charmbracelet/log"
"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/client/bot/handlers"
"github.com/krau/SaveAny-Bot/client/middleware"
"github.com/krau/SaveAny-Bot/config" "github.com/krau/SaveAny-Bot/config"
"github.com/ncruces/go-sqlite3/gormlite"
"golang.org/x/net/proxy" "golang.org/x/net/proxy"
) )
@@ -25,11 +29,8 @@ func newProxyDialer(proxyUrl string) (proxy.Dialer, error) {
return proxy.FromURL(url, proxy.Direct) return proxy.FromURL(url, proxy.Direct)
} }
func Init() { func Init(ctx context.Context) {
common.Log.Info("初始化 Telegram 客户端...") log.FromContext(ctx).Info("初始化 Bot...")
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(config.Cfg.Telegram.Timeout)*time.Second)
defer cancel()
go InitTelegraphClient()
resultChan := make(chan struct { resultChan := make(chan struct {
client *gotgproto.Client client *gotgproto.Client
err error err error
@@ -55,11 +56,17 @@ func Init() {
config.Cfg.Telegram.AppHash, config.Cfg.Telegram.AppHash,
gotgproto.ClientTypeBot(config.Cfg.Telegram.Token), gotgproto.ClientTypeBot(config.Cfg.Telegram.Token),
&gotgproto.ClientOpts{ &gotgproto.ClientOpts{
Session: sessionMaker.SqlSession(sqlite.Open(config.Cfg.DB.Session)), Session: sessionMaker.SqlSession(gormlite.Open(config.Cfg.DB.Session)),
DisableCopyright: true, DisableCopyright: true,
Middlewares: FloodWaitMiddleware(), Middlewares: middleware.NewDefaultMiddlewares(ctx, 5*time.Minute),
Resolver: resolver, Resolver: resolver,
Context: ctx,
MaxRetries: config.Cfg.Telegram.RpcRetry, MaxRetries: config.Cfg.Telegram.RpcRetry,
AutoFetchReply: true,
ErrorHandler: func(ctx *ext.Context, u *ext.Update, s string) error {
log.FromContext(ctx).Errorf("Unhandled error: %s", s)
return dispatcher.EndGroups
},
}, },
) )
if err != nil { if err != nil {
@@ -69,6 +76,9 @@ func Init() {
}{nil, err} }{nil, err}
return return
} }
client.API().BotsSetBotCommands(ctx, &tg.BotsSetBotCommandsRequest{
Scope: &tg.BotCommandScopeDefault{},
})
_, err = client.API().BotsSetBotCommands(ctx, &tg.BotsSetBotCommandsRequest{ _, err = client.API().BotsSetBotCommands(ctx, &tg.BotsSetBotCommandsRequest{
Scope: &tg.BotCommandScopeDefault{}, Scope: &tg.BotCommandScopeDefault{},
Commands: []tg.BotCommand{ Commands: []tg.BotCommand{
@@ -89,13 +99,13 @@ func Init() {
select { select {
case <-ctx.Done(): case <-ctx.Done():
common.Log.Panic("初始化客户端失败: 超时") log.FromContext(ctx).Errorf("已取消 Bot 初始化: %s", ctx.Err())
case result := <-resultChan: case result := <-resultChan:
if result.err != nil { if result.err != nil {
common.Log.Panicf("初始化客户端失败: %s", result.err) log.FromContext(ctx).Fatalf("初始化 Bot 失败: %s", result.err)
} }
Client = result.client Client = result.client
RegisterHandlers(Client.Dispatcher) handlers.Register(Client.Dispatcher)
common.Log.Info("客户端初始化完成") log.FromContext(ctx).Info("Bot 初始化完成")
} }
} }

View File

@@ -0,0 +1,80 @@
package handlers
import (
"errors"
"fmt"
"strings"
"github.com/celestix/gotgproto/dispatcher"
"github.com/celestix/gotgproto/ext"
"github.com/charmbracelet/log"
"github.com/gotd/td/tg"
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/msgelem"
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/shortcut"
"github.com/krau/SaveAny-Bot/database"
"github.com/krau/SaveAny-Bot/pkg/enums/tasktype"
"github.com/krau/SaveAny-Bot/pkg/tcbdata"
"github.com/krau/SaveAny-Bot/storage"
"gorm.io/gorm"
)
func handleAddCallback(ctx *ext.Context, update *ext.Update) error {
dataid := strings.Split(string(update.CallbackQuery.Data), " ")[1]
data, err := shortcut.GetCallbackDataWithAnswer[tcbdata.Add](ctx, update, dataid)
if err != nil {
return err
}
queryID := update.CallbackQuery.GetQueryID()
msgID := update.CallbackQuery.GetMsgID()
userID := update.CallbackQuery.GetUserID()
selectedStorage, err := storage.GetStorageByUserIDAndName(ctx, userID, data.SelectedStorName)
if err != nil {
log.FromContext(ctx).Errorf("Failed to get storage: %s", err)
ctx.AnswerCallback(msgelem.AlertCallbackAnswer(queryID, "存储获取失败: "+err.Error()))
return dispatcher.EndGroups
}
dirs, err := database.GetDirsByUserChatIDAndStorageName(ctx, userID, data.SelectedStorName)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return fmt.Errorf("获取用户目录失败: %w", err)
}
if !data.SettedDir && len(dirs) != 0 {
// ask for directory selection
markup, err := msgelem.BuildSetDirKeyboard(dirs, dataid)
if err != nil {
log.FromContext(ctx).Errorf("Failed to build directory keyboard: %s", err)
ctx.AnswerCallback(msgelem.AlertCallbackAnswer(queryID, "目录键盘构建失败: "+err.Error()))
return dispatcher.EndGroups
}
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
ID: update.CallbackQuery.GetMsgID(),
Message: "请选择要存储到的目录",
ReplyMarkup: markup,
})
return dispatcher.EndGroups
}
dirPath := ""
if data.DirID != 0 {
dir, err := database.GetDirByID(ctx, data.DirID)
if err != nil {
ctx.AnswerCallback(msgelem.AlertCallbackAnswer(queryID, "获取目录失败: "+err.Error()))
return dispatcher.EndGroups
}
dirPath = dir.Path
}
switch data.TaskType {
case tasktype.TaskTypeTgfiles:
if data.AsBatch {
return shortcut.CreateAndAddBatchTGFileTaskWithEdit(ctx, userID, selectedStorage, dirPath, data.Files, msgID)
}
return shortcut.CreateAndAddTGFileTaskWithEdit(ctx, userID, selectedStorage, dirPath, data.Files[0], msgID)
case tasktype.TaskTypeTphpics:
return shortcut.CreateAndAddTphTaskWithEdit(ctx, userID, data.TphPageNode, data.TphDirPath, data.TphPics, selectedStorage, msgID)
default:
log.FromContext(ctx).Errorf("Unsupported task type: %s", data.TaskType)
}
return dispatcher.EndGroups
}

View File

@@ -0,0 +1,28 @@
package handlers
import (
"strings"
"github.com/celestix/gotgproto/dispatcher"
"github.com/celestix/gotgproto/ext"
"github.com/charmbracelet/log"
"github.com/gotd/td/tg"
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/msgelem"
"github.com/krau/SaveAny-Bot/core"
)
func handleCancelCallback(ctx *ext.Context, update *ext.Update) error {
taskid := strings.Split(string(update.CallbackQuery.Data), " ")[1]
if err := core.CancelTask(ctx, taskid); err != nil {
log.FromContext(ctx).Errorf("error cancelling task %s: %v", taskid, err)
ctx.AnswerCallback(msgelem.AlertCallbackAnswer(update.CallbackQuery.GetQueryID(), "取消任务失败: "+err.Error()))
return dispatcher.EndGroups
}
ctx.EditMessage(update.CallbackQuery.GetUserID(), &tg.MessagesEditMessageRequest{
ID: update.CallbackQuery.GetMsgID(),
Message: "正在取消任务...",
})
return dispatcher.EndGroups
}

View File

@@ -0,0 +1,74 @@
package handlers
import (
"strconv"
"strings"
"github.com/celestix/gotgproto/dispatcher"
"github.com/celestix/gotgproto/ext"
"github.com/charmbracelet/log"
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/msgelem"
"github.com/krau/SaveAny-Bot/database"
"github.com/krau/SaveAny-Bot/storage"
)
func handleDirCmd(ctx *ext.Context, update *ext.Update) error {
logger := log.FromContext(ctx)
args := strings.Split(update.EffectiveMessage.Text, " ")
userChatID := update.GetUserChat().GetID()
dirs, err := database.GetUserDirsByChatID(ctx, userChatID)
if err != nil {
logger.Errorf("获取用户文件夹失败: %s", err)
ctx.Reply(update, ext.ReplyTextString("获取用户文件夹失败"), nil)
return dispatcher.EndGroups
}
if len(args) < 2 {
ctx.Reply(update, ext.ReplyTextStyledTextArray(msgelem.BuildDirHelpStyling(dirs)), nil)
return dispatcher.EndGroups
}
user, err := database.GetUserByChatID(ctx, update.GetUserChat().GetID())
if err != nil {
logger.Errorf("获取用户失败: %s", err)
ctx.Reply(update, ext.ReplyTextString("获取用户失败"), nil)
return dispatcher.EndGroups
}
switch args[1] {
case "add":
// /dir add local1 path/to/dir
if len(args) < 4 {
ctx.Reply(update, ext.ReplyTextStyledTextArray(msgelem.BuildDirHelpStyling(dirs)), nil)
return dispatcher.EndGroups
}
if _, err := storage.GetStorageByUserIDAndName(ctx, user.ChatID, args[2]); err != nil {
ctx.Reply(update, ext.ReplyTextString(err.Error()), nil)
return dispatcher.EndGroups
}
if err := database.CreateDirForUser(ctx, user.ID, args[2], args[3]); err != nil {
logger.Errorf("创建文件夹失败: %s", err)
ctx.Reply(update, ext.ReplyTextString("创建文件夹失败"), nil)
return dispatcher.EndGroups
}
ctx.Reply(update, ext.ReplyTextString("文件夹添加成功"), nil)
case "del":
// /dir del 3
if len(args) < 3 {
ctx.Reply(update, ext.ReplyTextStyledTextArray(msgelem.BuildDirHelpStyling(dirs)), nil)
return dispatcher.EndGroups
}
dirID, err := strconv.Atoi(args[2])
if err != nil {
ctx.Reply(update, ext.ReplyTextString("文件夹ID无效"), nil)
return dispatcher.EndGroups
}
if err := database.DeleteDirByID(ctx, uint(dirID)); err != nil {
logger.Errorf("删除文件夹失败: %s", err)
ctx.Reply(update, ext.ReplyTextString("删除文件夹失败"), nil)
return dispatcher.EndGroups
}
ctx.Reply(update, ext.ReplyTextString("文件夹删除成功"), nil)
default:
ctx.Reply(update, ext.ReplyTextString("未知操作"), nil)
}
return dispatcher.EndGroups
}

View File

@@ -1,23 +1,15 @@
package bot package handlers
import ( import (
"fmt" "fmt"
"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/pkg/consts"
"github.com/krau/SaveAny-Bot/dao"
) )
func start(ctx *ext.Context, update *ext.Update) error { func handleHelpCmd(ctx *ext.Context, update *ext.Update) error {
if err := dao.CreateUser(update.GetUserChat().GetID()); err != nil { const helpText string = `
common.Log.Errorf("创建用户失败: %s", err)
return dispatcher.EndGroups
}
return help(ctx, update)
}
const helpText string = `
Save Any Bot - 转存你的 Telegram 文件 Save Any Bot - 转存你的 Telegram 文件
版本: %s , 提交: %s 版本: %s , 提交: %s
命令: 命令:
@@ -33,8 +25,6 @@ Save Any Bot - 转存你的 Telegram 文件
Bot 发送(转发)文件, 或发送一个公开频道的消息链接以保存文件 Bot 发送(转发)文件, 或发送一个公开频道的消息链接以保存文件
` `
ctx.Reply(update, ext.ReplyTextString(fmt.Sprintf(helpText, consts.Version, consts.GitCommit)), nil)
func help(ctx *ext.Context, update *ext.Update) error {
ctx.Reply(update, ext.ReplyTextString(fmt.Sprintf(helpText, common.Version, common.GitCommit)), nil)
return dispatcher.EndGroups return dispatcher.EndGroups
} }

View File

@@ -0,0 +1,63 @@
package handlers
import (
"fmt"
"github.com/celestix/gotgproto/dispatcher"
"github.com/celestix/gotgproto/ext"
"github.com/charmbracelet/log"
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/msgelem"
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/shortcut"
"github.com/krau/SaveAny-Bot/pkg/tcbdata"
"github.com/krau/SaveAny-Bot/storage"
)
func handleMessageLink(ctx *ext.Context, update *ext.Update) error {
replied, files, editReplied, err := shortcut.GetFilesFromUpdateLinkMessageWithReplyEdit(ctx, update)
if err != nil {
return err
}
logger := log.FromContext(ctx)
userId := update.GetUserChat().GetID()
stors := storage.GetUserStorages(ctx, userId)
if len(files) == 1 {
req, err := msgelem.BuildAddOneSelectStorageMessage(ctx, stors, files[0], replied.ID)
if err != nil {
logger.Errorf("构建存储选择消息失败: %s", err)
editReplied("构建存储选择消息失败: "+err.Error(), nil)
return dispatcher.EndGroups
}
ctx.EditMessage(update.EffectiveChat().GetID(), req)
return dispatcher.EndGroups
}
markup, err := msgelem.BuildAddSelectStorageKeyboard(stors, tcbdata.Add{
Files: files,
})
if err != nil {
logger.Errorf("构建存储选择键盘失败: %s", err)
editReplied("构建存储选择键盘失败: "+err.Error(), nil)
return dispatcher.EndGroups
}
editReplied(fmt.Sprintf("找到 %d 个文件, 请选择存储位置", len(files)),
markup)
return dispatcher.EndGroups
}
func handleSilentSaveLink(ctx *ext.Context, update *ext.Update) error {
logger := log.FromContext(ctx)
stor := storage.FromContext(ctx)
if stor == nil {
logger.Warn("Context storage is nil")
ctx.Reply(update, ext.ReplyTextString("未找到存储"), nil)
return dispatcher.EndGroups
}
replied, files, _, err := shortcut.GetFilesFromUpdateLinkMessageWithReplyEdit(ctx, update)
if err != nil {
return err
}
userId := update.GetUserChat().GetID()
if len(files) == 1 {
return shortcut.CreateAndAddTGFileTaskWithEdit(ctx, userId, stor, "", files[0], replied.ID)
}
return shortcut.CreateAndAddBatchTGFileTaskWithEdit(ctx, userId, stor, "", files, replied.ID)
}

View File

@@ -0,0 +1,48 @@
package handlers
import (
"github.com/celestix/gotgproto/dispatcher"
"github.com/celestix/gotgproto/ext"
"github.com/charmbracelet/log"
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/msgelem"
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/shortcut"
"github.com/krau/SaveAny-Bot/storage"
)
func handleMediaMessage(ctx *ext.Context, update *ext.Update) error {
logger := log.FromContext(ctx)
message := update.EffectiveMessage.Message
logger.Debugf("Got media: %s", message.Media.TypeName())
msg, file, err := shortcut.GetFileFromMessageWithReply(ctx, update, message)
if err != nil {
return err
}
userId := update.GetUserChat().GetID()
stors := storage.GetUserStorages(ctx, userId)
req, err := msgelem.BuildAddOneSelectStorageMessage(ctx, stors, file, msg.ID)
if err != nil {
logger.Errorf("构建存储选择消息失败: %s", err)
ctx.Reply(update, ext.ReplyTextString("构建存储选择消息失败: "+err.Error()), nil)
return dispatcher.EndGroups
}
ctx.EditMessage(update.EffectiveChat().GetID(), req)
return dispatcher.EndGroups
}
func handleSilentSaveMedia(ctx *ext.Context, update *ext.Update) error {
logger := log.FromContext(ctx)
stor := storage.FromContext(ctx)
if stor == nil {
logger.Warn("Context storage is nil")
ctx.Reply(update, ext.ReplyTextString("未找到存储"), nil)
return dispatcher.EndGroups
}
message := update.EffectiveMessage.Message
logger.Debugf("Got media: %s", message.Media.TypeName())
userID := update.GetUserChat().GetID()
msg, file, err := shortcut.GetFileFromMessageWithReply(ctx, update, message)
if err != nil {
return err
}
return shortcut.CreateAndAddTGFileTaskWithEdit(ctx, userID, stor, "", file, msg.ID)
}

View File

@@ -0,0 +1,49 @@
package handlers
import (
"github.com/celestix/gotgproto/dispatcher"
"github.com/celestix/gotgproto/ext"
"github.com/duke-git/lancet/v2/slice"
"github.com/krau/SaveAny-Bot/config"
"github.com/krau/SaveAny-Bot/database"
"github.com/krau/SaveAny-Bot/storage"
)
func checkPermission(ctx *ext.Context, update *ext.Update) error {
userID := update.GetUserChat().GetID()
if !slice.Contain(config.Cfg.GetUsersID(), userID) {
const noPermissionText string = `
您不在白名单中, 无法使用此 Bot.
您可以部署自己的实例: https://github.com/krau/SaveAny-Bot
`
ctx.Reply(update, ext.ReplyTextString(noPermissionText), nil)
return dispatcher.EndGroups
}
return dispatcher.ContinueGroups
}
func handleSilentMode(next func(*ext.Context, *ext.Update) error, handler func(*ext.Context, *ext.Update) error) func(*ext.Context, *ext.Update) error {
return func(ctx *ext.Context, update *ext.Update) error {
userID := update.GetUserChat().GetID()
user, err := database.GetUserByChatID(ctx, userID)
if err != nil {
ctx.Reply(update, ext.ReplyTextString("获取用户信息失败: "+err.Error()), nil)
return dispatcher.EndGroups
}
if !user.Silent {
return next(ctx, update)
}
if user.DefaultStorage == "" {
ctx.Reply(update, ext.ReplyTextString("您已开启静默模式, 但未设置默认存储端, 请先使用 /storage 设置"), nil)
return next(ctx, update)
}
stor, err := storage.GetStorageByUserIDAndName(ctx, userID, user.DefaultStorage)
if err != nil {
ctx.Reply(update, ext.ReplyTextString("获取默认存储失败: "+err.Error()), nil)
return dispatcher.EndGroups
}
ctx.Context = storage.WithContext(ctx.Context, stor)
return handler(ctx, update)
}
}

View File

@@ -0,0 +1,41 @@
package handlers
import (
"github.com/celestix/gotgproto/dispatcher"
"github.com/celestix/gotgproto/dispatcher/handlers"
"github.com/celestix/gotgproto/dispatcher/handlers/filters"
"github.com/celestix/gotgproto/ext"
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/re"
"github.com/krau/SaveAny-Bot/pkg/tcbdata"
)
func Register(disp dispatcher.Dispatcher) {
disp.AddHandler(handlers.NewMessage(filters.Message.ChatType(filters.ChatTypeChannel), func(ctx *ext.Context, u *ext.Update) error {
return dispatcher.EndGroups
}))
disp.AddHandler(handlers.NewMessage(filters.Message.ChatType(filters.ChatTypeChat), func(ctx *ext.Context, u *ext.Update) error {
return dispatcher.EndGroups
}))
disp.AddHandler(handlers.NewMessage(filters.Message.All, checkPermission))
disp.AddHandler(handlers.NewCommand("start", handleHelpCmd))
disp.AddHandler(handlers.NewCommand("help", handleHelpCmd))
disp.AddHandler(handlers.NewCommand("silent", handleSilentCmd))
disp.AddHandler(handlers.NewCommand("storage", handleStorageCmd))
disp.AddHandler(handlers.NewCommand("dir", handleDirCmd))
disp.AddHandler(handlers.NewCommand("rule", handleRuleCmd))
disp.AddHandler(handlers.NewCommand("save", handleSilentMode(handleSaveCmd, handleSilentSaveReplied)))
disp.AddHandler(handlers.NewCallbackQuery(filters.CallbackQuery.Prefix(tcbdata.TypeAdd), handleAddCallback))
disp.AddHandler(handlers.NewCallbackQuery(filters.CallbackQuery.Prefix(tcbdata.TypeSetDefault), handleSetDefaultCallback))
disp.AddHandler(handlers.NewCallbackQuery(filters.CallbackQuery.Prefix("cancel"), handleCancelCallback))
linkRegexFilter, err := filters.Message.Regex(re.TgMessageLinkRegexString)
if err != nil {
panic("failed to create regex filter: " + err.Error())
}
disp.AddHandler(handlers.NewMessage(linkRegexFilter, handleSilentMode(handleMessageLink, handleSilentSaveLink)))
telegraphUrlRegexFilter, err := filters.Message.Regex(re.TelegraphUrlRegexString)
if err != nil {
panic("failed to create Telegraph URL regex filter: " + err.Error())
}
disp.AddHandler(handlers.NewMessage(telegraphUrlRegexFilter, handleSilentMode(handleTelegraphUrlMessage, handleSilentSaveTelegraph)))
disp.AddHandler(handlers.NewMessage(filters.Message.Media, handleSilentMode(handleMediaMessage, handleSilentSaveMedia)))
}

101
client/bot/handlers/rule.go Normal file
View File

@@ -0,0 +1,101 @@
package handlers
import (
"fmt"
"strconv"
"strings"
"github.com/celestix/gotgproto/dispatcher"
"github.com/celestix/gotgproto/ext"
"github.com/charmbracelet/log"
"github.com/duke-git/lancet/v2/slice"
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/msgelem"
"github.com/krau/SaveAny-Bot/database"
"github.com/krau/SaveAny-Bot/pkg/enums/rule"
)
func handleRuleCmd(ctx *ext.Context, update *ext.Update) error {
logger := log.FromContext(ctx)
args := strings.Split(update.EffectiveMessage.Text, " ")
userChatID := update.GetUserChat().GetID()
user, err := database.GetUserByChatID(ctx, userChatID)
if err != nil {
logger.Errorf("获取用户规则失败: %s", err)
ctx.Reply(update, ext.ReplyTextString("获取用户规则失败"), nil)
return dispatcher.EndGroups
}
if len(args) < 2 {
ctx.Reply(update, ext.ReplyTextStyledTextArray(msgelem.BuildRuleHelpStyling(user.ApplyRule, user.Rules)), nil)
return dispatcher.EndGroups
}
switch args[1] {
case "switch":
// /rule switch
applyRule := !user.ApplyRule
if err := database.UpdateUserApplyRule(ctx, user.ChatID, applyRule); err != nil {
logger.Errorf("更新用户失败: %s", err)
ctx.Reply(update, ext.ReplyTextString("更新用户失败"), nil)
return dispatcher.EndGroups
}
ctx.Reply(update, ext.ReplyTextString(fmt.Sprintf("已%s规则模式", map[bool]string{true: "启用", false: "禁用"}[applyRule])), nil)
case "add":
// /rule add <type> <data> <storage> <dirpath>
if len(args) < 6 {
ctx.Reply(update, ext.ReplyTextStyledTextArray(msgelem.BuildRuleHelpStyling(user.ApplyRule, user.Rules)), nil)
return dispatcher.EndGroups
}
ruleTypeArg := args[2]
ruleType, err := func() (rule.RuleType, error) {
for _, t := range rule.Values() {
if strings.EqualFold(t.String(), ruleTypeArg) {
return t, nil
}
}
return rule.RuleType(""), fmt.Errorf("无效的规则类型: %s\n可用: %v", ruleTypeArg, slice.Join(rule.Values(), ", "))
}()
if err != nil {
ctx.Reply(update, ext.ReplyTextString(err.Error()), nil)
return dispatcher.EndGroups
}
ruleData := args[3]
storageName := args[4]
dirPath := args[5]
rd := &database.Rule{
Type: ruleType.String(),
Data: ruleData,
StorageName: storageName,
DirPath: dirPath,
UserID: user.ID,
}
if err := database.CreateRule(ctx, rd); err != nil {
logger.Errorf("创建规则失败: %s", err)
ctx.Reply(update, ext.ReplyTextString("创建规则失败"), nil)
return dispatcher.EndGroups
}
ctx.Reply(update, ext.ReplyTextString("创建规则成功"), nil)
case "del":
// /rule del <id>
if len(args) < 3 {
ctx.Reply(update, ext.ReplyTextString("请提供规则ID"), nil)
return dispatcher.EndGroups
}
ruleID := args[2]
id, err := strconv.Atoi(ruleID)
if err != nil {
ctx.Reply(update, ext.ReplyTextString("无效的规则ID"), nil)
return dispatcher.EndGroups
}
if err := database.DeleteRule(ctx, uint(id)); err != nil {
logger.Errorf("删除规则失败: %s", err)
ctx.Reply(update, ext.ReplyTextString("删除规则失败"), nil)
return dispatcher.EndGroups
}
ctx.Reply(update, ext.ReplyTextString("删除规则成功"), nil)
default:
ctx.Reply(update, ext.ReplyTextStyledTextArray(msgelem.BuildRuleHelpStyling(user.ApplyRule, user.Rules)), nil)
return dispatcher.EndGroups
}
return dispatcher.EndGroups
}

168
client/bot/handlers/save.go Normal file
View File

@@ -0,0 +1,168 @@
package handlers
import (
"fmt"
"strings"
"github.com/celestix/gotgproto/dispatcher"
"github.com/celestix/gotgproto/ext"
"github.com/charmbracelet/log"
"github.com/gotd/td/tg"
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/mediautil"
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/msgelem"
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/shortcut"
"github.com/krau/SaveAny-Bot/common/utils/strutil"
"github.com/krau/SaveAny-Bot/common/utils/tgutil"
"github.com/krau/SaveAny-Bot/pkg/tcbdata"
"github.com/krau/SaveAny-Bot/pkg/tfile"
"github.com/krau/SaveAny-Bot/storage"
)
func handleSaveCmd(ctx *ext.Context, update *ext.Update) error {
logger := log.FromContext(ctx)
args := strings.Split(string(update.EffectiveMessage.Text), " ")
if len(args) >= 3 {
return handleBatchSave(ctx, update, args[1], args[2])
}
replyTo := update.EffectiveMessage.ReplyToMessage
if replyTo == nil || replyTo.Message == nil {
ctx.Reply(update, ext.ReplyTextString(msgelem.SaveHelpText), nil)
return dispatcher.EndGroups
}
genFilename := func() string {
if len(args) > 1 {
return args[1]
}
filename := tgutil.GenFileNameFromMessage(*replyTo.Message)
return filename
}()
option := tfile.WithNameIfEmpty(genFilename)
if len(args) > 1 {
option = tfile.WithName(genFilename)
}
msg, file, err := shortcut.GetFileFromMessageWithReply(ctx, update, replyTo.Message, option)
if err != nil {
return err
}
userId := update.GetUserChat().GetID()
stors := storage.GetUserStorages(ctx, userId)
req, err := msgelem.BuildAddOneSelectStorageMessage(ctx, stors, file, msg.ID)
if err != nil {
logger.Errorf("构建存储选择消息失败: %s", err)
ctx.Reply(update, ext.ReplyTextString("构建存储选择消息失败: "+err.Error()), nil)
return dispatcher.EndGroups
}
ctx.EditMessage(update.EffectiveChat().GetID(), req)
return dispatcher.EndGroups
}
func handleSilentSaveReplied(ctx *ext.Context, update *ext.Update) error {
args := strings.Split(string(update.EffectiveMessage.Text), " ")
if len(args) >= 3 {
return handleBatchSave(ctx, update, args[1], args[2])
}
logger := log.FromContext(ctx)
stor := storage.FromContext(ctx)
if stor == nil {
logger.Warn("Context storage is nil")
ctx.Reply(update, ext.ReplyTextString("未找到存储"), nil)
return dispatcher.EndGroups
}
replyTo := update.EffectiveMessage.ReplyToMessage
if replyTo == nil || replyTo.Message == nil {
ctx.Reply(update, ext.ReplyTextString(msgelem.SaveHelpText), nil)
return dispatcher.EndGroups
}
genFilename := func() string {
if len(args) > 1 {
return args[1]
}
filename := tgutil.GenFileNameFromMessage(*replyTo.Message)
return filename
}()
option := tfile.WithNameIfEmpty(genFilename)
if len(args) > 1 {
option = tfile.WithName(genFilename)
}
msg, file, err := shortcut.GetFileFromMessageWithReply(ctx, update, replyTo.Message, option)
if err != nil {
return err
}
return shortcut.CreateAndAddTGFileTaskWithEdit(ctx, update.GetUserChat().GetID(), stor, "", file, msg.GetID())
}
func handleBatchSave(ctx *ext.Context, update *ext.Update, chatArg string, msgIdRangeArg string) error {
startID, endID, err := strutil.ParseIntStrRange(msgIdRangeArg, "-")
if err != nil {
ctx.Reply(update, ext.ReplyTextString("无效的消息ID范围: "+err.Error()), nil)
return dispatcher.EndGroups
}
chatID, err := tgutil.ParseChatID(ctx, chatArg)
if err != nil {
ctx.Reply(update, ext.ReplyTextString("无效的ID或用户名: "+err.Error()), nil)
return dispatcher.EndGroups
}
replied, err := ctx.Reply(update, ext.ReplyTextString("正在获取消息..."), nil)
if err != nil {
log.FromContext(ctx).Errorf("回复失败: %s", err)
return dispatcher.EndGroups
}
// TODO: generator istead of get all messages
msgs, err := tgutil.GetMessagesRange(ctx, chatID, int(startID), int(endID))
if err != nil {
ctx.Reply(update, ext.ReplyTextString("获取消息失败: "+err.Error()), nil)
return dispatcher.EndGroups
}
if len(msgs) == 0 {
ctx.Reply(update, ext.ReplyTextString("没有找到指定范围内的消息"), nil)
return dispatcher.EndGroups
}
files := make([]tfile.TGFileMessage, 0, len(msgs))
for _, msg := range msgs {
media, ok := msg.GetMedia()
if !ok {
continue
}
supported := mediautil.IsSupported(media)
if !supported {
continue
}
file, err := tfile.FromMediaMessage(media, msg, tfile.WithNameIfEmpty(tgutil.GenFileNameFromMessage(*msg)))
if err != nil {
log.FromContext(ctx).Errorf("获取文件失败: %s", err)
continue
}
files = append(files, file)
}
if len(files) == 0 {
ctx.Reply(update, ext.ReplyTextString("没有找到指定范围内的可保存消息"), nil)
return dispatcher.EndGroups
}
stor := storage.FromContext(ctx)
if stor == nil {
// not in silent mode
stors := storage.GetUserStorages(ctx, update.GetUserChat().GetID())
markup, err := msgelem.BuildAddSelectStorageKeyboard(stors, tcbdata.Add{
Files: files,
})
if err != nil {
log.FromContext(ctx).Errorf("构建存储选择键盘失败: %s", err)
ctx.EditMessage(update.EffectiveChat().GetID(), &tg.MessagesEditMessageRequest{
ID: replied.ID,
Message: "构建存储选择键盘失败: " + err.Error(),
})
return dispatcher.EndGroups
}
ctx.EditMessage(update.EffectiveChat().GetID(), &tg.MessagesEditMessageRequest{
ID: replied.ID,
Message: fmt.Sprintf("找到 %d 个文件, 请选择存储位置", len(files)),
ReplyMarkup: markup,
})
return dispatcher.EndGroups
}
return shortcut.CreateAndAddBatchTGFileTaskWithEdit(ctx, update.GetUserChat().GetID(), stor, "", files, replied.ID)
}

View File

@@ -0,0 +1,104 @@
package handlers
import (
"strings"
"github.com/celestix/gotgproto/dispatcher"
"github.com/celestix/gotgproto/ext"
"github.com/gotd/td/tg"
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/msgelem"
"github.com/krau/SaveAny-Bot/common/cache"
"github.com/krau/SaveAny-Bot/database"
"github.com/krau/SaveAny-Bot/pkg/tcbdata"
"github.com/krau/SaveAny-Bot/storage"
)
func handleSilentCmd(ctx *ext.Context, update *ext.Update) error {
user, err := database.GetUserByChatID(ctx, update.GetUserChat().GetID())
if err != nil {
ctx.Reply(update, ext.ReplyTextString("获取用户信息失败: "+err.Error()), nil)
return nil
}
if !user.Silent && user.DefaultStorage == "" {
ctx.Reply(update, ext.ReplyTextString("请先使用 /storage 设置默认存储位置"), nil)
return nil
}
user.Silent = !user.Silent
if err := database.UpdateUser(ctx, user); err != nil {
ctx.Reply(update, ext.ReplyTextString("更新用户信息失败: "+err.Error()), nil)
return nil
}
responseText := "已" + map[bool]string{true: "开启", false: "关闭"}[user.Silent] + "静默模式"
ctx.Reply(update, ext.ReplyTextString(responseText), nil)
return dispatcher.EndGroups
}
func handleSetDefaultCallback(ctx *ext.Context, update *ext.Update) error {
dataid := strings.Split(string(update.CallbackQuery.Data), " ")[1]
data, ok := cache.Get[tcbdata.SetDefaultStorage](dataid)
if !ok {
ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{
QueryID: update.CallbackQuery.GetQueryID(),
Alert: true,
Message: "数据已过期",
CacheTime: 5,
})
return dispatcher.EndGroups
}
userID := update.CallbackQuery.GetUserID()
storageName := data.StorageName
selectedStorage, err := storage.GetStorageByUserIDAndName(ctx, userID, storageName)
if err != nil {
ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{
QueryID: update.CallbackQuery.GetQueryID(),
Alert: true,
Message: "存储获取失败: " + err.Error(),
CacheTime: 5,
})
return dispatcher.EndGroups
}
user, err := database.GetUserByChatID(ctx, userID)
if err != nil {
ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{
QueryID: update.CallbackQuery.GetQueryID(),
Alert: true,
Message: "获取用户信息失败: " + err.Error(),
CacheTime: 5,
})
return dispatcher.EndGroups
}
user.DefaultStorage = selectedStorage.Name()
if err := database.UpdateUser(ctx, user); err != nil {
ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{
QueryID: update.CallbackQuery.GetQueryID(),
Alert: true,
Message: "更新用户信息失败: " + err.Error(),
CacheTime: 5,
})
return dispatcher.EndGroups
}
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
ID: update.CallbackQuery.GetMsgID(),
Message: "已将默认存储位置设置为: " + selectedStorage.Name(),
})
return dispatcher.EndGroups
}
func handleStorageCmd(ctx *ext.Context, update *ext.Update) error {
userID := update.GetUserChat().GetID()
storages := storage.GetUserStorages(ctx, userID)
if len(storages) == 0 {
ctx.Reply(update, ext.ReplyTextString("无可用的存储"), nil)
return nil
}
markup, err := msgelem.BuildSetDefaultStorageMarkup(ctx, userID, storages)
if err != nil {
ctx.Reply(update, ext.ReplyTextString("获取存储失败: "+err.Error()), nil)
return nil
}
ctx.Reply(update, ext.ReplyTextString("请选择要设为默认的存储位置"), &ext.ReplyOpts{
Markup: markup,
})
return dispatcher.EndGroups
}

View File

@@ -0,0 +1,76 @@
package handlers
import (
"fmt"
"github.com/celestix/gotgproto/dispatcher"
"github.com/celestix/gotgproto/ext"
"github.com/charmbracelet/log"
"github.com/gotd/td/telegram/message/entity"
"github.com/gotd/td/telegram/message/styling"
"github.com/gotd/td/tg"
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/msgelem"
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/shortcut"
"github.com/krau/SaveAny-Bot/pkg/enums/tasktype"
"github.com/krau/SaveAny-Bot/pkg/tcbdata"
"github.com/krau/SaveAny-Bot/storage"
)
func handleTelegraphUrlMessage(ctx *ext.Context, update *ext.Update) error {
logger := log.FromContext(ctx)
msg, result, err := shortcut.GetTphPicsFromMessageWithReply(ctx, update)
if err != nil {
return err
}
userID := update.GetUserChat().GetID()
stors := storage.GetUserStorages(ctx, userID)
markup, err := msgelem.BuildAddSelectStorageKeyboard(stors, tcbdata.Add{
TaskType: tasktype.TaskTypeTphpics,
TphPageNode: result.Page,
TphDirPath: result.TphDir,
TphPics: result.Pics,
})
if err != nil {
logger.Errorf("构建存储选择键盘失败: %s", err)
ctx.Reply(update, ext.ReplyTextString("构建存储选择键盘失败: "+err.Error()), nil)
return dispatcher.EndGroups
}
eb := entity.Builder{}
if err := styling.Perform(&eb,
styling.Plain("标题: "),
styling.Code(result.Page.Title),
styling.Plain("\n图片数量: "),
styling.Code(fmt.Sprintf("%d", len(result.Pics))),
styling.Plain("\n请选择存储位置"),
); err != nil {
log.FromContext(ctx).Errorf("Failed to build entity: %s", err)
return dispatcher.EndGroups
}
text, entities := eb.Complete()
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
Message: text,
ID: msg.ID,
ReplyMarkup: markup,
Entities: entities,
})
return dispatcher.EndGroups
}
func handleSilentSaveTelegraph(ctx *ext.Context, update *ext.Update) error {
logger := log.FromContext(ctx)
stor := storage.FromContext(ctx)
if stor == nil {
logger.Warn("Context storage is nil")
ctx.Reply(update, ext.ReplyTextString("未找到存储"), nil)
return dispatcher.EndGroups
}
msg, result, err := shortcut.GetTphPicsFromMessageWithReply(ctx, update)
if err != nil {
return err
}
userID := update.GetUserChat().GetID()
return shortcut.CreateAndAddTphTaskWithEdit(ctx, userID, result.Page, result.TphDir, result.Pics, stor, msg.ID)
}

View File

@@ -0,0 +1,12 @@
package mediautil
import "github.com/gotd/td/tg"
func IsSupported(media tg.MessageMediaClass) bool {
switch media.(type) {
case *tg.MessageMediaDocument, *tg.MessageMediaPhoto:
return true
default:
return false
}
}

View File

@@ -0,0 +1,12 @@
package msgelem
import "github.com/gotd/td/tg"
func AlertCallbackAnswer(queryID int64, text string) *tg.MessagesSetBotCallbackAnswerRequest {
return &tg.MessagesSetBotCallbackAnswerRequest{
QueryID: queryID,
Alert: true,
Message: text,
CacheTime: 5,
}
}

View File

@@ -0,0 +1,36 @@
package msgelem
import (
"fmt"
"strings"
"github.com/gotd/td/telegram/message/styling"
"github.com/krau/SaveAny-Bot/database"
)
func BuildDirHelpStyling(dirs []database.Dir) []styling.StyledTextOption {
return []styling.StyledTextOption{
styling.Bold("使用方法: /dir <操作> <参数...>"),
styling.Plain("\n\n可用操作:\n"),
styling.Code("add"),
styling.Plain(" <存储名> <路径> - 添加路径\n"),
styling.Code("del"),
styling.Plain(" <路径ID> - 删除路径\n"),
styling.Plain("\n添加路径示例:\n"),
styling.Code("/dir add local1 path/to/dir"),
styling.Plain("\n\n删除路径示例:\n"),
styling.Code("/dir del 3"),
styling.Plain("\n\n当前已添加的路径:\n"),
styling.Blockquote(func() string {
var sb strings.Builder
for _, dir := range dirs {
sb.WriteString(fmt.Sprintf("%d: ", dir.ID))
sb.WriteString(dir.StorageName)
sb.WriteString(" - ")
sb.WriteString(dir.Path)
sb.WriteString("\n")
}
return sb.String()
}(), true),
}
}

View File

@@ -0,0 +1,32 @@
package msgelem
import (
"fmt"
"strings"
"github.com/gotd/td/telegram/message/styling"
"github.com/krau/SaveAny-Bot/database"
)
func BuildRuleHelpStyling(enabled bool, rules []database.Rule) []styling.StyledTextOption {
return []styling.StyledTextOption{
styling.Bold("使用方法: /rule <操作> <参数...>"),
styling.Bold(fmt.Sprintf("\n当前已%s规则模式", map[bool]string{true: "启用", false: "禁用"}[enabled])),
styling.Plain("\n\n可用操作:\n"),
styling.Code("switch"),
styling.Plain(" - 开关规则模式\n"),
styling.Code("add"),
styling.Plain(" <类型> <数据> <存储名> <路径> - 添加规则\n"),
styling.Code("del"),
styling.Plain(" <规则ID> - 删除规则\n"),
styling.Plain("\n当前已添加的规则:\n"),
styling.Blockquote(func() string {
var sb strings.Builder
for _, rule := range rules {
ruleText := fmt.Sprintf("%s %s %s %s", rule.Type, rule.Data, rule.StorageName, rule.DirPath)
sb.WriteString(fmt.Sprintf("%d: %s\n", rule.ID, ruleText))
}
return sb.String()
}(), true),
}
}

View File

@@ -0,0 +1,15 @@
package msgelem
const (
SaveHelpText = `
使用方法:
1. 使用该命令回复要保存的文件, 可选文件名参数.
示例:
/save custom_file_name.mp4
2. 设置默认存储后, 发送 /save <频道ID/用户名> <消息ID范围> 来批量保存文件. 遵从存储规则, 若未匹配到任何规则则使用默认存储.
示例:
/save @moreacg 114-514
`
)

View File

@@ -0,0 +1,169 @@
package msgelem
import (
"context"
"fmt"
"github.com/charmbracelet/log"
"github.com/gotd/td/telegram/message/entity"
"github.com/gotd/td/telegram/message/styling"
"github.com/gotd/td/tg"
"github.com/krau/SaveAny-Bot/common/cache"
"github.com/krau/SaveAny-Bot/database"
"github.com/krau/SaveAny-Bot/pkg/enums/tasktype"
"github.com/krau/SaveAny-Bot/pkg/tcbdata"
"github.com/krau/SaveAny-Bot/pkg/tfile"
"github.com/krau/SaveAny-Bot/storage"
"github.com/rs/xid"
)
func BuildAddSelectStorageKeyboard(stors []storage.Storage, adddata tcbdata.Add) (*tg.ReplyInlineMarkup, error) {
taskType := adddata.TaskType
if taskType == "" {
if len(adddata.Files) > 0 {
taskType = tasktype.TaskTypeTgfiles
} else if adddata.TphPageNode != nil {
taskType = tasktype.TaskTypeTphpics
} else {
return nil, fmt.Errorf("unknown task type: %s", taskType)
}
}
buttons := make([]tg.KeyboardButtonClass, 0)
for _, storage := range stors {
data := tcbdata.Add{
TaskType: taskType,
SelectedStorName: storage.Name(),
Files: adddata.Files,
AsBatch: len(adddata.Files) > 1,
TphPageNode: adddata.TphPageNode,
TphPics: adddata.TphPics,
TphDirPath: adddata.TphDirPath,
}
dataid := xid.New().String()
err := cache.Set(dataid, data)
if err != nil {
return nil, err
}
buttons = append(buttons, &tg.KeyboardButtonCallback{
Text: storage.Name(),
Data: fmt.Appendf(nil, "%s %s", tcbdata.TypeAdd, dataid),
})
}
markup := &tg.ReplyInlineMarkup{}
for i := 0; i < len(buttons); i += 3 {
row := tg.KeyboardButtonRow{}
row.Buttons = buttons[i:min(i+3, len(buttons))]
markup.Rows = append(markup.Rows, row)
}
return markup, nil
}
func BuildAddOneSelectStorageMessage(ctx context.Context, stors []storage.Storage, file tfile.TGFileMessage, msgId int) (*tg.MessagesEditMessageRequest, error) {
eb := entity.Builder{}
var entities []tg.MessageEntityClass
text := fmt.Sprintf("文件名: %s\n请选择存储位置", file.Name())
if err := styling.Perform(&eb,
styling.Plain("文件名: "),
styling.Code(file.Name()),
styling.Plain("\n请选择存储位置"),
); err != nil {
log.FromContext(ctx).Errorf("Failed to build entity: %s", err)
} else {
text, entities = eb.Complete()
}
markup, err := BuildAddSelectStorageKeyboard(stors, tcbdata.Add{
TaskType: tasktype.TaskTypeTgfiles,
Files: []tfile.TGFileMessage{file},
AsBatch: false,
})
if err != nil {
return nil, fmt.Errorf("failed to build storage keyboard: %w", err)
}
return &tg.MessagesEditMessageRequest{
Message: text,
Entities: entities,
ReplyMarkup: markup,
ID: msgId,
}, nil
}
func BuildSetDefaultStorageMarkup(ctx context.Context, userID int64, stors []storage.Storage) (*tg.ReplyInlineMarkup, error) {
buttons := make([]tg.KeyboardButtonClass, 0)
for _, storage := range stors {
data := tcbdata.SetDefaultStorage{
StorageName: storage.Name(),
}
dataid := xid.New().String()
err := cache.Set(dataid, data)
if err != nil {
return nil, err
}
buttons = append(buttons, &tg.KeyboardButtonCallback{
Text: storage.Name(),
Data: fmt.Appendf(nil, "%s %s", tcbdata.TypeSetDefault, dataid),
})
}
markup := &tg.ReplyInlineMarkup{}
for i := 0; i < len(buttons); i += 3 {
row := tg.KeyboardButtonRow{}
row.Buttons = buttons[i:min(i+3, len(buttons))]
markup.Rows = append(markup.Rows, row)
}
return markup, nil
}
func BuildSetDirKeyboard(dirs []database.Dir, dataid string) (*tg.ReplyInlineMarkup, error) {
data, ok := cache.Get[tcbdata.Add](dataid)
if !ok {
return nil, fmt.Errorf("failed to get data from cache: %s", dataid)
}
if data.DirID != 0 || data.SettedDir {
log.Warnf("Data already has a directory set: %d, %t", data.DirID, data.SettedDir)
return nil, fmt.Errorf("data already has a directory set")
}
buttons := make([]tg.KeyboardButtonClass, 0)
for _, dir := range dirs {
dirDataId := xid.New().String()
dirData := tcbdata.Add{
Files: data.Files,
SelectedStorName: data.SelectedStorName,
AsBatch: data.AsBatch,
DirID: dir.ID,
SettedDir: true,
}
err := cache.Set(dirDataId, dirData)
if err != nil {
return nil, fmt.Errorf("failed to set directory data in cache: %w", err)
}
buttons = append(buttons, &tg.KeyboardButtonCallback{
Text: dir.Path,
Data: fmt.Appendf(nil, "%s %s", tcbdata.TypeAdd, dirDataId),
})
}
dirDefaultDataId := xid.New().String()
dirDefaultData := tcbdata.Add{
Files: data.Files,
SelectedStorName: data.SelectedStorName,
AsBatch: data.AsBatch,
DirID: 0,
SettedDir: true,
}
err := cache.Set(dirDefaultDataId, dirDefaultData)
if err != nil {
return nil, fmt.Errorf("failed to set default directory data in cache: %w", err)
}
buttons = append(buttons, &tg.KeyboardButtonCallback{
Text: "默认",
Data: fmt.Appendf(nil, "%s %s", tcbdata.TypeAdd, dirDefaultDataId),
})
markup := &tg.ReplyInlineMarkup{}
for i := 0; i < len(buttons); i += 3 {
row := tg.KeyboardButtonRow{}
row.Buttons = buttons[i:min(i+3, len(buttons))]
markup.Rows = append(markup.Rows, row)
}
return markup, nil
}

View File

@@ -0,0 +1,33 @@
package msgelem
import (
"context"
"fmt"
"strconv"
"github.com/charmbracelet/log"
"github.com/gotd/td/telegram/message/entity"
"github.com/gotd/td/telegram/message/styling"
"github.com/gotd/td/tg"
)
func BuildTaskAddedEntities(
ctx context.Context,
filename string,
queueLength int,
) (string, []tg.MessageEntityClass) {
entityBuilder := entity.Builder{}
var entities []tg.MessageEntityClass
text := fmt.Sprintf("已添加到任务队列\n文件名: %s\n当前排队任务数: %d", filename, queueLength)
if err := styling.Perform(&entityBuilder,
styling.Plain("已添加到任务队列\n文件名: "),
styling.Code(filename),
styling.Plain("\n当前排队任务数: "),
styling.Bold(strconv.Itoa(queueLength)),
); err != nil {
log.FromContext(ctx).Errorf("Failed to build entity: %s", err)
} else {
text, entities = entityBuilder.Complete()
}
return text, entities
}

View File

@@ -0,0 +1,10 @@
package re
import "regexp"
var (
TgMessageLinkRegexString = `https?://t\.me/(?:c/\d+|[A-Za-z0-9_]+)/\d+(?:/\d+)?(?:\?[^\s#]*[A-Za-z0-9_])?\b`
TgMessageLinkRegexp = regexp.MustCompile(TgMessageLinkRegexString)
TelegraphUrlRegexString = `https://telegra.ph/.*`
TelegraphUrlRegexp = regexp.MustCompile(TelegraphUrlRegexString)
)

View File

@@ -0,0 +1,80 @@
package ruleutil
import (
"context"
"github.com/charmbracelet/log"
"github.com/krau/SaveAny-Bot/database"
"github.com/krau/SaveAny-Bot/pkg/consts"
ruleenum "github.com/krau/SaveAny-Bot/pkg/enums/rule"
"github.com/krau/SaveAny-Bot/pkg/rule"
"github.com/krau/SaveAny-Bot/pkg/tfile"
)
type ruleInput struct {
File tfile.TGFileMessage
}
type ruleInputOption func(*ruleInput)
func NewInput(file tfile.TGFileMessage, opts ...ruleInputOption) *ruleInput {
input := &ruleInput{
File: file,
}
for _, opt := range opts {
opt(input)
}
return input
}
type matchedStorName string
func (m matchedStorName) String() string {
return string(m)
}
func (m matchedStorName) IsValid() bool {
return m != "" && m != consts.RuleStorNameChosen
}
func ApplyRule(ctx context.Context, rules []database.Rule, inputs *ruleInput) (matchedStorageName matchedStorName, dirPath string) {
if inputs == nil || len(rules) == 0 {
return "", ""
}
logger := log.FromContext(ctx)
for _, ur := range rules {
switch ur.Type {
case ruleenum.FileNameRegex.String():
ru, err := rule.NewRuleFileNameRegex(ur.StorageName, ur.DirPath, ur.Data)
if err != nil {
logger.Errorf("Failed to create rule: %s", err)
continue
}
ok, err := ru.Match(inputs.File)
if err != nil {
logger.Errorf("Failed to match rule: %s", err)
continue
}
if ok {
dirPath = ru.StoragePath()
matchedStorageName = matchedStorName(ru.StorageName())
}
case ruleenum.MessageRegex.String():
ru, err := rule.NewRuleMessageRegex(ur.StorageName, ur.DirPath, ur.Data)
if err != nil {
logger.Errorf("Failed to create rule: %s", err)
continue
}
ok, err := ru.Match(inputs.File.Message().GetMessage())
if err != nil {
logger.Errorf("Failed to match rule: %s", err)
continue
}
if ok {
dirPath = ru.StoragePath()
matchedStorageName = matchedStorName(ru.StorageName())
}
}
}
return
}

View File

@@ -0,0 +1,193 @@
// Some shortcuts for duplicate code in handlers, they should return dispatcher errors
package shortcut
import (
"encoding/json"
"net/url"
"strings"
"github.com/celestix/gotgproto/dispatcher"
"github.com/celestix/gotgproto/ext"
"github.com/celestix/gotgproto/types"
"github.com/charmbracelet/log"
"github.com/gotd/td/tg"
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/mediautil"
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/msgelem"
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/re"
"github.com/krau/SaveAny-Bot/common/cache"
"github.com/krau/SaveAny-Bot/common/utils/tgutil"
"github.com/krau/SaveAny-Bot/common/utils/tphutil"
"github.com/krau/SaveAny-Bot/pkg/telegraph"
"github.com/krau/SaveAny-Bot/pkg/tfile"
)
// 获取消息中的文件并回复等待消息, 返回等待消息, 获取到的文件
func GetFileFromMessageWithReply(ctx *ext.Context, update *ext.Update, message *tg.Message, tfileopts ...tfile.TGFileOptions) (replied *types.Message,
file tfile.TGFileMessage, err error,
) {
logger := log.FromContext(ctx)
media := message.Media
supported := mediautil.IsSupported(media)
if !supported {
ctx.Reply(update, ext.ReplyTextString("不支持的消息类型"), nil)
return nil, nil, dispatcher.EndGroups
}
replied, err = ctx.Reply(update, ext.ReplyTextString("正在获取文件信息..."), nil)
if err != nil {
logger.Errorf("Failed to reply: %s", err)
return nil, nil, dispatcher.EndGroups
}
options := []tfile.TGFileOptions{
tfile.WithMessage(message),
}
if len(tfileopts) > 0 {
options = append(options, tfileopts...)
} else {
options = append(options, tfile.WithNameIfEmpty(tgutil.GenFileNameFromMessage(*message)))
}
file, err = tfile.FromMediaMessage(media, message, options...)
if err != nil {
logger.Errorf("Failed to get file from media: %s", err)
ctx.Reply(update, ext.ReplyTextString("获取文件失败: "+err.Error()), nil)
return nil, nil, dispatcher.EndGroups
}
return replied, file, nil
}
type EditMessageFunc func(text string, markup tg.ReplyMarkupClass)
// 获取链接中的文件并回复等待消息
func GetFilesFromUpdateLinkMessageWithReplyEdit(ctx *ext.Context, update *ext.Update) (replied *types.Message, files []tfile.TGFileMessage, editReplied EditMessageFunc, err error) {
logger := log.FromContext(ctx)
msgLinks := re.TgMessageLinkRegexp.FindAllString(update.EffectiveMessage.GetMessage(), -1)
if len(msgLinks) == 0 {
logger.Warn("no matched message links but called handleMessageLink")
return nil, nil, nil, dispatcher.EndGroups
}
replied, err = ctx.Reply(update, ext.ReplyTextString("正在获取消息..."), nil)
if err != nil {
logger.Errorf("failed to reply: %s", err)
return nil, nil, nil, dispatcher.EndGroups
}
editReplied = func(text string, markup tg.ReplyMarkupClass) {
if _, err := ctx.EditMessage(update.EffectiveChat().GetID(), &tg.MessagesEditMessageRequest{
ID: replied.ID,
Message: text,
ReplyMarkup: markup,
}); err != nil {
logger.Errorf("failed to edit message: %s", err)
}
}
files = make([]tfile.TGFileMessage, 0, len(msgLinks))
for _, link := range msgLinks {
chatId, msgId, err := tgutil.ParseMessageLink(ctx, link)
if err != nil {
logger.Errorf("failed to parse message link %s: %s", link, err)
continue
}
msg, err := tgutil.GetMessageByID(ctx, chatId, msgId)
if err != nil {
logger.Errorf("failed to get message by ID: %s", err)
continue
}
media, ok := msg.GetMedia()
if !ok {
logger.Debugf("message %d has no media", msg.GetID())
continue
}
file, err := tfile.FromMediaMessage(media, msg, tfile.WithNameIfEmpty(tgutil.GenFileNameFromMessage(*msg)))
if err != nil {
logger.Errorf("failed to create file from media: %s", err)
continue
}
files = append(files, file)
}
if len(files) == 0 {
editReplied("没有找到可保存的文件", nil)
return nil, nil, nil, dispatcher.EndGroups
}
return replied, files, editReplied, nil
}
func GetCallbackDataWithAnswer[DataType any](ctx *ext.Context, update *ext.Update, dataid string) (DataType, error) {
data, ok := cache.Get[DataType](dataid)
if !ok {
log.FromContext(ctx).Warnf("Invalid data ID: %s", dataid)
queryID := update.CallbackQuery.GetQueryID()
ctx.AnswerCallback(msgelem.AlertCallbackAnswer(queryID, "数据已过期或无效"))
var zero DataType
return zero, dispatcher.EndGroups
}
return data, nil
}
type TelegraphResult struct {
Pics []string `json:"pics"` // image urls
TphDir string `json:"tph_dir"` // telegraph path, unescaped
Page *telegraph.Page `json:"page"` // telegraph page node
}
// return replied message, image urls, telegraph path(unescaped), error
func GetTphPicsFromMessageWithReply(ctx *ext.Context, update *ext.Update) (*types.Message, *TelegraphResult, error) {
logger := log.FromContext(ctx)
tphurl := re.TelegraphUrlRegexp.FindString(update.EffectiveMessage.GetMessage()) // TODO: batch urls
if tphurl == "" {
logger.Warnf("No telegraph url found but called handleTelegraph")
return nil, nil, dispatcher.ContinueGroups
}
pagepath := strings.Split(tphurl, "/")[len(strings.Split(tphurl, "/"))-1]
tphdir, err := url.PathUnescape(pagepath)
if err != nil {
logger.Errorf("Failed to unescape telegraph path: %s", err)
ctx.Reply(update, ext.ReplyTextString("解析 telegraph 路径失败: "+err.Error()), nil)
return nil, nil, dispatcher.EndGroups
}
msg, err := ctx.Reply(update, ext.ReplyTextString("正在获取 telegraph 页面..."), nil)
if err != nil {
logger.Errorf("Failed to reply to update: %s", err)
return nil, nil, dispatcher.EndGroups
}
page, err := tphutil.DefaultClient().GetPage(ctx, pagepath)
if err != nil {
logger.Errorf("Failed to get telegraph page: %s", err)
ctx.Reply(update, ext.ReplyTextString("获取 telegraph 页面失败: "+err.Error()), nil)
return nil, nil, dispatcher.EndGroups
}
imgs := make([]string, 0)
for _, elem := range page.Content {
var node telegraph.NodeElement
data, err := json.Marshal(elem)
if err != nil {
logger.Errorf("Failed to marshal element: %s", err)
continue
}
err = json.Unmarshal(data, &node)
if err != nil {
logger.Errorf("Failed to unmarshal element: %s", err)
continue
}
if len(node.Children) != 0 {
for _, child := range node.Children {
imgs = append(imgs, tphutil.GetNodeImages(child)...)
}
}
if node.Tag == "img" {
if src, ok := node.Attrs["src"]; ok {
imgs = append(imgs, src)
}
}
}
if len(imgs) == 0 {
logger.Warn("No images found in telegraph page")
ctx.Reply(update, ext.ReplyTextString("在 telegraph 页面中未找到图片"), nil)
return nil, nil, dispatcher.EndGroups
}
return msg, &TelegraphResult{
Pics: imgs,
TphDir: tphdir,
Page: page,
}, nil
}

View File

@@ -0,0 +1,152 @@
package shortcut
import (
"fmt"
"path"
"github.com/celestix/gotgproto/dispatcher"
"github.com/celestix/gotgproto/ext"
"github.com/charmbracelet/log"
"github.com/gotd/td/tg"
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/msgelem"
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/ruleutil"
"github.com/krau/SaveAny-Bot/common/utils/tgutil"
"github.com/krau/SaveAny-Bot/core"
"github.com/krau/SaveAny-Bot/core/batchtftask"
"github.com/krau/SaveAny-Bot/core/tftask"
"github.com/krau/SaveAny-Bot/database"
"github.com/krau/SaveAny-Bot/pkg/tfile"
"github.com/krau/SaveAny-Bot/storage"
"github.com/rs/xid"
)
// 创建一个 tftask.TGFileTask 并添加到任务队列中, 以编辑消息的方式反馈结果
func CreateAndAddTGFileTaskWithEdit(ctx *ext.Context, userID int64, stor storage.Storage, dirPath string, file tfile.TGFileMessage, trackMsgID int) error {
logger := log.FromContext(ctx)
user, err := database.GetUserByChatID(ctx, userID)
if err != nil {
logger.Errorf("Failed to get user by chat ID: %s", err)
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
ID: trackMsgID,
Message: "获取用户失败: " + err.Error(),
})
return dispatcher.EndGroups
}
if user.ApplyRule && user.Rules != nil {
matchedStorageName, matchedDirPath := ruleutil.ApplyRule(ctx, user.Rules, ruleutil.NewInput(file))
dirPath = matchedDirPath
if matchedStorageName.IsValid() {
stor, err = storage.GetStorageByUserIDAndName(ctx, user.ChatID, matchedStorageName.String())
if err != nil {
logger.Errorf("Failed to get storage by user ID and name: %s", err)
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
ID: trackMsgID,
Message: "获取存储失败: " + err.Error(),
})
return dispatcher.EndGroups
}
}
}
storagePath := stor.JoinStoragePath(path.Join(dirPath, file.Name()))
injectCtx := tgutil.ExtWithContext(ctx.Context, ctx)
taskid := xid.New().String()
task, err := tftask.NewTGFileTask(taskid, injectCtx, file, ctx.Raw, stor, storagePath,
tftask.NewProgressTrack(
trackMsgID,
userID))
if err != nil {
logger.Errorf("create task failed: %s", err)
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
ID: trackMsgID,
Message: "创建任务失败: " + err.Error(),
})
return dispatcher.EndGroups
}
if err := core.AddTask(injectCtx, task); err != nil {
logger.Errorf("add task failed: %s", err)
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
ID: trackMsgID,
Message: "添加任务失败: " + err.Error(),
})
return dispatcher.EndGroups
}
text, entities := msgelem.BuildTaskAddedEntities(ctx, file.Name(), core.GetLength(injectCtx))
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
ID: trackMsgID,
Message: text,
Entities: entities,
})
return dispatcher.EndGroups
}
// 创建一个 batchtftask.BatchTGFileTask 并添加到任务队列中, 以编辑消息的方式反馈结果
func CreateAndAddBatchTGFileTaskWithEdit(ctx *ext.Context, userID int64, stor storage.Storage, dirPath string, files []tfile.TGFileMessage, trackMsgID int) error {
logger := log.FromContext(ctx)
user, err := database.GetUserByChatID(ctx, userID)
if err != nil {
logger.Errorf("Failed to get user by chat ID: %s", err)
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
ID: trackMsgID,
Message: "获取用户失败: " + err.Error(),
})
return dispatcher.EndGroups
}
useRule := user.ApplyRule && user.Rules != nil
applyRule := func(file tfile.TGFileMessage) (string, string) {
if !useRule {
return stor.Name(), dirPath
}
storName, dirP := ruleutil.ApplyRule(ctx, user.Rules, ruleutil.NewInput(file))
if !storName.IsValid() {
return stor.Name(), dirP
}
return storName.String(), dirP
}
elems := make([]batchtftask.TaskElement, 0, len(files))
for _, file := range files {
storName, dirPath := applyRule(file)
fileStor := stor
if storName != stor.Name() && storName != "" {
fileStor, err = storage.GetStorageByUserIDAndName(ctx, user.ChatID, storName)
if err != nil {
logger.Errorf("Failed to get storage by user ID and name: %s", err)
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
ID: trackMsgID,
Message: "获取存储失败: " + err.Error(),
})
return dispatcher.EndGroups
}
}
storPath := fileStor.JoinStoragePath(path.Join(dirPath, file.Name()))
elem, err := batchtftask.NewTaskElement(fileStor, storPath, file)
if err != nil {
logger.Errorf("Failed to create task element: %s", err)
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
ID: trackMsgID,
Message: "任务创建失败: " + err.Error(),
})
return dispatcher.EndGroups
}
elems = append(elems, *elem)
}
injectCtx := tgutil.ExtWithContext(ctx.Context, ctx)
taskid := xid.New().String()
task := batchtftask.NewBatchTGFileTask(taskid, injectCtx, elems, ctx.Raw, batchtftask.NewProgressTracker(trackMsgID, userID), true)
if err := core.AddTask(injectCtx, task); err != nil {
logger.Errorf("Failed to add batch task: %s", err)
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
ID: trackMsgID,
Message: "批量任务添加失败: " + err.Error(),
})
return dispatcher.EndGroups
}
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
ID: trackMsgID,
Message: fmt.Sprintf("已添加批量任务, 共 %d 个文件", len(files)),
ReplyMarkup: nil,
})
return dispatcher.EndGroups
}

View File

@@ -0,0 +1,50 @@
package shortcut
import (
"github.com/celestix/gotgproto/dispatcher"
"github.com/celestix/gotgproto/ext"
"github.com/charmbracelet/log"
"github.com/gotd/td/tg"
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/msgelem"
"github.com/krau/SaveAny-Bot/common/utils/tgutil"
"github.com/krau/SaveAny-Bot/common/utils/tphutil"
"github.com/krau/SaveAny-Bot/core"
"github.com/krau/SaveAny-Bot/core/tphtask"
"github.com/krau/SaveAny-Bot/pkg/telegraph"
"github.com/krau/SaveAny-Bot/storage"
"github.com/rs/xid"
)
func CreateAndAddTphTaskWithEdit(ctx *ext.Context,
userID int64,
tphpage *telegraph.Page,
dirPath string, // unescaped ph path for file storage
pics []string,
stor storage.Storage,
trackMsgID int) error {
injectCtx := tgutil.ExtWithContext(ctx.Context, ctx)
task := tphtask.NewTask(xid.New().String(),
injectCtx,
tphpage.Path,
pics,
stor,
stor.JoinStoragePath(dirPath),
tphutil.DefaultClient(),
tphtask.NewProgress(trackMsgID, userID),
)
if err := core.AddTask(injectCtx, task); err != nil {
log.FromContext(ctx).Errorf("Failed to add task: %s", err)
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
ID: trackMsgID,
Message: "任务添加失败: " + err.Error(),
})
return dispatcher.EndGroups
}
text, entities := msgelem.BuildTaskAddedEntities(ctx, tphpage.Title, core.GetLength(ctx))
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
ID: trackMsgID,
Message: text,
Entities: entities,
})
return dispatcher.EndGroups
}

View File

@@ -1,4 +1,4 @@
package middlewares package middleware
import ( import (
"context" "context"
@@ -7,10 +7,11 @@ import (
"github.com/cenkalti/backoff/v4" "github.com/cenkalti/backoff/v4"
"github.com/gotd/contrib/middleware/floodwait" "github.com/gotd/contrib/middleware/floodwait"
"github.com/gotd/td/telegram" "github.com/gotd/td/telegram"
"github.com/krau/SaveAny-Bot/userclient/middlewares/recovery" "github.com/krau/SaveAny-Bot/client/middleware/recovery"
"github.com/krau/SaveAny-Bot/userclient/middlewares/retry" "github.com/krau/SaveAny-Bot/client/middleware/retry"
) )
// https://github.com/iyear/tdl/blob/master/core/tclient/tclient.go
func NewDefaultMiddlewares(ctx context.Context, timeout time.Duration) []telegram.Middleware { func NewDefaultMiddlewares(ctx context.Context, timeout time.Duration) []telegram.Middleware {
return []telegram.Middleware{ return []telegram.Middleware{
recovery.New(ctx, newBackoff(timeout)), recovery.New(ctx, newBackoff(timeout)),
@@ -21,7 +22,6 @@ func NewDefaultMiddlewares(ctx context.Context, timeout time.Duration) []telegra
func newBackoff(timeout time.Duration) backoff.BackOff { func newBackoff(timeout time.Duration) backoff.BackOff {
b := backoff.NewExponentialBackOff() b := backoff.NewExponentialBackOff()
b.Multiplier = 1.1 b.Multiplier = 1.1
b.MaxElapsedTime = timeout b.MaxElapsedTime = timeout
b.MaxInterval = 10 * time.Second b.MaxInterval = 10 * time.Second

View File

@@ -0,0 +1,19 @@
package middleware
import (
"time"
"github.com/gotd/contrib/middleware/floodwait"
"github.com/gotd/contrib/middleware/ratelimit"
"github.com/gotd/td/telegram"
"golang.org/x/time/rate"
)
func NewFloodWaitMiddlewares(maxRetries uint) []telegram.Middleware {
waiter := floodwait.NewSimpleWaiter().WithMaxRetries(maxRetries)
ratelimiter := ratelimit.New(rate.Every(time.Millisecond*100), 5)
return []telegram.Middleware{
waiter,
ratelimiter,
}
}

View File

@@ -5,12 +5,12 @@ import (
"time" "time"
"github.com/cenkalti/backoff/v4" "github.com/cenkalti/backoff/v4"
"github.com/charmbracelet/log"
"github.com/go-faster/errors" "github.com/go-faster/errors"
"github.com/gotd/td/bin" "github.com/gotd/td/bin"
"github.com/gotd/td/telegram" "github.com/gotd/td/telegram"
"github.com/gotd/td/tg" "github.com/gotd/td/tg"
"github.com/gotd/td/tgerr" "github.com/gotd/td/tgerr"
"github.com/krau/SaveAny-Bot/common"
) )
type recovery struct { type recovery struct {
@@ -39,7 +39,7 @@ func (r *recovery) Handle(next tg.Invoker) telegram.InvokeFunc {
return nil return nil
}, r.backoff, func(err error, duration time.Duration) { }, r.backoff, func(err error, duration time.Duration) {
common.Log.Debug("Wait for connection recovery", "error", err, "duration", duration) log.FromContext(ctx).Debug("Wait for connection recovery", "error", err, "duration", duration)
}) })
} }
} }

View File

@@ -4,12 +4,12 @@ import (
"context" "context"
"fmt" "fmt"
"github.com/charmbracelet/log"
"github.com/go-faster/errors" "github.com/go-faster/errors"
"github.com/gotd/td/bin" "github.com/gotd/td/bin"
"github.com/gotd/td/telegram" "github.com/gotd/td/telegram"
"github.com/gotd/td/tg" "github.com/gotd/td/tg"
"github.com/gotd/td/tgerr" "github.com/gotd/td/tgerr"
"github.com/krau/SaveAny-Bot/common"
) )
var internalErrors = []string{ var internalErrors = []string{
@@ -33,7 +33,7 @@ func (r retry) Handle(next tg.Invoker) telegram.InvokeFunc {
for retries < r.max { for retries < r.max {
if err := next.Invoke(ctx, input, output); err != nil { if err := next.Invoke(ctx, input, output); err != nil {
if tgerr.Is(err, r.errors...) { if tgerr.Is(err, r.errors...) {
common.Log.Debug("retry middleware", "retries", retries, "error", err) log.FromContext(ctx).Debug("retry middleware", "retries", retries, "error", err)
retries++ retries++
continue continue
} }

View File

@@ -1,4 +1,4 @@
package userclient package user
import ( import (
"strings" "strings"

View File

@@ -1,4 +1,4 @@
package userclient package user
import ( import (
"context" "context"
@@ -7,10 +7,10 @@ import (
"github.com/celestix/gotgproto" "github.com/celestix/gotgproto"
"github.com/celestix/gotgproto/ext" "github.com/celestix/gotgproto/ext"
"github.com/celestix/gotgproto/sessionMaker" "github.com/celestix/gotgproto/sessionMaker"
"github.com/glebarez/sqlite" "github.com/charmbracelet/log"
"github.com/krau/SaveAny-Bot/common" "github.com/krau/SaveAny-Bot/client/middleware"
"github.com/krau/SaveAny-Bot/config" "github.com/krau/SaveAny-Bot/config"
"github.com/krau/SaveAny-Bot/userclient/middlewares" "github.com/ncruces/go-sqlite3/gormlite"
) )
var UC *gotgproto.Client var UC *gotgproto.Client
@@ -26,7 +26,7 @@ func GetCtx() *ext.Context {
} }
func Login(ctx context.Context) (*gotgproto.Client, error) { func Login(ctx context.Context) (*gotgproto.Client, error) {
common.Log.Debug("Logging in as user client") log.FromContext(ctx).Debug("Logging in as user client")
if UC != nil { if UC != nil {
return UC, nil return UC, nil
} }
@@ -40,11 +40,11 @@ func Login(ctx context.Context) (*gotgproto.Client, error) {
config.Cfg.Telegram.AppHash, config.Cfg.Telegram.AppHash,
gotgproto.ClientTypePhone(""), gotgproto.ClientTypePhone(""),
&gotgproto.ClientOpts{ &gotgproto.ClientOpts{
Session: sessionMaker.SqlSession(sqlite.Open(config.Cfg.Telegram.Userbot.Session)), Session: sessionMaker.SqlSession(gormlite.Open(config.Cfg.Telegram.Userbot.Session)),
AuthConversator: &termialAuthConversator{}, AuthConversator: &termialAuthConversator{},
// Context: ctx, Context: ctx,
DisableCopyright: true, DisableCopyright: true,
Middlewares: middlewares.NewDefaultMiddlewares(ctx, 5*time.Minute), Middlewares: middleware.NewDefaultMiddlewares(ctx, 5*time.Minute),
}, },
) )
if err != nil { if err != nil {
@@ -70,10 +70,6 @@ func Login(ctx context.Context) (*gotgproto.Client, error) {
return nil, r.err return nil, r.err
} }
UC = r.client UC = r.client
// disp := UC.Dispatcher
// disp.AddHandler(handlers.NewAnyUpdate(func(ctx *ext.Context, u *ext.Update) error {
// return dispatcher.EndGroups
// }))
return UC, nil return UC, nil
} }
} }

View File

@@ -1,4 +1,4 @@
// cmd/gen_i18n/main.go // cmd/geni18n/main.go
package main package main
import ( import (
@@ -14,8 +14,8 @@ import (
) )
func main() { func main() {
dir := flag.String("dir", "./i18n/locale", "Locales directory path") dir := flag.String("dir", "./common/i18n/locale", "Locales directory path")
out := flag.String("out", "i18n/i18nk/keys.go", "Output file path") out := flag.String("out", "common/i18n/i18nk/keys.go", "Output file path")
pkg := flag.String("pkg", "i18nk", "Package name for generated file") pkg := flag.String("pkg", "i18nk", "Package name for generated file")
flag.Parse() flag.Parse()

View File

@@ -1,6 +1,7 @@
package cmd package cmd
import ( import (
"context"
"fmt" "fmt"
"github.com/spf13/cobra" "github.com/spf13/cobra"
@@ -12,8 +13,8 @@ var rootCmd = &cobra.Command{
Run: Run, Run: Run,
} }
func Execute() { func Execute(ctx context.Context) {
if err := rootCmd.Execute(); err != nil { if err := rootCmd.ExecuteContext(ctx); err != nil {
fmt.Println(err) fmt.Println(err)
} }
} }

View File

@@ -4,46 +4,78 @@ import (
"context" "context"
"fmt" "fmt"
"os" "os"
"os/signal"
"path/filepath" "path/filepath"
"syscall" "time"
"slices" "slices"
"github.com/krau/SaveAny-Bot/bot" "github.com/charmbracelet/log"
"github.com/krau/SaveAny-Bot/common" "github.com/krau/SaveAny-Bot/client/bot"
userclient "github.com/krau/SaveAny-Bot/client/user"
"github.com/krau/SaveAny-Bot/common/i18n"
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
"github.com/krau/SaveAny-Bot/common/utils/fsutil"
"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/database"
"github.com/krau/SaveAny-Bot/i18n"
"github.com/krau/SaveAny-Bot/i18n/i18nk"
"github.com/krau/SaveAny-Bot/storage" "github.com/krau/SaveAny-Bot/storage"
"github.com/krau/SaveAny-Bot/userclient"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
func Run(_ *cobra.Command, _ []string) { func Run(cmd *cobra.Command, _ []string) {
InitAll() ctx := cmd.Context()
core.Run() logger := log.NewWithOptions(os.Stdout, log.Options{
Level: log.DebugLevel,
ReportTimestamp: true,
TimeFormat: time.TimeOnly,
ReportCaller: true,
})
ctx = log.WithContext(ctx, logger)
quit := make(chan os.Signal, 1) initAll(ctx)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) core.Run(ctx)
sig := <-quit
common.Log.Info(sig, i18n.T(i18nk.Exiting)) <-ctx.Done()
defer common.Log.Info(i18n.T(i18nk.Bye)) logger.Info(i18n.T(i18nk.Exiting))
defer logger.Info(i18n.T(i18nk.Bye))
cleanCache()
}
func initAll(ctx context.Context) {
if err := config.Init(ctx); err != nil {
fmt.Println("Failed to load config:", err)
os.Exit(1)
}
logger := log.FromContext(ctx)
i18n.Init(config.Cfg.Lang)
logger.Info(i18n.T(i18nk.Initing))
if config.Cfg.Telegram.Userbot.Enable {
uc, err := userclient.Login(ctx)
if err != nil {
logger.Fatalf("User client login failed: %s", err)
}
logger.Infof("User client logged in as %s", uc.Self.FirstName)
}
database.Init(ctx)
storage.LoadStorages(ctx)
bot.Init(ctx)
}
func cleanCache() {
if config.Cfg.NoCleanCache { if config.Cfg.NoCleanCache {
return return
} }
if config.Cfg.Temp.BasePath != "" && !config.Cfg.Stream { if config.Cfg.Temp.BasePath != "" && !config.Cfg.Stream {
if slices.Contains([]string{"/", ".", "\\", ".."}, filepath.Clean(config.Cfg.Temp.BasePath)) { if slices.Contains([]string{"/", ".", "\\", ".."}, filepath.Clean(config.Cfg.Temp.BasePath)) {
common.Log.Error(i18n.T(i18nk.InvalidCacheDir, map[string]any{ log.Error(i18n.T(i18nk.InvalidCacheDir, map[string]any{
"Path": config.Cfg.Temp.BasePath, "Path": config.Cfg.Temp.BasePath,
})) }))
return return
} }
currentDir, err := os.Getwd() currentDir, err := os.Getwd()
if err != nil { if err != nil {
common.Log.Error(i18n.T(i18nk.GetWorkdirFailed, map[string]any{ log.Error(i18n.T(i18nk.GetWorkdirFailed, map[string]any{
"Error": err, "Error": err,
})) }))
return return
@@ -51,42 +83,18 @@ func Run(_ *cobra.Command, _ []string) {
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 {
common.Log.Error(i18n.T(i18nk.GetCacheAbsPathFailed, map[string]any{ log.Error(i18n.T(i18nk.GetCacheAbsPathFailed, map[string]any{
"Error": err, "Error": err,
})) }))
return return
} }
common.Log.Info(i18n.T(i18nk.CleaningCache, map[string]any{ log.Info(i18n.T(i18nk.CleaningCache, map[string]any{
"Path": cachePath, "Path": cachePath,
})) }))
if err := common.RemoveAllInDir(cachePath); err != nil { if err := fsutil.RemoveAllInDir(cachePath); err != nil {
common.Log.Error(i18n.T(i18nk.CleanCacheFailed, map[string]any{ log.Error(i18n.T(i18nk.CleanCacheFailed, map[string]any{
"Error": err, "Error": err,
})) }))
} }
} }
} }
func InitAll() {
if err := config.Init(); err != nil {
fmt.Println("Failed to load config:", err)
os.Exit(1)
}
common.InitLogger()
i18n.Init(config.Cfg.Lang)
common.Log.Info(i18n.T(i18nk.Initing))
dao.Init()
storage.LoadStorages()
common.Init()
if config.Cfg.Telegram.Userbot.Enable {
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer cancel()
uc, err := userclient.Login(ctx)
if err != nil {
common.Log.Errorf("User client login failed: %s", err)
os.Exit(1)
}
common.Log.Infof("User client logged in as %s", uc.Self.FirstName)
}
bot.Init()
}

View File

@@ -4,7 +4,7 @@ import (
"fmt" "fmt"
"runtime" "runtime"
"github.com/krau/SaveAny-Bot/common" "github.com/krau/SaveAny-Bot/pkg/consts"
"github.com/rhysd/go-github-selfupdate/selfupdate" "github.com/rhysd/go-github-selfupdate/selfupdate"
"github.com/blang/semver" "github.com/blang/semver"
@@ -16,7 +16,7 @@ var VersionCmd = &cobra.Command{
Aliases: []string{"v"}, Aliases: []string{"v"},
Short: "Print the version number of saveany-bot", Short: "Print the version number of saveany-bot",
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
fmt.Printf("saveany-bot version: %s %s/%s\nBuildTime: %s, Commit: %s\n", common.Version, runtime.GOOS, runtime.GOARCH, common.BuildTime, common.GitCommit) fmt.Printf("saveany-bot version: %s %s/%s\nBuildTime: %s, Commit: %s\n", consts.Version, runtime.GOOS, runtime.GOARCH, consts.BuildTime, consts.GitCommit)
}, },
} }
@@ -25,14 +25,14 @@ var upgradeCmd = &cobra.Command{
Aliases: []string{"up"}, Aliases: []string{"up"},
Short: "Upgrade saveany-bot to the latest version", Short: "Upgrade saveany-bot to the latest version",
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
v := semver.MustParse(common.Version) v := semver.MustParse(consts.Version)
latest, err := selfupdate.UpdateSelf(v, "krau/SaveAny-Bot") latest, err := selfupdate.UpdateSelf(v, "krau/SaveAny-Bot")
if err != nil { if err != nil {
fmt.Println("Binary update failed:", err) fmt.Println("Binary update failed:", err)
return return
} }
if latest.Version.Equals(v) { if latest.Version.Equals(v) {
fmt.Println("Current binary is the latest version", common.Version) fmt.Println("Current binary is the latest version", consts.Version)
} else { } else {
fmt.Println("Successfully updated to version", latest.Version) fmt.Println("Successfully updated to version", latest.Version)
fmt.Println("Release note:\n", latest.ReleaseNotes) fmt.Println("Release note:\n", latest.ReleaseNotes)

View File

@@ -1,38 +0,0 @@
package common
import (
"context"
"time"
"github.com/eko/gocache/lib/v4/cache"
gocachestore "github.com/eko/gocache/store/go_cache/v4"
gocache "github.com/patrickmn/go-cache"
)
var Cache *cache.Cache[any]
func initCache() {
gocacheClient := gocache.New(time.Hour*1, time.Minute*10)
gocacheStore := gocachestore.NewGoCache(gocacheClient)
cacheManager := cache.New[any](gocacheStore)
Cache = cacheManager
}
func CacheGet[T any](ctx context.Context, key string) (T, error) {
data, err := Cache.Get(ctx, key)
if err != nil {
return *new(T), err
}
if v, ok := data.(T); ok {
return v, nil
}
return *new(T), nil
}
func CacheSet(ctx context.Context, key string, value any) error {
return Cache.Set(ctx, key, value)
}
func CacheDelete(ctx context.Context, key string) error {
return Cache.Delete(ctx, key)
}

50
common/cache/ristretto.go vendored Normal file
View File

@@ -0,0 +1,50 @@
package cache
import (
"fmt"
"github.com/charmbracelet/log"
"github.com/dgraph-io/ristretto/v2"
)
var cache *ristretto.Cache[string, any]
// TODO: maybe we should use simple ttl cache instead of ristretto...
func init() {
c, err := ristretto.NewCache(&ristretto.Config[string, any]{
NumCounters: 1e5,
MaxCost: 1e6, // 1000000 / 112 ≈ 8928
BufferItems: 64,
OnReject: func(item *ristretto.Item[any]) {
log.Warnf("Cache item rejected: key=%d, value=%v", item.Key, item.Value)
},
})
if err != nil {
log.Fatalf("failed to create ristretto cache: %v", err)
}
cache = c
}
func Set(key string, value any) error {
ok := cache.Set(key, value, 0)
if !ok {
return fmt.Errorf("failed to set value in cache")
}
cache.Wait()
return nil
}
func Get[T any](key string) (T, bool) {
v, ok := cache.Get(key)
if !ok {
var zero T
return zero, false
}
vT, ok := v.(T)
if !ok {
var zero T
return zero, false
}
return vT, true
}

View File

@@ -1,5 +0,0 @@
package common
func Init() {
initCache()
}

View File

@@ -1,43 +0,0 @@
package common
import (
"github.com/gookit/slog"
"github.com/gookit/slog/handler"
"github.com/gookit/slog/rotatefile"
"github.com/krau/SaveAny-Bot/config"
)
var Log *slog.Logger
func InitLogger() {
if Log != nil {
return
}
Log = slog.New()
logLevel := slog.LevelByName(config.Cfg.Log.Level)
logFilePath := config.Cfg.Log.File
logBackupNum := config.Cfg.Log.BackupCount
var logLevels []slog.Level
for _, level := range slog.AllLevels {
if level <= logLevel {
logLevels = append(logLevels, level)
}
}
tem := "[{{datetime}}] [{{level}}] [{{caller}}] {{message}} {{data}} {{extra}}\n"
consoleH := handler.NewConsoleHandler(logLevels)
consoleH.Formatter().(*slog.TextFormatter).SetTemplate(tem)
Log.AddHandler(consoleH)
if logFilePath != "" && logBackupNum > 0 {
fileH, err := handler.NewTimeRotateFile(
logFilePath,
rotatefile.EveryDay,
handler.WithLogLevels(slog.AllLevels),
handler.WithBackupNum(logBackupNum),
)
fileH.Formatter().(*slog.TextFormatter).SetTemplate(tem)
if err != nil {
panic(err)
}
Log.AddHandler(fileH)
}
}

View File

@@ -1,48 +0,0 @@
package common
import (
"os"
"path/filepath"
"time"
"github.com/krau/SaveAny-Bot/i18n"
"github.com/krau/SaveAny-Bot/i18n/i18nk"
)
func RmFileAfter(path string, td time.Duration) {
_, err := os.Stat(path)
if err != nil {
Log.Errorf(i18n.T(i18nk.CreateRmTimerFailed, map[string]any{
"Path": path,
"Error": err,
}))
return
}
Log.Debugf(i18n.T(i18nk.RemoveFileAfter, map[string]any{
"Duration": td.String(),
"Path": path,
}))
time.AfterFunc(td, func() {
if err := os.Remove(path); err != nil {
Log.Errorf(i18n.T(i18nk.RemoveFileFailed, map[string]any{
"Path": path,
"Error": err,
}))
}
})
}
// 删除目录下的所有内容, 但不删除目录本身
func RemoveAllInDir(dirPath string) error {
entries, err := os.ReadDir(dirPath)
if err != nil {
return err
}
for _, entry := range entries {
entryPath := filepath.Join(dirPath, entry.Name())
if err := os.RemoveAll(entryPath); err != nil {
return err
}
}
return nil
}

18
common/tdler/dler.go Normal file
View File

@@ -0,0 +1,18 @@
package tdler
import (
"github.com/gotd/td/telegram/downloader"
"github.com/krau/SaveAny-Bot/common/utils/dlutil"
"github.com/krau/SaveAny-Bot/config"
"github.com/krau/SaveAny-Bot/pkg/consts/tglimit"
"github.com/krau/SaveAny-Bot/pkg/tfile"
)
type Client interface {
downloader.Client
}
func NewDownloader(client Client, file tfile.TGFile) *downloader.Builder {
return downloader.NewDownloader().WithPartSize(tglimit.MaxPartSize).
Download(client, file.Location()).WithThreads(dlutil.BestThreads(file.Size(), config.Cfg.Threads))
}

View File

@@ -1,26 +0,0 @@
package common
import (
"crypto/md5"
"encoding/hex"
"regexp"
)
func HashString(s string) string {
hash := md5.New()
hash.Write([]byte(s))
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
}

33
common/utils/dlutil/dl.go Normal file
View File

@@ -0,0 +1,33 @@
package dlutil
import "time"
var threadsLevels = []struct {
threads int
size int64
}{
{1, 10 << 20},
{2, 50 << 20},
{4, 200 << 20},
{8, 500 << 20},
}
func BestThreads(size int64, max int) int {
for _, thread := range threadsLevels {
if size < thread.size {
return min(thread.threads, max)
}
}
return max
}
func GetSpeed(downloaded int64, startTime time.Time) float64 {
if startTime.IsZero() {
return 0
}
elapsed := time.Since(startTime).Seconds()
if elapsed <= 0 {
return 0
}
return float64(downloaded) / elapsed
}

57
common/utils/fsutil/fs.go Normal file
View File

@@ -0,0 +1,57 @@
package fsutil
import (
"os"
"path/filepath"
"github.com/gabriel-vasile/mimetype"
)
// 删除文件夹内的所有文件和子目录, 但不删除文件夹本身
func RemoveAllInDir(dirPath string) error {
entries, err := os.ReadDir(dirPath)
if err != nil {
return err
}
for _, entry := range entries {
entryPath := filepath.Join(dirPath, entry.Name())
if err := os.RemoveAll(entryPath); err != nil {
return err
}
}
return nil
}
func DetectFileExt(fp string) string {
mt, err := mimetype.DetectFile(fp)
if err != nil {
return ""
}
return mt.Extension()
}
type File struct {
*os.File
}
func (f *File) Remove() error {
return os.Remove(f.Name())
}
func (f *File) CloseAndRemove() error {
if err := f.Close(); err != nil {
return err
}
return f.Remove()
}
func CreateFile(fp string) (*File, error) {
if err := os.MkdirAll(filepath.Dir(fp), os.ModePerm); err != nil {
return nil, err
}
file, err := os.Create(fp)
if err != nil {
return nil, err
}
return &File{File: file}, nil
}

View File

@@ -0,0 +1,49 @@
package ioutil
import "io"
type ProgressWriterAt struct {
wrAt io.WriterAt
onWrite func(n int)
}
func (p *ProgressWriterAt) WriteAt(buf []byte, off int64) (n int, err error) {
n, err = p.wrAt.WriteAt(buf, off)
if n > 0 {
p.onWrite(n)
}
return
}
func NewProgressWriterAt(
wrAt io.WriterAt,
onWrite func(n int),
) *ProgressWriterAt {
return &ProgressWriterAt{
wrAt: wrAt,
onWrite: onWrite,
}
}
type ProgressWriter struct {
wr io.Writer
onWrite func(n int)
}
func (p *ProgressWriter) Write(buf []byte) (n int, err error) {
n, err = p.wr.Write(buf)
if n > 0 {
p.onWrite(n)
}
return
}
func NewProgressWriter(
wr io.Writer,
onWrite func(n int),
) *ProgressWriter {
return &ProgressWriter{
wr: wr,
onWrite: onWrite,
}
}

View File

@@ -0,0 +1,50 @@
package strutil
import (
"crypto/md5"
"encoding/hex"
"fmt"
"regexp"
"strconv"
"strings"
"github.com/duke-git/lancet/v2/slice"
)
func HashString(s string) string {
hash := md5.New()
hash.Write([]byte(s))
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 slice.Compact(tags)
}
func ParseIntStrRange(input string, sep string) (int64, int64, error) {
parts := strings.Split(input, sep)
if len(parts) != 2 {
return 0, 0, fmt.Errorf("invalid range format: %s", input)
}
min, err := strconv.ParseInt(strings.TrimSpace(parts[0]), 10, 64)
if err != nil {
return 0, 0, fmt.Errorf("invalid minimum value: %s", parts[0])
}
max, err := strconv.ParseInt(strings.TrimSpace(parts[1]), 10, 64)
if err != nil {
return 0, 0, fmt.Errorf("invalid maximum value: %s", parts[1])
}
if min > max {
min, max = max, min
}
return min, max, nil
}

View File

@@ -0,0 +1,22 @@
package tgutil
import (
"context"
"github.com/celestix/gotgproto/ext"
)
type contextKey struct{}
var extKey = contextKey{}
func ExtFromContext(ctx context.Context) *ext.Context {
if extCtx, ok := ctx.Value(extKey).(*ext.Context); ok {
return extCtx
}
return nil
}
func ExtWithContext(ctx context.Context, extCtx *ext.Context) context.Context {
return context.WithValue(ctx, extKey, extCtx)
}

View File

@@ -0,0 +1,183 @@
package tgutil
import (
"fmt"
"strconv"
"strings"
"github.com/celestix/gotgproto/ext"
"github.com/duke-git/lancet/v2/maputil"
"github.com/duke-git/lancet/v2/mathutil"
"github.com/duke-git/lancet/v2/slice"
lcstrutil "github.com/duke-git/lancet/v2/strutil"
"github.com/duke-git/lancet/v2/validator"
"github.com/gabriel-vasile/mimetype"
"github.com/gotd/td/tg"
"github.com/krau/SaveAny-Bot/common/cache"
"github.com/krau/SaveAny-Bot/common/utils/strutil"
"github.com/rs/xid"
)
func GenFileNameFromMessage(message tg.Message) string {
ext := func(media tg.MessageMediaClass) string {
switch media := media.(type) {
case *tg.MessageMediaDocument:
doc, ok := media.Document.AsNotEmpty()
if !ok {
return ""
}
ext := mimetype.Lookup(doc.MimeType).Extension()
if ext == "" {
return ""
}
return ext
case *tg.MessageMediaPhoto:
return ".jpg"
}
return ""
}(message.Media)
text := strings.TrimSpace(message.GetMessage())
if text == "" {
return fmt.Sprintf("%d_%s%s", message.GetID(), xid.New().String(), ext)
}
filename := func() string {
tags := strutil.ExtractTagsFromText(text)
if len(tags) > 0 {
tagStrRunes := make([]rune, 0, 64)
for i, tag := range tags {
if i > 0 {
tagStrRunes = append(tagStrRunes, '_')
}
tagStrRunes = append(tagStrRunes, []rune(tag)...)
if len(tagStrRunes) >= 64 {
break
}
}
tagStr := string(tagStrRunes)
return fmt.Sprintf("%s_%s", tagStr, strconv.Itoa(message.GetID()))
}
text = lcstrutil.Substring(strings.Map(func(r rune) rune {
if r < 0x20 || r == 0x7F {
return '_'
}
switch r {
// invalid characters
case '/', '\\',
':', '*', '?', '"', '<', '>', '|':
return '_'
// empty
case ' ', '\t', '\r', '\n':
return '_'
}
if validator.IsPrintable(string(r)) {
return r
}
return '_'
}, text), 0, 64)
text = strings.Join(strings.FieldsFunc(text, func(r rune) bool {
return r == '_' || r == ' '
}), "_")
return text
}()
if filename == "" {
filename = fmt.Sprintf("%d_%s", message.GetID(), xid.New().String())
}
return filename + ext
}
func BuildCancelButton(taskID string) tg.KeyboardButtonClass {
return &tg.KeyboardButtonCallback{
Text: "取消任务",
Data: fmt.Appendf(nil, "cancel %s", taskID),
}
}
func InputMessageClassSliceFromInt(ids []int) []tg.InputMessageClass {
result := make([]tg.InputMessageClass, 0, len(ids))
for _, id := range ids {
result = append(result, &tg.InputMessageID{
ID: id,
})
}
return result
}
func GetMessagesRange(ctx *ext.Context, chatID int64, minId, maxId int) ([]*tg.Message, error) {
if minId > maxId {
return nil, fmt.Errorf("minId (%d) cannot be greater than maxId (%d)", minId, maxId)
}
total := maxId - minId + 1
msgIds := mathutil.Range(minId, total)
toFetchIds := make([]int, 0, total)
cached := make(map[int]*tg.Message, total)
for _, id := range msgIds {
if msg, ok := cache.Get[*tg.Message](fmt.Sprintf("tgmsg:%d:%d:%d", ctx.Self.ID, chatID, id)); ok {
cached[id] = msg
} else {
toFetchIds = append(toFetchIds, id)
}
}
if len(toFetchIds) == 0 {
return maputil.Values(cached), nil
}
result := make([]*tg.Message, 0, total)
chunks := slice.Chunk(toFetchIds, 100)
for _, chunk := range chunks {
msgs, err := ctx.GetMessages(chatID, InputMessageClassSliceFromInt(chunk))
if err != nil {
return nil, err
}
if len(msgs) == 0 {
continue
}
for _, msg := range msgs {
if msg == nil {
continue
}
tgMessage, ok := msg.(*tg.Message)
if !ok {
continue
}
if tgMessage.GetID() < minId || tgMessage.GetID() > maxId {
continue
}
result = append(result, tgMessage)
}
}
for _, msg := range result {
cache.Set(fmt.Sprintf("tgmsg:%d:%d:%d", ctx.Self.ID, chatID, msg.GetID()), msg)
}
for _, msg := range cached {
if msg == nil {
continue
}
result = append(result, msg)
}
return result, nil
}
func GetMessageByID(ctx *ext.Context, chatID int64, msgID int) (*tg.Message, error) {
key := fmt.Sprintf("tgmsg:%d:%d:%d", ctx.Self.ID, chatID, msgID)
if msg, ok := cache.Get[*tg.Message](key); ok {
return msg, nil
}
msgs, err := ctx.GetMessages(chatID, []tg.InputMessageClass{
&tg.InputMessageID{ID: msgID},
})
if err != nil {
return nil, fmt.Errorf("failed to get message by ID: %w", err)
}
if len(msgs) == 0 {
return nil, fmt.Errorf("message not found: chatID=%d, msgID=%d", chatID, msgID)
}
msg := msgs[0]
tgm, ok := msg.(*tg.Message)
if !ok {
return nil, fmt.Errorf("unexpected message type: %T", msg)
}
cache.Set(key, tgm)
return tgm, nil
}

View File

@@ -0,0 +1,119 @@
package tgutil
import (
"fmt"
"net/url"
"strconv"
"strings"
"github.com/celestix/gotgproto/ext"
"github.com/duke-git/lancet/v2/validator"
"github.com/gotd/td/tg"
)
func ParseChatID(ctx *ext.Context, idOrUsername string) (int64, error) {
idOrUsername = strings.TrimPrefix(idOrUsername, "@")
if validator.IsIntStr(idOrUsername) {
chatID, err := strconv.Atoi(idOrUsername)
if err != nil {
return 0, err
}
return int64(chatID), nil
}
username := idOrUsername
peer := ctx.PeerStorage.GetPeerByUsername(username)
if peer != nil && peer.ID != 0 {
return peer.ID, nil
}
chat, err := ctx.ResolveUsername(username)
if err != nil {
return 0, err
}
if chat == nil {
return 0, fmt.Errorf("no chat found for username: %s", idOrUsername)
}
chatID := chat.GetID()
if chatID == 0 {
return 0, fmt.Errorf("chat ID is zero for username: %s", idOrUsername)
}
return chatID, nil
}
// return: ChatID, MessageID, error
func ParseMessageLink(ctx *ext.Context, link string) (int64, int, error) {
u, err := url.Parse(link)
if err != nil {
return 0, 0, fmt.Errorf("invalid URL: %w", err)
}
paths := strings.Split(strings.TrimPrefix(u.Path, "/"), "/")
if cmt := u.Query().Get("comment"); cmt != "" {
// 频道评论的消息链接
// https://t.me/acherkrau/123?comment=2
chid, err := ParseChatID(ctx, paths[0])
if err != nil {
return 0, 0, fmt.Errorf("failed to parse chat ID: %w", err)
}
chatfull, err := ctx.GetChat(chid)
if err != nil {
return 0, 0, fmt.Errorf("failed to get chat: %w", err)
}
chfull, ok := chatfull.(*tg.ChannelFull)
if !ok {
return 0, 0, fmt.Errorf("chat is not a channel: %s", chatfull.TypeName())
}
linkChatId, ok := chfull.GetLinkedChatID()
if !ok {
return 0, 0, fmt.Errorf("channel has no linked chat")
}
msgID, err := strconv.Atoi(cmt)
if err != nil {
return 0, 0, fmt.Errorf("failed to parse comment ID: %w", err)
}
return linkChatId, msgID, nil
}
switch len(paths) {
case 2: // https://t.me/acherkrau/123
chatID, err := ParseChatID(ctx, paths[0])
if err != nil {
return 0, 0, fmt.Errorf("failed to parse chat ID: %w", err)
}
msgID, err := strconv.Atoi(paths[1])
if err != nil {
return 0, 0, fmt.Errorf("failed to parse message ID: %w", err)
}
return chatID, msgID, nil
case 3:
// https://t.me/c/123456789/123
// https://t.me/acherkrau/123/456 , 456: message thread ID
chatPart, msgPart := paths[1], paths[2]
if paths[0] != "c" {
chatPart = paths[0]
}
chatID, err := ParseChatID(ctx, chatPart)
if err != nil {
return 0, 0, fmt.Errorf("failed to parse chat ID: %w", err)
}
msgID, err := strconv.Atoi(msgPart)
if err != nil {
return 0, 0, fmt.Errorf("failed to parse message ID: %w", err)
}
return chatID, msgID, nil
case 4:
// https://t.me/c/123456789/111/456 111: topic id
if paths[0] != "c" {
return 0, 0, fmt.Errorf("invalid message link format: %s", link)
}
chatID, err := ParseChatID(ctx, paths[1])
if err != nil {
return 0, 0, fmt.Errorf("failed to parse chat ID: %w", err)
}
msgID, err := strconv.Atoi(paths[3])
if err != nil {
return 0, 0, fmt.Errorf("failed to parse message ID: %w", err)
}
return chatID, msgID, nil
}
return 0, 0, fmt.Errorf("invalid message link format: %s", link)
}

View File

@@ -0,0 +1,51 @@
package tphutil
import (
"encoding/json"
"github.com/krau/SaveAny-Bot/config"
"github.com/krau/SaveAny-Bot/pkg/telegraph"
)
var tphClient *telegraph.Client
func DefaultClient() *telegraph.Client {
if tphClient != nil {
return tphClient
}
if config.Cfg.Telegram.Proxy.Enable && config.Cfg.Telegram.Proxy.URL != "" {
proxyUrl := config.Cfg.Telegram.Proxy.URL
var err error
tphClient, err = telegraph.NewClientWithProxy(proxyUrl)
if err != nil {
tphClient = telegraph.NewClient()
}
} else {
tphClient = telegraph.NewClient()
}
return tphClient
}
func GetNodeImages(node telegraph.Node) []string {
var srcs []string
var nodeElement telegraph.NodeElement
data, err := json.Marshal(node)
if err != nil {
return srcs
}
err = json.Unmarshal(data, &nodeElement)
if err != nil {
return srcs
}
if nodeElement.Tag == "img" {
if src, exists := nodeElement.Attrs["src"]; exists {
srcs = append(srcs, src)
}
}
for _, child := range nodeElement.Children {
srcs = append(srcs, GetNodeImages(child)...)
}
return srcs
}

View File

@@ -1,7 +0,0 @@
package common
var (
Version string = "dev"
BuildTime string = "unknown"
GitCommit string = "unknown"
)

View File

@@ -3,7 +3,7 @@ package storage
import ( import (
"fmt" "fmt"
"github.com/krau/SaveAny-Bot/types" storenum "github.com/krau/SaveAny-Bot/pkg/enums/storage"
) )
type AlistStorageConfig struct { type AlistStorageConfig struct {
@@ -29,8 +29,8 @@ func (a *AlistStorageConfig) Validate() error {
return nil return nil
} }
func (a *AlistStorageConfig) GetType() types.StorageType { func (a *AlistStorageConfig) GetType() storenum.StorageType {
return types.StorageTypeAlist return storenum.Alist
} }
func (a *AlistStorageConfig) GetName() string { func (a *AlistStorageConfig) GetName() string {

View File

@@ -4,16 +4,17 @@ import (
"fmt" "fmt"
"reflect" "reflect"
"github.com/krau/SaveAny-Bot/types" storenum "github.com/krau/SaveAny-Bot/pkg/enums/storage"
"github.com/mitchellh/mapstructure" "github.com/mitchellh/mapstructure"
"github.com/spf13/viper" "github.com/spf13/viper"
) )
var storageFactories = map[types.StorageType]func(cfg *BaseConfig) (StorageConfig, error){ var storageFactories = map[storenum.StorageType]func(cfg *BaseConfig) (StorageConfig, error){
types.StorageTypeLocal: createStorageConfig(&LocalStorageConfig{}), storenum.Local: createStorageConfig(&LocalStorageConfig{}),
types.StorageTypeAlist: createStorageConfig(&AlistStorageConfig{}), storenum.Alist: createStorageConfig(&AlistStorageConfig{}),
types.StorageTypeWebdav: createStorageConfig(&WebdavStorageConfig{}), storenum.Webdav: createStorageConfig(&WebdavStorageConfig{}),
types.StorageTypeMinio: createStorageConfig(&MinioStorageConfig{}), storenum.Minio: createStorageConfig(&MinioStorageConfig{}),
storenum.Telegram: createStorageConfig(&TelegramStorageConfig{}),
} }
func createStorageConfig(configType StorageConfig) func(cfg *BaseConfig) (StorageConfig, error) { func createStorageConfig(configType StorageConfig) func(cfg *BaseConfig) (StorageConfig, error) {
@@ -41,8 +42,12 @@ func LoadStorageConfigs(v *viper.Viper) ([]StorageConfig, error) {
if !baseCfg.Enable { if !baseCfg.Enable {
continue continue
} }
st, err := storenum.ParseStorageType(baseCfg.Type)
if err != nil {
return nil, fmt.Errorf("invalid storage type %s for %s: %w", baseCfg.Type, baseCfg.Name, err)
}
factory, ok := storageFactories[types.StorageType(baseCfg.Type)] factory, ok := storageFactories[st]
if !ok { if !ok {
return nil, fmt.Errorf("unsupported storage type: %s", baseCfg.Type) return nil, fmt.Errorf("unsupported storage type: %s", baseCfg.Type)
} }

View File

@@ -3,7 +3,7 @@ package storage
import ( import (
"fmt" "fmt"
"github.com/krau/SaveAny-Bot/types" storenum "github.com/krau/SaveAny-Bot/pkg/enums/storage"
) )
type LocalStorageConfig struct { type LocalStorageConfig struct {
@@ -18,8 +18,8 @@ func (l *LocalStorageConfig) Validate() error {
return nil return nil
} }
func (l *LocalStorageConfig) GetType() types.StorageType { func (l *LocalStorageConfig) GetType() storenum.StorageType {
return types.StorageTypeLocal return storenum.Local
} }
func (l *LocalStorageConfig) GetName() string { func (l *LocalStorageConfig) GetName() string {

View File

@@ -3,7 +3,7 @@ package storage
import ( import (
"fmt" "fmt"
"github.com/krau/SaveAny-Bot/types" storenum "github.com/krau/SaveAny-Bot/pkg/enums/storage"
) )
type MinioStorageConfig struct { type MinioStorageConfig struct {
@@ -32,8 +32,8 @@ func (m *MinioStorageConfig) Validate() error {
return nil return nil
} }
func (m *MinioStorageConfig) GetType() types.StorageType { func (m *MinioStorageConfig) GetType() storenum.StorageType {
return types.StorageTypeMinio return storenum.Minio
} }
func (m *MinioStorageConfig) GetName() string { func (m *MinioStorageConfig) GetName() string {

View File

@@ -0,0 +1,32 @@
package storage
import (
"fmt"
storenum "github.com/krau/SaveAny-Bot/pkg/enums/storage"
)
type TelegramStorageConfig struct {
BaseConfig
ChatID int64 `toml:"chat_id" mapstructure:"chat_id" json:"chat_id"`
RateLimit int `toml:"rate_limit" mapstructure:"rate_limit" json:"rate_limit"`
RateBurst int `toml:"rate_burst" mapstructure:"rate_burst" json:"rate_burst"`
}
func (m *TelegramStorageConfig) Validate() error {
if m.ChatID == 0 {
return fmt.Errorf("chat_id is required for telegram storage")
}
if m.RateLimit < 0 || m.RateBurst < 0 {
return fmt.Errorf("rate_limit and rate_burst must be greater than 0 for telegram storage")
}
return nil
}
func (m *TelegramStorageConfig) GetType() storenum.StorageType {
return storenum.Telegram
}
func (m *TelegramStorageConfig) GetName() string {
return m.Name
}

View File

@@ -1,10 +1,12 @@
package storage package storage
import "github.com/krau/SaveAny-Bot/types" import (
storenum "github.com/krau/SaveAny-Bot/pkg/enums/storage"
)
type StorageConfig interface { type StorageConfig interface {
Validate() error Validate() error
GetType() types.StorageType GetType() storenum.StorageType
GetName() string GetName() string
} }

View File

@@ -3,7 +3,7 @@ package storage
import ( import (
"fmt" "fmt"
"github.com/krau/SaveAny-Bot/types" storenum "github.com/krau/SaveAny-Bot/pkg/enums/storage"
) )
type WebdavStorageConfig struct { type WebdavStorageConfig struct {
@@ -27,8 +27,8 @@ func (w *WebdavStorageConfig) Validate() error {
return nil return nil
} }
func (w *WebdavStorageConfig) GetType() types.StorageType { func (w *WebdavStorageConfig) GetType() storenum.StorageType {
return types.StorageTypeWebdav return storenum.Webdav
} }
func (w *WebdavStorageConfig) GetName() string { func (w *WebdavStorageConfig) GetName() string {

View File

@@ -1,15 +1,16 @@
package config package config
import ( import (
"context"
"errors" "errors"
"fmt" "fmt"
"os" "os"
"strings" "strings"
"github.com/duke-git/lancet/v2/slice" "github.com/duke-git/lancet/v2/slice"
"github.com/krau/SaveAny-Bot/common/i18n"
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
"github.com/krau/SaveAny-Bot/config/storage" "github.com/krau/SaveAny-Bot/config/storage"
"github.com/krau/SaveAny-Bot/i18n"
"github.com/krau/SaveAny-Bot/i18n/i18nk"
"github.com/spf13/viper" "github.com/spf13/viper"
) )
@@ -53,7 +54,6 @@ type telegramConfig struct {
AppHash string `toml:"app_hash" mapstructure:"app_hash" json:"app_hash"` AppHash string `toml:"app_hash" mapstructure:"app_hash" json:"app_hash"`
Timeout int `toml:"timeout" mapstructure:"timeout" json:"timeout"` Timeout int `toml:"timeout" mapstructure:"timeout" json:"timeout"`
Proxy proxyConfig `toml:"proxy" mapstructure:"proxy"` Proxy proxyConfig `toml:"proxy" mapstructure:"proxy"`
FloodRetry int `toml:"flood_retry" mapstructure:"flood_retry" json:"flood_retry"`
RpcRetry int `toml:"rpc_retry" mapstructure:"rpc_retry" json:"rpc_retry"` RpcRetry int `toml:"rpc_retry" mapstructure:"rpc_retry" json:"rpc_retry"`
Userbot userbotConfig `toml:"userbot" mapstructure:"userbot" json:"userbot"` Userbot userbotConfig `toml:"userbot" mapstructure:"userbot" json:"userbot"`
} }
@@ -79,7 +79,7 @@ func (c Config) GetStorageByName(name string) storage.StorageConfig {
return nil return nil
} }
func Init() error { func Init(ctx context.Context) error {
viper.SetConfigName("config") viper.SetConfigName("config")
viper.AddConfigPath(".") viper.AddConfigPath(".")
viper.AddConfigPath("/etc/saveany/") viper.AddConfigPath("/etc/saveany/")

122
core/batchtftask/execute.go Normal file
View File

@@ -0,0 +1,122 @@
package batchtftask
import (
"context"
"fmt"
"io"
"os"
"path"
"github.com/charmbracelet/log"
"github.com/duke-git/lancet/v2/retry"
"github.com/krau/SaveAny-Bot/common/tdler"
"github.com/krau/SaveAny-Bot/common/utils/fsutil"
"github.com/krau/SaveAny-Bot/common/utils/ioutil"
"github.com/krau/SaveAny-Bot/config"
"github.com/krau/SaveAny-Bot/pkg/enums/key"
"golang.org/x/sync/errgroup"
)
func (t *Task) Execute(ctx context.Context) error {
logger := log.FromContext(ctx).WithPrefix(fmt.Sprintf("batch_file[%s]", t.ID))
logger.Info("Starting batch file task")
t.Progress.OnStart(ctx, t)
workers := config.Cfg.Workers
eg, gctx := errgroup.WithContext(ctx)
eg.SetLimit(workers)
for _, elem := range t.Elems {
elem := elem
eg.Go(func() error {
if t.processing[elem.ID] != nil {
return fmt.Errorf("element with ID %s is already being processed", elem.ID)
}
t.processing[elem.ID] = &elem
defer func() {
delete(t.processing, elem.ID)
}()
return t.processElement(gctx, elem)
})
}
err := eg.Wait()
if err != nil {
logger.Errorf("Error during batch file processing: %v", err)
} else {
logger.Info("Batch file task completed successfully")
}
t.Progress.OnDone(ctx, t, err)
return err
}
func (t *Task) processElement(ctx context.Context, elem TaskElement) error {
logger := log.FromContext(ctx).WithPrefix(fmt.Sprintf("file[%s]", elem.File.Name()))
if elem.stream {
pr, pw := io.Pipe()
defer pr.Close()
errg, uploadCtx := errgroup.WithContext(ctx)
errg.Go(func() error {
return elem.Storage.Save(uploadCtx, pr, elem.Path)
})
wr := ioutil.NewProgressWriter(pw, func(n int) {
t.downloaded.Add(int64(n))
t.Progress.OnProgress(ctx, t)
})
errg.Go(func() error {
logger.Info("Starting file download in stream mode")
_, err := tdler.NewDownloader(t.client, elem.File).Stream(uploadCtx, wr)
if closeErr := pw.CloseWithError(err); closeErr != nil {
logger.Errorf("Failed to close pipe writer: %v", closeErr)
}
return err
})
if err := errg.Wait(); err != nil {
return fmt.Errorf("failed to download file in stream mode: %w", err)
}
logger.Info("File downloaded successfully in stream mode")
return nil
}
logger.Info("Starting file download")
localFile, err := fsutil.CreateFile(elem.localPath)
if err != nil {
return fmt.Errorf("failed to create local file: %w", err)
}
defer func() {
if err := localFile.CloseAndRemove(); err != nil {
logger.Errorf("Failed to close local file: %v", err)
}
}()
wrAt := ioutil.NewProgressWriterAt(localFile, func(n int) {
t.downloaded.Add(int64(n))
t.Progress.OnProgress(ctx, t)
})
_, err = tdler.NewDownloader(t.client, elem.File).Parallel(ctx, wrAt)
if err != nil {
return fmt.Errorf("failed to download file: %w", err)
}
logger.Info("File downloaded successfully")
if path.Ext(elem.FileName()) == "" {
ext := fsutil.DetectFileExt(elem.localPath)
if ext != "" {
elem.Path = elem.Path + ext
}
}
var fileStat os.FileInfo
fileStat, err = os.Stat(elem.localPath)
if err != nil {
return fmt.Errorf("failed to get file stat: %w", err)
}
vctx := context.WithValue(ctx, key.ContextKeyContentLength, fileStat.Size())
err = retry.Retry(func() error {
var file *os.File
file, err = os.Open(elem.localPath)
if err != nil {
return fmt.Errorf("failed to open cache file: %w", err)
}
defer file.Close()
if err = elem.Storage.Save(vctx, file, elem.Path); err != nil {
logger.Errorf("Failed to save file: %s, retrying...", err)
return err
}
return nil
}, retry.Context(vctx), retry.RetryTimes(uint(config.Cfg.Retry)))
return err
}

View File

@@ -0,0 +1,176 @@
package batchtftask
import (
"context"
"errors"
"fmt"
"strconv"
"sync/atomic"
"time"
"github.com/charmbracelet/log"
"github.com/duke-git/lancet/v2/slice"
"github.com/gotd/td/telegram/message/entity"
"github.com/gotd/td/telegram/message/styling"
"github.com/gotd/td/tg"
"github.com/krau/SaveAny-Bot/common/utils/dlutil"
"github.com/krau/SaveAny-Bot/common/utils/tgutil"
)
type ProgressTracker interface {
OnStart(ctx context.Context, info TaskInfo)
OnProgress(ctx context.Context, info TaskInfo)
OnDone(ctx context.Context, info TaskInfo, err error)
}
type Progress struct {
MessageID int
ChatID int64
start time.Time
lastUpdatePercent atomic.Int32
}
func (p *Progress) OnStart(ctx context.Context, info TaskInfo) {
p.start = time.Now()
p.lastUpdatePercent.Store(0)
log.FromContext(ctx).Debugf("Batch task progress tracking started for message %d in chat %d", p.MessageID, p.ChatID)
entityBuilder := entity.Builder{}
var entities []tg.MessageEntityClass
if err := styling.Perform(&entityBuilder,
styling.Plain("开始执行批量下载任务\n总大小: "),
styling.Code(fmt.Sprintf("%.2f MB (%d个文件)", float64(info.TotalSize())/(1024*1024), info.Count())),
); err != nil {
log.FromContext(ctx).Errorf("Failed to build entities: %s", err)
return
}
text, entities := entityBuilder.Complete()
req := &tg.MessagesEditMessageRequest{
ID: p.MessageID,
}
req.SetMessage(text)
req.SetEntities(entities)
req.SetReplyMarkup(&tg.ReplyInlineMarkup{
Rows: []tg.KeyboardButtonRow{
{
Buttons: []tg.KeyboardButtonClass{
tgutil.BuildCancelButton(info.TaskID()),
},
},
}},
)
ext := tgutil.ExtFromContext(ctx)
if ext != nil {
ext.EditMessage(p.ChatID, req)
return
}
}
func (p *Progress) OnProgress(ctx context.Context, info TaskInfo) {
if !shouldUpdateProgress(info.TotalSize(), info.Downloaded(), int(p.lastUpdatePercent.Load())) {
return
}
percent := int((info.Downloaded() * 100) / info.TotalSize())
if p.lastUpdatePercent.Load() == int32(percent) {
return
}
p.lastUpdatePercent.Store(int32(percent))
log.FromContext(ctx).Debugf("Progress update: %s, %d/%d", info.TaskID(), info.Downloaded(), info.TotalSize())
entityBuilder := entity.Builder{}
var entities []tg.MessageEntityClass
if err := styling.Perform(&entityBuilder,
styling.Plain("正在处理批量下载任务\n总大小: "),
styling.Code(fmt.Sprintf("%.2f MB (%d个文件)", float64(info.TotalSize())/(1024*1024), info.Count())),
styling.Plain("\n正在处理:\n"),
func() styling.StyledTextOption {
var lines []string
for _, elem := range info.Processing() {
lines = append(lines, fmt.Sprintf(" - %s (%.2f MB)", elem.FileName(), float64(elem.FileSize())/(1024*1024)))
}
if len(lines) == 0 {
lines = append(lines, " - 无")
}
return styling.Plain(slice.Join(lines, "\n"))
}(),
styling.Plain("\n平均速度: "),
styling.Bold(fmt.Sprintf("%.2f MB/s", dlutil.GetSpeed(info.Downloaded(), p.start)/(1024*1024))),
styling.Plain("\n当前进度: "),
styling.Bold(fmt.Sprintf("%.2f%%", float64(info.Downloaded())/float64(info.TotalSize())*100)),
); err != nil {
log.FromContext(ctx).Errorf("Failed to build entities: %s", err)
return
}
text, entities := entityBuilder.Complete()
req := &tg.MessagesEditMessageRequest{
ID: p.MessageID,
}
req.SetMessage(text)
req.SetEntities(entities)
req.SetReplyMarkup(&tg.ReplyInlineMarkup{
Rows: []tg.KeyboardButtonRow{
{
Buttons: []tg.KeyboardButtonClass{
tgutil.BuildCancelButton(info.TaskID()),
},
},
}},
)
ext := tgutil.ExtFromContext(ctx)
if ext != nil {
ext.EditMessage(p.ChatID, req)
return
}
}
func (p *Progress) OnDone(ctx context.Context, info TaskInfo, err error) {
if err != nil {
log.FromContext(ctx).Errorf("Batch task %s failed: %s", info.TaskID(), err)
} else {
log.FromContext(ctx).Debugf("Batch task %s completed successfully", info.TaskID())
}
entityBuilder := entity.Builder{}
var stylingErr error
if err != nil {
if errors.Is(err, context.Canceled) {
stylingErr = styling.Perform(&entityBuilder,
styling.Plain("任务已取消"),
)
} else {
stylingErr = styling.Perform(&entityBuilder,
styling.Plain("处理失败, 错误:\n "),
styling.Code(err.Error()),
)
}
} else {
stylingErr = styling.Perform(&entityBuilder,
styling.Plain("处理完成\n文件数: "),
styling.Code(strconv.Itoa(info.Count())),
styling.Plain("\n总大小: "),
styling.Code(fmt.Sprintf("%.2f MB", float64(info.TotalSize())/(1024*1024))),
)
}
if stylingErr != nil {
log.FromContext(ctx).Errorf("Failed to build entities: %s", stylingErr)
return
}
text, entities := entityBuilder.Complete()
req := &tg.MessagesEditMessageRequest{
ID: p.MessageID,
}
req.SetMessage(text)
req.SetEntities(entities)
ext := tgutil.ExtFromContext(ctx)
if ext != nil {
ext.EditMessage(p.ChatID, req)
}
}
func NewProgressTracker(messageID int, chatID int64) ProgressTracker {
return &Progress{
MessageID: messageID,
ChatID: chatID,
}
}

94
core/batchtftask/task.go Normal file
View File

@@ -0,0 +1,94 @@
package batchtftask
import (
"context"
"fmt"
"path/filepath"
"sync/atomic"
"github.com/krau/SaveAny-Bot/common/tdler"
"github.com/krau/SaveAny-Bot/config"
"github.com/krau/SaveAny-Bot/pkg/tfile"
"github.com/krau/SaveAny-Bot/storage"
"github.com/rs/xid"
)
type TaskElement struct {
ID string
Storage storage.Storage
Path string
File tfile.TGFile
localPath string
stream bool
}
type Task struct {
ID string
Ctx context.Context
Elems []TaskElement
Progress ProgressTracker
IgnoreErrors bool // if true, errors during processing will be ignored
downloaded atomic.Int64
client tdler.Client
totalSize int64
processing map[string]TaskElementInfo
failed map[string]error // errors for each element
}
func NewTaskElement(
stor storage.Storage,
path string,
file tfile.TGFile,
) (*TaskElement, error) {
id := xid.New().String()
_, ok := stor.(storage.StorageCannotStream)
if !config.Cfg.Stream || ok {
cachePath, err := filepath.Abs(filepath.Join(config.Cfg.Temp.BasePath, fmt.Sprintf("%s_%s", id, file.Name())))
if err != nil {
return nil, fmt.Errorf("failed to get absolute path for cache: %w", err)
}
return &TaskElement{
ID: id,
Storage: stor,
Path: path,
File: file,
localPath: cachePath,
}, nil
}
return &TaskElement{
ID: id,
Storage: stor,
Path: path,
File: file,
stream: true,
}, nil
}
func NewBatchTGFileTask(
id string,
ctx context.Context,
files []TaskElement,
client tdler.Client,
progress ProgressTracker,
ignoreErrors bool,
) *Task {
task := &Task{
ID: id,
Ctx: ctx,
client: client,
Elems: files,
Progress: progress,
downloaded: atomic.Int64{},
totalSize: func() int64 {
var total int64
for _, elem := range files {
total += elem.File.Size()
}
return total
}(),
processing: make(map[string]TaskElementInfo),
IgnoreErrors: ignoreErrors,
failed: make(map[string]error),
}
return task
}

View File

@@ -0,0 +1,56 @@
package batchtftask
type TaskElementInfo interface {
FileName() string
FileSize() int64
StoragePath() string
StorageName() string
}
func (e *TaskElement) FileName() string {
return e.File.Name()
}
func (e *TaskElement) FileSize() int64 {
return e.File.Size()
}
func (e *TaskElement) StoragePath() string {
return e.Path
}
func (e *TaskElement) StorageName() string {
return e.Storage.Name()
}
type TaskInfo interface {
TaskID() string
TotalSize() int64
Downloaded() int64
Count() int
Processing() []TaskElementInfo
}
func (t *Task) TaskID() string {
return t.ID
}
func (t *Task) TotalSize() int64 {
return t.totalSize
}
func (t *Task) Downloaded() int64 {
return t.downloaded.Load()
}
func (t *Task) Count() int {
return len(t.Elems)
}
func (t *Task) Processing() []TaskElementInfo {
processing := make([]TaskElementInfo, 0, len(t.Elems))
for _, elem := range t.processing {
processing = append(processing, elem)
}
return processing
}

32
core/batchtftask/utils.go Normal file
View File

@@ -0,0 +1,32 @@
package batchtftask
var progressUpdatesLevels = []struct {
size int64 // 文件大小阈值
stepPercent int // 每多少 % 更新一次
}{
{10 << 20, 100},
{50 << 20, 20},
{200 << 20, 10},
{500 << 20, 5},
}
func shouldUpdateProgress(total, downloaded int64, lastUpdatePercent int) bool {
if total <= 0 || downloaded <= 0 {
return false
}
percent := int((downloaded * 100) / total)
if percent <= lastUpdatePercent {
return false
}
step := progressUpdatesLevels[len(progressUpdatesLevels)-1].stepPercent
for _, lvl := range progressUpdatesLevels {
if total < lvl.size {
step = lvl.stepPercent
break
}
}
return percent >= lastUpdatePercent+step
}

View File

@@ -2,92 +2,62 @@ package core
import ( import (
"context" "context"
"errors"
"fmt"
"github.com/celestix/gotgproto/ext" "github.com/charmbracelet/log"
"github.com/gotd/td/telegram/downloader"
"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/queue" "github.com/krau/SaveAny-Bot/pkg/queue"
"github.com/krau/SaveAny-Bot/types"
) )
var Downloader *downloader.Downloader var queueInstance *queue.TaskQueue[Exectable]
func init() { type Exectable interface {
Downloader = downloader.NewDownloader().WithPartSize(1024 * 1024) TaskID() string
Execute(ctx context.Context) error
} }
func worker(queue *queue.TaskQueue, semaphore chan struct{}) { func worker(ctx context.Context, qe *queue.TaskQueue[Exectable], semaphore chan struct{}) {
for { for {
semaphore <- struct{}{} semaphore <- struct{}{}
task := queue.GetTask() qtask, err := qe.Get()
common.Log.Debugf("Got task: %s", task.String()) if err != nil {
break // queue closed and empty
switch task.Status {
case types.Pending:
common.Log.Infof("Processing task: %s", task.String())
if err := processPendingTask(task); err != nil {
task.Error = err
if errors.Is(err, context.Canceled) {
task.Status = types.Canceled
} else {
common.Log.Errorf("Failed to do task: %s", err)
task.Status = types.Failed
}
} else {
task.Status = types.Succeeded
}
queue.AddTask(task)
case types.Succeeded:
common.Log.Infof("Task succeeded: %s", task.String())
extCtx, ok := task.Ctx.(*ext.Context)
if !ok {
common.Log.Errorf("Context is not *ext.Context: %T", task.Ctx)
} else if task.ReplyMessageID != 0 {
extCtx.EditMessage(task.ReplyChatID, &tg.MessagesEditMessageRequest{
Message: fmt.Sprintf("文件保存成功\n [%s]: %s", task.StorageName, task.StoragePath),
ID: task.ReplyMessageID,
})
}
case types.Failed:
common.Log.Errorf("Task failed: %s", task.String())
extCtx, ok := task.Ctx.(*ext.Context)
if !ok {
common.Log.Errorf("Context is not *ext.Context: %T", task.Ctx)
} else if task.ReplyMessageID != 0 {
extCtx.EditMessage(task.ReplyChatID, &tg.MessagesEditMessageRequest{
Message: "文件保存失败\n" + task.Error.Error(),
ID: task.ReplyMessageID,
})
}
case types.Canceled:
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 if task.ReplyMessageID != 0 {
extCtx.EditMessage(task.ReplyChatID, &tg.MessagesEditMessageRequest{
Message: "任务已取消",
ID: task.ReplyMessageID,
})
}
default:
common.Log.Errorf("Unknown task status: %s", task.Status)
} }
log.FromContext(ctx).Infof("Processing task: %s", qtask.ID)
task := qtask.Data
if err := task.Execute(qtask.Context()); err != nil {
log.FromContext(ctx).Errorf("Failed to execute task %s: %v", qtask.ID, err)
} else {
log.FromContext(ctx).Infof("Task %s completed successfully", qtask.ID)
}
qe.Done(qtask.ID)
<-semaphore <-semaphore
common.Log.Debugf("Task done: %s; status: %s", task.String(), task.Status)
queue.DoneTask(task)
} }
} }
func Run() { func Run(ctx context.Context) {
common.Log.Info("Start processing tasks...") log.FromContext(ctx).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++ { if queueInstance == nil {
go worker(queue.Queue, semaphore) queueInstance = queue.NewTaskQueue[Exectable]()
}
for range config.Cfg.Workers {
go worker(ctx, queueInstance, semaphore)
} }
} }
func AddTask(ctx context.Context, task Exectable) error {
return queueInstance.Add(queue.NewTask(ctx, task.TaskID(), task))
}
func CancelTask(ctx context.Context, id string) error {
err := queueInstance.CancelTask(id)
return err
}
func GetLength(ctx context.Context) int {
if queueInstance == nil {
return 0
}
return queueInstance.ActiveLength()
}

View File

@@ -1,291 +0,0 @@
package core
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"path"
"path/filepath"
"strings"
"time"
"github.com/celestix/gotgproto/ext"
"github.com/celestix/telegraph-go/v2"
"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/krau/SaveAny-Bot/bot"
"github.com/krau/SaveAny-Bot/common"
"github.com/krau/SaveAny-Bot/config"
"github.com/krau/SaveAny-Bot/storage"
"github.com/krau/SaveAny-Bot/types"
"github.com/krau/SaveAny-Bot/userclient"
"golang.org/x/sync/errgroup"
)
func processPendingTask(task *types.Task) error {
common.Log.Infof("Start processing task: %s", task.String())
if task.FileName() == "" {
task.File.FileName = fmt.Sprintf("%d_%d_%s", task.FileChatID, task.FileMessageID, task.File.Hash())
}
taskStorage, storagePath, err := getStorageAndPathForTask(task)
if err != nil {
return err
}
if taskStorage == nil {
return fmt.Errorf("not found storage: %s", task.StorageName)
}
task.StoragePath = storagePath
ctx, ok := task.Ctx.(*ext.Context)
if !ok {
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)
}
api := bot.Client.API()
if task.UseUserClient && userclient.UC != nil {
api = userclient.UC.API()
}
downloadBuilder := Downloader.Download(api, task.File.Location).WithThreads(getTaskThreads(task.File.FileSize))
notsupportStreamStorage, notsupportStream := taskStorage.(storage.StorageNotSupportStream)
cancelMarkUp := getCancelTaskMarkup(task)
if config.Cfg.Stream {
if !notsupportStream {
text, entities := buildProgressMessageEntity(task, 0, task.StartTime, 0)
if task.ReplyMessageID != 0 {
ctx.EditMessage(task.ReplyChatID, &tg.MessagesEditMessageRequest{
Message: text,
Entities: entities,
ID: task.ReplyMessageID,
ReplyMarkup: cancelMarkUp,
})
}
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
}
common.Log.Warnf("存储 %s 不支持流式传输: %s", task.StorageName, notsupportStreamStorage.NotSupportStream())
if task.ReplyMessageID != 0 {
ctx.EditMessage(task.ReplyChatID, &tg.MessagesEditMessageRequest{
Message: fmt.Sprintf("存储 %s 不支持流式传输: %s\n正在使用普通下载...", task.StorageName, notsupportStreamStorage.NotSupportStream()),
ID: task.ReplyMessageID,
ReplyMarkup: cancelMarkUp,
})
}
}
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)
if task.ReplyMessageID != 0 {
ctx.EditMessage(task.ReplyChatID, &tg.MessagesEditMessageRequest{
Message: text,
Entities: entities,
ID: task.ReplyMessageID,
ReplyMarkup: cancelMarkUp,
})
}
progressCallback := buildProgressCallback(ctx, task, getProgressUpdateCount(task.File.FileSize))
dest, err := NewTaskLocalFile(cacheDestPath, task.File.FileSize, progressCallback)
if err != nil {
return fmt.Errorf("创建文件失败: %w", err)
}
defer dest.Close()
task.StartTime = time.Now()
_, err = downloadBuilder.Parallel(cancelCtx, dest)
if err != nil {
return fmt.Errorf("下载文件失败: %w", err)
}
defer cleanCacheFile(cacheDestPath)
fixTaskFileExt(task, cacheDestPath)
common.Log.Infof("Downloaded file: %s", cacheDestPath)
if task.ReplyMessageID != 0 {
ctx.EditMessage(task.ReplyChatID, &tg.MessagesEditMessageRequest{
Message: fmt.Sprintf("下载完成: %s\n正在转存文件...", task.FileName()),
ID: task.ReplyMessageID,
})
}
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)
}
if task.ReplyMessageID != 0 {
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 len(node.Children) != 0 {
for _, child := range node.Children {
imgs = append(imgs, getNodeImages(child)...)
}
}
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,80 +0,0 @@
package core
import (
"reflect"
"testing"
"github.com/celestix/telegraph-go/v2"
)
func TestGetImgSrcs(t *testing.T) {
complexStructure := telegraph.NodeElement{
Tag: "div",
Children: []telegraph.Node{
telegraph.NodeElement{
Tag: "figure",
Children: []telegraph.Node{
telegraph.NodeElement{
Tag: "img",
Attrs: map[string]string{
"src": "https://example.com/image1.png",
},
},
telegraph.NodeElement{
Tag: "p",
Children: []telegraph.Node{
"A text node",
},
},
telegraph.NodeElement{
Tag: "figure",
Children: []telegraph.Node{
telegraph.NodeElement{
Tag: "img",
Attrs: map[string]string{
"src": "https://example.com/image2.png",
},
},
},
},
},
},
telegraph.NodeElement{
Tag: "img",
Attrs: map[string]string{
"src": "https://example.com/image3.png",
},
},
"text node",
telegraph.NodeElement{
Tag: "div",
Children: []telegraph.Node{
telegraph.NodeElement{
Tag: "span",
Children: []telegraph.Node{
telegraph.NodeElement{
Tag: "img",
Attrs: map[string]string{
"src": "https://example.com/image4.png",
},
},
},
},
},
},
},
}
expected := []string{
"https://example.com/image1.png",
"https://example.com/image2.png",
"https://example.com/image3.png",
"https://example.com/image4.png",
}
got := getNodeImages(complexStructure)
if !reflect.DeepEqual(expected, got) {
t.Errorf("expected %vgot %v", expected, got)
}
}

View File

@@ -1,110 +0,0 @@
package core
import (
"fmt"
"path"
"regexp"
"github.com/celestix/gotgproto/ext"
"github.com/krau/SaveAny-Bot/bot"
"github.com/krau/SaveAny-Bot/common"
"github.com/krau/SaveAny-Bot/dao"
"github.com/krau/SaveAny-Bot/storage"
"github.com/krau/SaveAny-Bot/types"
)
func getStorageAndPathForTask(task *types.Task) (storage.Storage, string, error) {
user, err := dao.GetUserByChatID(task.UserID)
if err != nil {
return nil, "", fmt.Errorf("failed to get user by chat ID: %w", err)
}
if task.StoragePath == "" {
task.StoragePath = task.FileName()
}
taskStorage, err := storage.GetStorageByUserIDAndName(task.UserID, task.StorageName)
if err != nil {
return nil, "", err
}
storagePath := taskStorage.JoinStoragePath(*task)
var ruleTaskStorage storage.Storage
var ruleStoragePath string
if user.ApplyRule && user.Rules != nil {
for _, rule := range user.Rules {
matchStorage, matchStoragePath := applyRule(&rule, *task)
if matchStorage != nil && matchStoragePath != "" {
ruleTaskStorage = matchStorage
ruleStoragePath = matchStoragePath
common.Log.Debugf("Rule matched: %s, %s", ruleTaskStorage.Name(), ruleStoragePath)
return ruleTaskStorage, ruleStoragePath, nil
}
}
}
if taskStorage.Exists(task.Ctx, storagePath) {
ext := path.Ext(task.FileName())
name := task.FileName()[:len(task.FileName())-len(ext)]
task.File.FileName = fmt.Sprintf("%s_%d%s", name, task.FileDBID, ext)
task.StoragePath = task.File.FileName
storagePath = taskStorage.JoinStoragePath(*task)
}
return taskStorage, storagePath, nil
}
func applyRule(rule *dao.Rule, task types.Task) (storage.Storage, string) {
var DirPath, StorageName string
switch rule.Type {
case string(types.RuleTypeFileNameRegex):
ruleRegex, err := regexp.Compile(rule.Data)
if err != nil {
common.Log.Errorf("failed to compile regex: %s", err)
return nil, ""
}
if !ruleRegex.MatchString(task.FileName()) {
return nil, ""
}
DirPath = rule.DirPath
StorageName = rule.StorageName
case string(types.RuleTypeMessageRegex):
ruleRegex, err := regexp.Compile(rule.Data)
if err != nil {
common.Log.Errorf("failed to compile regex: %s", err)
return nil, ""
}
ctx, ok := task.Ctx.(*ext.Context)
if !ok {
common.Log.Fatalf("context is not *ext.Context: %T", task.Ctx)
return nil, ""
}
msg, err := bot.GetTGMessage(ctx, task.FileChatID, task.FileMessageID)
if err != nil {
common.Log.Errorf("failed to get message: %s", err)
return nil, ""
}
if msg == nil {
return nil, ""
}
if !ruleRegex.MatchString(msg.GetMessage()) {
return nil, ""
}
DirPath = rule.DirPath
StorageName = rule.StorageName
default:
common.Log.Errorf("unknown rule type: %s", rule.Type)
return nil, ""
}
taskStorageName := func() string {
if StorageName == "" || StorageName == "CHOSEN" {
return task.StorageName
}
return StorageName
}()
taskStorage, err := storage.GetStorageByUserIDAndName(task.UserID, taskStorageName)
if err != nil {
common.Log.Errorf("failed to get storage: %s", err)
return nil, ""
}
task.StoragePath = path.Join(DirPath, task.StoragePath)
return taskStorage, taskStorage.JoinStoragePath(task)
}

82
core/tftask/execute.go Normal file
View File

@@ -0,0 +1,82 @@
package tftask
import (
"context"
"fmt"
"os"
"path"
"time"
"github.com/charmbracelet/log"
"github.com/krau/SaveAny-Bot/common/tdler"
"github.com/krau/SaveAny-Bot/common/utils/fsutil"
"github.com/krau/SaveAny-Bot/config"
"github.com/krau/SaveAny-Bot/pkg/enums/key"
)
func (t *TGFileTask) Execute(ctx context.Context) error {
logger := log.FromContext(ctx).WithPrefix(fmt.Sprintf("file[%s]", t.File.Name()))
t.Progress.OnStart(ctx, t)
if t.stream {
return executeStream(ctx, t)
}
logger.Info("Starting file download")
localFile, err := fsutil.CreateFile(t.localPath)
if err != nil {
return fmt.Errorf("failed to create local file: %w", err)
}
defer func() {
if err := localFile.CloseAndRemove(); err != nil {
logger.Errorf("Failed to close local file: %v", err)
}
}()
wrAt := newWriterAt(ctx, localFile, t.Progress, t)
defer func() {
t.Progress.OnDone(ctx, t, err)
}()
_, err = tdler.NewDownloader(t.client, t.File).Parallel(ctx, wrAt)
if err != nil {
return fmt.Errorf("failed to download file: %w", err)
}
logger.Infof("File downloaded successfully")
if path.Ext(t.File.Name()) == "" {
ext := fsutil.DetectFileExt(t.localPath)
if ext != "" {
t.Path = t.Path + ext
}
}
var fileStat os.FileInfo
fileStat, err = os.Stat(t.localPath)
if err != nil {
return fmt.Errorf("failed to get file stat: %w", err)
}
vctx := context.WithValue(ctx, key.ContextKeyContentLength, fileStat.Size())
for i := range config.Cfg.Retry + 1 {
if err = vctx.Err(); err != nil {
return fmt.Errorf("context canceled while saving file: %w", err)
}
var file *os.File
file, err = os.Open(t.localPath)
if err != nil {
return fmt.Errorf("failed to open cache file: %w", err)
}
defer file.Close()
if err = t.Storage.Save(vctx, file, t.Path); err != nil {
if i == config.Cfg.Retry {
return fmt.Errorf("failed to save file: %w", err)
}
logger.Errorf("Failed to save file: %s, retrying...", err)
select {
case <-vctx.Done():
return fmt.Errorf("context canceled during retry delay: %w", vctx.Err())
case <-time.After(time.Duration(i*500) * time.Millisecond):
}
continue
}
return nil
}
return fmt.Errorf("failed to save file after retries")
}

186
core/tftask/progress.go Normal file
View File

@@ -0,0 +1,186 @@
package tftask
import (
"context"
"errors"
"fmt"
"sync/atomic"
"time"
"github.com/charmbracelet/log"
"github.com/gotd/td/telegram/message/entity"
"github.com/gotd/td/telegram/message/styling"
"github.com/gotd/td/tg"
"github.com/krau/SaveAny-Bot/common/utils/dlutil"
"github.com/krau/SaveAny-Bot/common/utils/tgutil"
)
type ProgressTracker interface {
OnStart(ctx context.Context, info TaskInfo)
OnProgress(ctx context.Context, info TaskInfo, downloaded, total int64)
OnDone(ctx context.Context, info TaskInfo, err error)
}
type Progress struct {
MessageID int
ChatID int64
start time.Time
lastUpdatePercent atomic.Int32
}
func (p *Progress) OnStart(ctx context.Context, info TaskInfo) {
p.start = time.Now()
p.lastUpdatePercent.Store(0)
log.FromContext(ctx).Debugf("Progress tracking started for message %d in chat %d", p.MessageID, p.ChatID)
entityBuilder := entity.Builder{}
var entities []tg.MessageEntityClass
if err := styling.Perform(&entityBuilder,
styling.Plain("开始下载\n文件名: "),
styling.Code(info.FileName()),
styling.Plain("\n保存路径: "),
styling.Code(fmt.Sprintf("[%s]:%s", info.StorageName(), info.StoragePath())),
styling.Plain("\n文件大小: "),
styling.Code(fmt.Sprintf("%.2f MB", float64(info.FileSize())/(1024*1024))),
); err != nil {
log.FromContext(ctx).Errorf("Failed to build entities: %s", err)
return
}
text, entities := entityBuilder.Complete()
req := &tg.MessagesEditMessageRequest{
ID: p.MessageID,
}
req.SetMessage(text)
req.SetEntities(entities)
req.SetReplyMarkup(&tg.ReplyInlineMarkup{
Rows: []tg.KeyboardButtonRow{
{
Buttons: []tg.KeyboardButtonClass{
tgutil.BuildCancelButton(info.TaskID()),
},
},
}},
)
ext := tgutil.ExtFromContext(ctx)
if ext != nil {
ext.EditMessage(p.ChatID, req)
return
}
}
func (p *Progress) OnProgress(ctx context.Context, info TaskInfo, downloaded, total int64) {
if !shouldUpdateProgress(total, downloaded, int(p.lastUpdatePercent.Load())) {
return
}
percent := int32((downloaded * 100) / total)
if p.lastUpdatePercent.Load() == percent {
return
}
p.lastUpdatePercent.Store(percent)
log.FromContext(ctx).Debugf("Progress update: %s, %d/%d", info.FileName(), downloaded, total)
entityBuilder := entity.Builder{}
var entities []tg.MessageEntityClass
if err := styling.Perform(&entityBuilder,
styling.Plain("正在处理下载任务\n文件名: "),
styling.Code(info.FileName()),
styling.Plain("\n保存路径: "),
styling.Code(fmt.Sprintf("[%s]:%s", info.StorageName(), info.StoragePath())),
styling.Plain("\n文件大小: "),
styling.Code(fmt.Sprintf("%.2f MB", float64(total)/(1024*1024))),
styling.Plain("\n平均速度: "),
styling.Bold(fmt.Sprintf("%.2f MB/s", dlutil.GetSpeed(downloaded, p.start)/(1024*1024))),
styling.Plain("\n当前进度: "),
styling.Bold(fmt.Sprintf("%.2f%%", float64(downloaded)/float64(total)*100)),
); err != nil {
log.FromContext(ctx).Errorf("Failed to build entities: %s", err)
return
}
text, entities := entityBuilder.Complete()
req := &tg.MessagesEditMessageRequest{
ID: p.MessageID,
}
req.SetMessage(text)
req.SetEntities(entities)
req.SetReplyMarkup(&tg.ReplyInlineMarkup{
Rows: []tg.KeyboardButtonRow{
{
Buttons: []tg.KeyboardButtonClass{
tgutil.BuildCancelButton(info.TaskID()),
},
},
}},
)
ext := tgutil.ExtFromContext(ctx)
if ext != nil {
ext.EditMessage(p.ChatID, req)
return
}
}
func (p *Progress) OnDone(ctx context.Context, info TaskInfo, err error) {
if err != nil {
log.FromContext(ctx).Errorf("Progress error for file [%s]: %v", info.FileName(), err)
} else {
log.FromContext(ctx).Debugf("Progress done for file [%s]", info.FileName())
}
entityBuilder := entity.Builder{}
var stylingErr error
if err != nil {
if errors.Is(err, context.Canceled) {
stylingErr = styling.Perform(&entityBuilder,
styling.Plain("任务已取消\n文件名: "),
styling.Code(info.FileName()),
)
} else {
stylingErr = styling.Perform(&entityBuilder,
styling.Plain("下载失败\n文件名: "),
styling.Code(info.FileName()),
styling.Plain("\n错误: "),
styling.Bold(err.Error()),
)
}
} else {
stylingErr = styling.Perform(&entityBuilder,
styling.Plain("下载完成\n文件名: "),
styling.Code(info.FileName()),
styling.Plain("\n保存路径: "),
styling.Code(fmt.Sprintf("[%s]:%s", info.StorageName(), info.StoragePath())),
)
}
if stylingErr != nil {
log.FromContext(ctx).Errorf("Failed to build entities: %s", stylingErr)
return
}
text, entities := entityBuilder.Complete()
req := &tg.MessagesEditMessageRequest{
ID: p.MessageID,
}
req.SetMessage(text)
req.SetEntities(entities)
ext := tgutil.ExtFromContext(ctx)
if ext != nil {
ext.EditMessage(p.ChatID, req)
}
}
type ProgressOption func(*Progress)
func NewProgressTrack(
messageID int,
chatID int64,
opts ...ProgressOption,
) ProgressTracker {
p := &Progress{
MessageID: messageID,
ChatID: chatID,
}
for _, opt := range opts {
opt(p)
}
return p
}

40
core/tftask/stream.go Normal file
View File

@@ -0,0 +1,40 @@
package tftask
import (
"context"
"fmt"
"io"
"github.com/charmbracelet/log"
"github.com/krau/SaveAny-Bot/common/tdler"
"golang.org/x/sync/errgroup"
)
func executeStream(ctx context.Context, task *TGFileTask) error {
logger := log.FromContext(ctx).WithPrefix(fmt.Sprintf("file[%s]", task.File.Name()))
pr, pw := io.Pipe()
defer pr.Close()
errg, uploadCtx := errgroup.WithContext(ctx)
errg.Go(func() error {
return task.Storage.Save(uploadCtx, pr, task.Path)
})
wr := newWriter(ctx, pw, task.Progress, task)
errg.Go(func() error {
logger.Info("Starting file download in stream mode")
_, err := tdler.NewDownloader(task.client, task.File).Stream(uploadCtx, wr)
if closeErr := pw.CloseWithError(err); closeErr != nil {
logger.Errorf("Failed to close pipe writer: %v", closeErr)
}
return err
})
var err error
defer func() {
task.Progress.OnDone(ctx, task, err)
}()
if err = errg.Wait(); err != nil {
return err
}
logger.Info("File downloaded successfully in stream mode")
return nil
}

29
core/tftask/taskinfo.go Normal file
View File

@@ -0,0 +1,29 @@
package tftask
type TaskInfo interface {
TaskID() string
FileName() string
FileSize() int64
StoragePath() string
StorageName() string
}
func (t *TGFileTask) TaskID() string {
return t.ID
}
func (t *TGFileTask) FileName() string {
return t.File.Name()
}
func (t *TGFileTask) FileSize() int64 {
return t.File.Size()
}
func (t *TGFileTask) StoragePath() string {
return t.Path
}
func (t *TGFileTask) StorageName() string {
return t.Storage.Name()
}

64
core/tftask/tftask.go Normal file
View File

@@ -0,0 +1,64 @@
package tftask
import (
"context"
"fmt"
"path/filepath"
"github.com/krau/SaveAny-Bot/common/tdler"
"github.com/krau/SaveAny-Bot/config"
"github.com/krau/SaveAny-Bot/pkg/tfile"
"github.com/krau/SaveAny-Bot/storage"
)
type TGFileTask struct {
ID string
Ctx context.Context
File tfile.TGFile
Storage storage.Storage
Path string
Progress ProgressTracker
client tdler.Client
stream bool // true if the file should be downloaded in stream mode
localPath string
}
func NewTGFileTask(
id string,
ctx context.Context,
file tfile.TGFile,
client tdler.Client,
stor storage.Storage,
path string,
progress ProgressTracker,
) (*TGFileTask, error) {
_, ok := stor.(storage.StorageCannotStream)
if !config.Cfg.Stream || ok {
cachePath, err := filepath.Abs(filepath.Join(config.Cfg.Temp.BasePath, fmt.Sprintf("%s_%s", id, file.Name())))
if err != nil {
return nil, fmt.Errorf("failed to get absolute path for cache: %w", err)
}
tftask := &TGFileTask{
ID: id,
Ctx: ctx,
client: client,
File: file,
Storage: stor,
Path: path,
Progress: progress,
localPath: cachePath,
}
return tftask, nil
}
tfileTask := &TGFileTask{
ID: id,
Ctx: ctx,
client: client,
File: file,
Storage: stor,
Path: path,
Progress: progress,
stream: true,
}
return tfileTask, nil
}

32
core/tftask/util.go Normal file
View File

@@ -0,0 +1,32 @@
package tftask
var progressUpdatesLevels = []struct {
size int64 // 文件大小阈值
stepPercent int // 每多少 % 更新一次
}{
{10 << 20, 100},
{50 << 20, 20},
{200 << 20, 10},
{500 << 20, 5},
}
func shouldUpdateProgress(total, downloaded int64, lastUpdatePercent int) bool {
if total <= 0 || downloaded <= 0 {
return false
}
percent := int((downloaded * 100) / total)
if percent <= lastUpdatePercent {
return false
}
step := progressUpdatesLevels[len(progressUpdatesLevels)-1].stepPercent
for _, lvl := range progressUpdatesLevels {
if total < lvl.size {
step = lvl.stepPercent
break
}
}
return percent >= lastUpdatePercent+step
}

75
core/tftask/writer.go Normal file
View File

@@ -0,0 +1,75 @@
package tftask
import (
"context"
"io"
"sync/atomic"
)
type ProgressWriterAt struct {
ctx context.Context
wrAt io.WriterAt
progress ProgressTracker
downloaded *atomic.Int64
total int64
info TaskInfo
}
func (w *ProgressWriterAt) WriteAt(p []byte, off int64) (int, error) {
at, err := w.wrAt.WriteAt(p, off)
if err != nil {
return 0, err
}
w.progress.OnProgress(w.ctx, w.info, w.downloaded.Add(int64(at)), w.total)
return at, nil
}
func newWriterAt(
ctx context.Context,
wrAt io.WriterAt,
progress ProgressTracker,
taskInfo TaskInfo,
) *ProgressWriterAt {
return &ProgressWriterAt{
ctx: ctx,
progress: progress,
downloaded: &atomic.Int64{},
total: taskInfo.FileSize(),
wrAt: wrAt,
info: taskInfo,
}
}
type ProgressWriter struct {
ctx context.Context
wrAt io.Writer
progress ProgressTracker
downloaded *atomic.Int64
total int64
info TaskInfo
}
func (w *ProgressWriter) Write(p []byte) (int, error) {
at, err := w.wrAt.Write(p)
if err != nil {
return 0, err
}
w.progress.OnProgress(w.ctx, w.info, w.downloaded.Add(int64(at)), w.total)
return at, nil
}
func newWriter(
ctx context.Context,
wr io.Writer,
progress ProgressTracker,
taskInfo TaskInfo,
) *ProgressWriter {
return &ProgressWriter{
ctx: ctx,
progress: progress,
downloaded: &atomic.Int64{},
total: taskInfo.FileSize(),
wrAt: wr,
info: taskInfo,
}
}

94
core/tphtask/execute.go Normal file
View File

@@ -0,0 +1,94 @@
package tphtask
import (
"context"
"fmt"
"io"
"path"
"path/filepath"
"github.com/charmbracelet/log"
"github.com/duke-git/lancet/v2/retry"
"github.com/krau/SaveAny-Bot/common/utils/fsutil"
"github.com/krau/SaveAny-Bot/config"
"go.uber.org/multierr"
"golang.org/x/sync/errgroup"
)
func (t *Task) Execute(ctx context.Context) error {
logger := log.FromContext(ctx)
logger.Infof("Starting Telegraph task %s", t.PhPath)
t.progress.OnStart(ctx, t)
eg, gctx := errgroup.WithContext(ctx)
eg.SetLimit(config.Cfg.Workers)
for i, pic := range t.Pics {
pic := pic
i := i
eg.Go(func() error {
err := t.processPic(gctx, pic, i)
if err != nil {
logger.Errorf("Error processing picture %s: %v", pic, err)
return fmt.Errorf("failed to process picture %s: %w", pic, err)
}
t.downloaded.Add(1)
t.progress.OnProgress(gctx, t)
return nil
})
}
err := eg.Wait()
if err != nil {
logger.Errorf("Error during Telegraph task execution: %v", err)
} else {
logger.Infof("Telegraph task %s completed successfully", t.PhPath)
}
t.progress.OnDone(ctx, t, err)
return err
}
func (t *Task) processPic(ctx context.Context, picUrl string, index int) error {
retryOpts := []retry.Option{
retry.Context(ctx),
retry.RetryTimes(uint(config.Cfg.Retry)),
}
var lastErr error
err := retry.Retry(func() error {
var body io.ReadCloser
body, lastErr = t.client.Download(ctx, picUrl)
if lastErr != nil {
lastErr = fmt.Errorf("failed to download picture %s: %w", picUrl, lastErr)
return lastErr
}
defer body.Close()
filename := fmt.Sprintf("%d%s", index+1, path.Ext(picUrl))
if t.cannotStream {
cacheFile, err := fsutil.CreateFile(filepath.Join(config.Cfg.Temp.BasePath,
fmt.Sprintf("tph_%s_%s", t.TaskID(), filename),
))
if err != nil {
lastErr = fmt.Errorf("failed to create cache file for picture %s: %w", filename, err)
return lastErr
}
defer func() {
if err := cacheFile.CloseAndRemove(); err != nil {
logger := log.FromContext(ctx)
logger.Errorf("Failed to close and remove cache file for picture %s: %v", filename, err)
}
}()
_, lastErr = io.Copy(cacheFile, body)
if lastErr != nil {
lastErr = fmt.Errorf("failed to copy picture %s to cache file: %w", filename, lastErr)
return lastErr
}
lastErr = t.Stor.Save(ctx, cacheFile, path.Join(t.StorPath, filename))
} else {
lastErr = t.Stor.Save(ctx, body, path.Join(t.StorPath, filename))
}
if lastErr != nil {
lastErr = fmt.Errorf("failed to save picture %s: %w", filename, lastErr)
return lastErr
}
return nil
}, retryOpts...)
return multierr.Combine(err, lastErr)
}

150
core/tphtask/progress.go Normal file
View File

@@ -0,0 +1,150 @@
package tphtask
import (
"context"
"errors"
"fmt"
"github.com/charmbracelet/log"
"github.com/gotd/td/telegram/message/entity"
"github.com/gotd/td/telegram/message/styling"
"github.com/gotd/td/tg"
"github.com/krau/SaveAny-Bot/common/utils/tgutil"
)
type ProgressTracker interface {
OnStart(ctx context.Context, info TaskInfo)
OnProgress(ctx context.Context, info TaskInfo)
OnDone(ctx context.Context, info TaskInfo, err error)
}
type Progress struct {
MessageID int
ChatID int64
}
func (p *Progress) OnStart(ctx context.Context, info TaskInfo) {
logger := log.FromContext(ctx)
logger.Debugf("Telegraph task progress tracking started for message %d in chat %d", p.MessageID, p.ChatID)
entityBuilder := entity.Builder{}
var entities []tg.MessageEntityClass
if err := styling.Perform(&entityBuilder,
styling.Plain("开始下载Telegraph\n图片数量: "),
styling.Code(fmt.Sprintf("%d", info.TotalPics())),
); err != nil {
log.FromContext(ctx).Errorf("Failed to build entities: %s", err)
return
}
text, entities := entityBuilder.Complete()
req := &tg.MessagesEditMessageRequest{
ID: p.MessageID,
}
req.SetMessage(text)
req.SetEntities(entities)
req.SetReplyMarkup(&tg.ReplyInlineMarkup{
Rows: []tg.KeyboardButtonRow{
{
Buttons: []tg.KeyboardButtonClass{
tgutil.BuildCancelButton(info.TaskID()),
},
},
}},
)
ext := tgutil.ExtFromContext(ctx)
if ext != nil {
ext.EditMessage(p.ChatID, req)
return
}
}
func (p *Progress) OnProgress(ctx context.Context, info TaskInfo) {
if !shouldUpdateProgress(info.Downloaded(), int64(info.TotalPics())) {
return
}
log.FromContext(ctx).Debugf("Progress update: %s, %d/%d", info.TaskID(), info.Downloaded(), info.TotalPics())
entityBuilder := entity.Builder{}
var entities []tg.MessageEntityClass
if err := styling.Perform(&entityBuilder,
styling.Plain("正在下载\n当前进度: "),
styling.Code(fmt.Sprintf("%d/%d", info.Downloaded(), info.TotalPics())),
); err != nil {
log.FromContext(ctx).Errorf("Failed to build entities: %s", err)
return
}
text, entities := entityBuilder.Complete()
req := &tg.MessagesEditMessageRequest{
ID: p.MessageID,
}
req.SetMessage(text)
req.SetEntities(entities)
req.SetReplyMarkup(&tg.ReplyInlineMarkup{
Rows: []tg.KeyboardButtonRow{
{
Buttons: []tg.KeyboardButtonClass{
tgutil.BuildCancelButton(info.TaskID()),
},
},
}},
)
ext := tgutil.ExtFromContext(ctx)
if ext != nil {
ext.EditMessage(p.ChatID, req)
return
}
}
func (p *Progress) OnDone(ctx context.Context, info TaskInfo, err error) {
logger := log.FromContext(ctx)
if err != nil {
if errors.Is(err, context.Canceled) {
logger.Infof("Telegraph task %s was canceled", info.TaskID())
ext := tgutil.ExtFromContext(ctx)
if ext != nil {
ext.EditMessage(p.ChatID, &tg.MessagesEditMessageRequest{
ID: p.MessageID,
Message: fmt.Sprintf("处理已取消: %s", info.TaskID()),
})
}
} else {
logger.Errorf("Telegraph task %s failed: %s", info.TaskID(), err)
ext := tgutil.ExtFromContext(ctx)
if ext != nil {
ext.EditMessage(p.ChatID, &tg.MessagesEditMessageRequest{
ID: p.MessageID,
Message: fmt.Sprintf("处理失败: %s", err.Error()),
})
}
}
return
}
logger.Infof("Telegraph task %s completed successfully", info.TaskID())
entityBuilder := entity.Builder{}
if err := styling.Perform(&entityBuilder,
styling.Plain("处理完成\n图片数量: "),
styling.Code(fmt.Sprintf("%d", info.TotalPics())),
styling.Plain("\n保存路径: "),
styling.Code(fmt.Sprintf("[%s]:%s", info.StorageName(), info.StoragePath())),
); err != nil {
logger.Errorf("Failed to build entities: %s", err)
return
}
text, entities := entityBuilder.Complete()
req := &tg.MessagesEditMessageRequest{
ID: p.MessageID,
}
req.SetMessage(text)
req.SetEntities(entities)
ext := tgutil.ExtFromContext(ctx)
if ext != nil {
ext.EditMessage(p.ChatID, req)
}
}
func NewProgress(messageID int, chatID int64) *Progress {
return &Progress{
MessageID: messageID,
ChatID: chatID,
}
}

51
core/tphtask/task.go Normal file
View File

@@ -0,0 +1,51 @@
package tphtask
import (
"context"
"sync/atomic"
"github.com/krau/SaveAny-Bot/pkg/telegraph"
"github.com/krau/SaveAny-Bot/storage"
)
type Task struct {
ID string
Ctx context.Context
PhPath string
Pics []string
Stor storage.Storage
StorPath string
client *telegraph.Client
progress ProgressTracker
cannotStream bool
totalpics int
downloaded atomic.Int64
}
func NewTask(
id string,
ctx context.Context,
phPath string,
pics []string,
stor storage.Storage,
storPath string,
client *telegraph.Client,
progress ProgressTracker,
) *Task {
_, cannotStream := stor.(storage.StorageCannotStream)
tphtask := &Task{
ID: id,
Ctx: ctx,
PhPath: phPath,
Pics: pics,
Stor: stor,
StorPath: storPath,
client: client,
progress: progress,
cannotStream: cannotStream,
totalpics: len(pics),
downloaded: atomic.Int64{},
}
return tphtask
}

34
core/tphtask/taskinfo.go Normal file
View File

@@ -0,0 +1,34 @@
package tphtask
type TaskInfo interface {
TaskID() string
Phpath() string
TotalPics() int
Downloaded() int64
StorageName() string
StoragePath() string
}
func (t *Task) TaskID() string {
return t.ID
}
func (t *Task) Phpath() string {
return t.PhPath
}
func (t *Task) TotalPics() int {
return t.totalpics
}
func (t *Task) Downloaded() int64 {
return t.downloaded.Load()
}
func (t *Task) StorageName() string {
return t.Stor.Name()
}
func (t *Task) StoragePath() string {
return t.StorPath
}

13
core/tphtask/utils.go Normal file
View File

@@ -0,0 +1,13 @@
package tphtask
func shouldUpdateProgress(downloaded int64, total int64) bool {
if total <= 0 || downloaded <= 0 {
return false
}
step := int64(10)
if downloaded < step {
return downloaded == total
}
return downloaded%step == 0 || downloaded == total
}

View File

@@ -1,303 +0,0 @@
package core
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"os"
"path"
"time"
"github.com/celestix/gotgproto/ext"
"github.com/celestix/telegraph-go/v2"
"github.com/gabriel-vasile/mimetype"
"github.com/gotd/td/telegram/message/entity"
"github.com/gotd/td/telegram/message/styling"
"github.com/gotd/td/tg"
"github.com/krau/SaveAny-Bot/bot"
"github.com/krau/SaveAny-Bot/common"
"github.com/krau/SaveAny-Bot/config"
"github.com/krau/SaveAny-Bot/storage"
"github.com/krau/SaveAny-Bot/types"
"github.com/krau/SaveAny-Bot/userclient"
)
func saveFileWithRetry(ctx context.Context, storagePath string, taskStorage storage.Storage, cacheFilePath string) error {
file, err := os.Open(cacheFilePath)
if err != nil {
return fmt.Errorf("failed to open cache file: %w", err)
}
defer file.Close()
fileStat, err := file.Stat()
if err != nil {
return fmt.Errorf("failed to get file stat: %w", err)
}
vctx := context.WithValue(ctx, types.ContextKeyContentLength, fileStat.Size())
for i := 0; i <= config.Cfg.Retry; i++ {
if err := vctx.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)
}
defer file.Close()
if err := taskStorage.Save(vctx, file, storagePath); err != nil {
if i == config.Cfg.Retry {
return fmt.Errorf("failed to save file: %w", err)
}
common.Log.Errorf("Failed to save file: %s, retrying...", err)
select {
case <-vctx.Done():
return fmt.Errorf("context canceled during retry delay: %w", vctx.Err())
case <-time.After(time.Duration(i*500) * time.Millisecond):
}
continue
}
return nil
}
return nil
}
func processPhoto(task *types.Task, taskStorage storage.Storage) error {
api := bot.Client.API()
if task.UseUserClient && userclient.UC != nil {
api = userclient.UC.API()
}
res, err := api.UploadGetFile(task.Ctx, &tg.UploadGetFileRequest{
Location: task.File.Location,
Offset: 0,
Limit: 1024 * 1024,
})
if err != nil {
return fmt.Errorf("failed to get file: %w", err)
}
result, ok := res.(*tg.UploadFile)
if !ok {
return fmt.Errorf("unexpected type %T", res)
}
common.Log.Infof("Downloaded photo: %s", task.FileName())
return taskStorage.Save(task.Ctx, bytes.NewReader(result.Bytes), task.StoragePath)
}
func cleanCacheFile(destPath string) {
if config.Cfg.Temp.CacheTTL > 0 {
common.RmFileAfter(destPath, time.Duration(config.Cfg.Temp.CacheTTL)*time.Second)
} else {
if err := os.Remove(destPath); err != nil {
common.Log.Errorf("Failed to purge file: %s", err)
}
}
}
// 获取进度需要更新的次数
func getProgressUpdateCount(fileSize int64) int {
updateCount := 5
if fileSize > 1024*1024*1000 {
updateCount = 50
} else if fileSize > 1024*1024*500 {
updateCount = 20
} else if fileSize > 1024*1024*200 {
updateCount = 10
}
return updateCount
}
func getSpeed(bytesRead int64, startTime time.Time) string {
if startTime.IsZero() {
return "0MB/s"
}
elapsed := time.Since(startTime)
speed := float64(bytesRead) / 1024 / 1024 / elapsed.Seconds()
return fmt.Sprintf("%.2fMB/s", speed)
}
func buildProgressMessageEntity(task *types.Task, bytesRead int64, startTime time.Time, progress float64) (string, []tg.MessageEntityClass) {
entityBuilder := entity.Builder{}
text := fmt.Sprintf("正在处理下载任务\n文件名: %s\n保存路径: %s\n平均速度: %s\n当前进度: %.2f%%",
task.FileName(),
fmt.Sprintf("[%s]:%s", task.StorageName, task.StoragePath),
getSpeed(bytesRead, startTime),
progress,
)
var entities []tg.MessageEntityClass
if err := styling.Perform(&entityBuilder,
styling.Plain("正在处理下载任务\n文件名: "),
styling.Code(task.FileName()),
styling.Plain("\n保存路径: "),
styling.Code(fmt.Sprintf("[%s]:%s", task.StorageName, task.StoragePath)),
styling.Plain("\n平均速度: "),
styling.Bold(getSpeed(bytesRead, task.StartTime)),
styling.Plain("\n当前进度: "),
styling.Bold(fmt.Sprintf("%.2f%%", progress)),
); err != nil {
common.Log.Errorf("Failed to build entities: %s", err)
return text, entities
}
return entityBuilder.Complete()
}
func buildProgressCallback(ctx *ext.Context, task *types.Task, updateCount int) func(bytesRead, contentLength int64) {
return func(bytesRead, contentLength int64) {
progress := float64(bytesRead) / float64(contentLength) * 100
common.Log.Tracef("Downloading %s: %.2f%%", task.String(), progress)
progressInt := int(progress)
if task.File.FileSize < 1024*1024*50 || progressInt == 0 || progressInt%int(100/updateCount) != 0 {
return
}
if task.ReplyMessageID == 0 {
return
}
text, entities := buildProgressMessageEntity(task, bytesRead, task.StartTime, progress)
ctx.EditMessage(task.ReplyChatID, &tg.MessagesEditMessageRequest{
Message: text,
Entities: entities,
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) {
if path.Ext(task.FileName()) == "" {
mimeType, err := mimetype.DetectFile(localFilePath)
if err != nil {
common.Log.Errorf("Failed to detect mime type: %s", err)
} else {
task.File.FileName = fmt.Sprintf("%s%s", task.FileName(), mimeType.Extension())
task.StoragePath = fmt.Sprintf("%s%s", task.StoragePath, mimeType.Extension())
}
}
}
func getTaskThreads(fileSize int64) int {
threads := 1
if fileSize > 1024*1024*100 {
threads = config.Cfg.Threads
} else if fileSize > 1024*1024*50 {
threads = config.Cfg.Threads / 2
}
return threads
}
type TaskLocalFile struct {
file *os.File
size int64
done int64
progressCallback func(bytesRead, contentLength int64)
callbackTimes int64
nextCallbackAt int64
callbackInterval int64
}
func (t *TaskLocalFile) Read(p []byte) (n int, err error) {
return t.file.Read(p)
}
func (t *TaskLocalFile) Close() error {
return t.file.Close()
}
func (t *TaskLocalFile) WriteAt(p []byte, off int64) (int, error) {
n, err := t.file.WriteAt(p, off)
if err != nil {
return n, err
}
t.done += int64(n)
if t.progressCallback != nil && t.done >= t.nextCallbackAt {
t.progressCallback(t.done, t.size)
t.nextCallbackAt += t.callbackInterval
}
return n, nil
}
func NewTaskLocalFile(filePath string, fileSize int64, progressCallback func(bytesRead, contentLength int64)) (*TaskLocalFile, error) {
file, err := os.Create(filePath)
if err != nil {
return nil, fmt.Errorf("failed to open file: %w", err)
}
var callbackInterval int64
callbackInterval = fileSize / 100
if callbackInterval == 0 {
callbackInterval = 1
}
return &TaskLocalFile{
file: file,
size: fileSize,
progressCallback: progressCallback,
callbackTimes: 100,
nextCallbackAt: callbackInterval,
callbackInterval: callbackInterval,
}, 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,
}
}
func getNodeImages(node telegraph.Node) []string {
var srcs []string
var nodeElement telegraph.NodeElement
data, err := json.Marshal(node)
if err != nil {
return srcs
}
err = json.Unmarshal(data, &nodeElement)
if err != nil {
return srcs
}
if nodeElement.Tag == "img" {
if src, exists := nodeElement.Attrs["src"]; exists {
srcs = append(srcs, src)
}
}
for _, child := range nodeElement.Children {
srcs = append(srcs, getNodeImages(child)...)
}
return srcs
}

Some files were not shown because too many files have changed in this diff Show More