Compare commits
40 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ea07ff7eca | ||
|
|
4d837e946c | ||
|
|
f947ee6fc7 | ||
|
|
40ad12a892 | ||
|
|
697e419643 | ||
|
|
eef051de3b | ||
|
|
6e29442c05 | ||
|
|
a3f1f75caf | ||
|
|
f05dd883e3 | ||
|
|
9cb866de8c | ||
|
|
980455fd24 | ||
|
|
24978470cd | ||
|
|
215e082028 | ||
|
|
a7b93e57fc | ||
|
|
a4b3b459a9 | ||
|
|
06f326088a | ||
|
|
b7d3ec6230 | ||
|
|
f812990e1c | ||
|
|
492900bbef | ||
|
|
764be2a083 | ||
|
|
46c21b77e9 | ||
|
|
8b389a58d5 | ||
|
|
25ad9befa0 | ||
|
|
e824b210d1 | ||
|
|
ae0aa7db3f | ||
|
|
226c15ef08 | ||
|
|
9b3f955e48 | ||
|
|
4997ec408f | ||
|
|
0756cc9eb1 | ||
|
|
37c32a23d4 | ||
|
|
3aa1e2eaed | ||
|
|
b87dd68880 | ||
|
|
68e5a51300 | ||
|
|
7300e54c40 | ||
|
|
94f796d0e8 | ||
|
|
c023fd869d | ||
|
|
e5d1e143e0 | ||
|
|
03eb4f8a18 | ||
|
|
231eb61d25 | ||
|
|
fd1b586b8d |
6
.github/workflows/build-release.yml
vendored
6
.github/workflows/build-release.yml
vendored
@@ -63,9 +63,9 @@ jobs:
|
||||
README.md
|
||||
ldflags: >-
|
||||
-s -w
|
||||
-X "github.com/krau/SaveAny-Bot/pkg/consts.Version=${{ env.VERSION }}"
|
||||
-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/pkg/consts.GitCommit=${{ github.sha }}"
|
||||
-X "github.com/krau/SaveAny-Bot/config.Version=${{ env.VERSION }}"
|
||||
-X "github.com/krau/SaveAny-Bot/config.BuildTime=${{ format(github.event.repository.updated_at, 'yyyy-MM-dd HH:mm:ss') }}"
|
||||
-X "github.com/krau/SaveAny-Bot/config.GitCommit=${{ github.sha }}"
|
||||
binary_name: saveany-bot
|
||||
env:
|
||||
VERSION: ${{ env.VERSION }}
|
||||
|
||||
@@ -17,9 +17,9 @@ RUN --mount=type=cache,target=/root/.cache/go-build \
|
||||
go build -trimpath \
|
||||
-ldflags=" \
|
||||
-s -w \
|
||||
-X 'github.com/krau/SaveAny-Bot/common.Version=${VERSION}' \
|
||||
-X 'github.com/krau/SaveAny-Bot/common.GitCommit=${GitCommit}' \
|
||||
-X 'github.com/krau/SaveAny-Bot/common.BuildTime=${BuildTime}' \
|
||||
-X 'github.com/krau/SaveAny-Bot/config.Version=${VERSION}' \
|
||||
-X 'github.com/krau/SaveAny-Bot/config.GitCommit=${GitCommit}' \
|
||||
-X 'github.com/krau/SaveAny-Bot/config.BuildTime=${BuildTime}' \
|
||||
" \
|
||||
-o saveany-bot .
|
||||
|
||||
|
||||
@@ -19,16 +19,17 @@ import (
|
||||
"golang.org/x/net/proxy"
|
||||
)
|
||||
|
||||
func Init(ctx context.Context) {
|
||||
func Init(ctx context.Context) <-chan struct{} {
|
||||
log.FromContext(ctx).Info("初始化 Bot...")
|
||||
resultChan := make(chan struct {
|
||||
client *gotgproto.Client
|
||||
err error
|
||||
})
|
||||
shouldRestart := make(chan struct{})
|
||||
go func() {
|
||||
var resolver dcs.Resolver
|
||||
if config.Cfg.Telegram.Proxy.Enable && config.Cfg.Telegram.Proxy.URL != "" {
|
||||
dialer, err := netutil.NewProxyDialer(config.Cfg.Telegram.Proxy.URL)
|
||||
if config.C().Telegram.Proxy.Enable && config.C().Telegram.Proxy.URL != "" {
|
||||
dialer, err := netutil.NewProxyDialer(config.C().Telegram.Proxy.URL)
|
||||
if err != nil {
|
||||
resultChan <- struct {
|
||||
client *gotgproto.Client
|
||||
@@ -43,19 +44,23 @@ func Init(ctx context.Context) {
|
||||
resolver = dcs.DefaultResolver()
|
||||
}
|
||||
client, err := gotgproto.NewClient(
|
||||
config.Cfg.Telegram.AppID,
|
||||
config.Cfg.Telegram.AppHash,
|
||||
gotgproto.ClientTypeBot(config.Cfg.Telegram.Token),
|
||||
config.C().Telegram.AppID,
|
||||
config.C().Telegram.AppHash,
|
||||
gotgproto.ClientTypeBot(config.C().Telegram.Token),
|
||||
&gotgproto.ClientOpts{
|
||||
Session: sessionMaker.SqlSession(gormlite.Open(config.Cfg.DB.Session)),
|
||||
Session: sessionMaker.SqlSession(gormlite.Open(config.C().DB.Session)),
|
||||
DisableCopyright: true,
|
||||
Middlewares: middleware.NewDefaultMiddlewares(ctx, 5*time.Minute),
|
||||
Resolver: resolver,
|
||||
Context: ctx,
|
||||
MaxRetries: config.Cfg.Telegram.RpcRetry,
|
||||
MaxRetries: config.C().Telegram.RpcRetry,
|
||||
AutoFetchReply: true,
|
||||
ErrorHandler: func(ctx *ext.Context, u *ext.Update, s string) error {
|
||||
log.FromContext(ctx).Errorf("Unhandled error: %s", s)
|
||||
if s == "SAVEANTBOT-RESTART" {
|
||||
shouldRestart <- struct{}{}
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
log.FromContext(ctx).Errorf("unhandled error: %s", s)
|
||||
return dispatcher.EndGroups
|
||||
},
|
||||
},
|
||||
@@ -70,18 +75,9 @@ func Init(ctx context.Context) {
|
||||
client.API().BotsSetBotCommands(ctx, &tg.BotsSetBotCommandsRequest{
|
||||
Scope: &tg.BotCommandScopeDefault{},
|
||||
})
|
||||
commands := []tg.BotCommand{
|
||||
{Command: "start", Description: "开始使用"},
|
||||
{Command: "help", Description: "显示帮助"},
|
||||
{Command: "silent", Description: "开启/关闭静默模式"},
|
||||
{Command: "storage", Description: "设置默认存储端"},
|
||||
{Command: "save", Description: "保存文件"},
|
||||
{Command: "dir", Description: "管理存储文件夹"},
|
||||
{Command: "rule", Description: "管理规则"},
|
||||
}
|
||||
if config.Cfg.Telegram.Userbot.Enable {
|
||||
commands = append(commands, tg.BotCommand{Command: "watch", Description: "监听聊天"})
|
||||
commands = append(commands, tg.BotCommand{Command: "unwatch", Description: "取消监听聊天"})
|
||||
commands := make([]tg.BotCommand, 0, len(handlers.CommandHandlers))
|
||||
for _, info := range handlers.CommandHandlers {
|
||||
commands = append(commands, tg.BotCommand{Command: info.Cmd, Description: info.Desc})
|
||||
}
|
||||
_, err = client.API().BotsSetBotCommands(ctx, &tg.BotsSetBotCommandsRequest{
|
||||
Scope: &tg.BotCommandScopeDefault{},
|
||||
@@ -103,4 +99,5 @@ func Init(ctx context.Context) {
|
||||
handlers.Register(result.client.Dispatcher)
|
||||
log.FromContext(ctx).Info("Bot 初始化完成")
|
||||
}
|
||||
return shouldRestart
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package handlers
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"github.com/celestix/gotgproto/dispatcher"
|
||||
@@ -11,6 +12,7 @@ import (
|
||||
"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/common/utils/fsutil"
|
||||
"github.com/krau/SaveAny-Bot/database"
|
||||
"github.com/krau/SaveAny-Bot/pkg/enums/tasktype"
|
||||
"github.com/krau/SaveAny-Bot/pkg/tcbdata"
|
||||
@@ -74,6 +76,9 @@ func handleAddCallback(ctx *ext.Context, update *ext.Update) error {
|
||||
case tasktype.TaskTypeTphpics:
|
||||
return shortcut.CreateAndAddtelegraphWithEdit(ctx, userID, data.TphPageNode, data.TphDirPath, data.TphPics, selectedStorage, msgID)
|
||||
case tasktype.TaskTypeParseditem:
|
||||
if len(data.ParsedItem.Resources) > 1 {
|
||||
dirPath = path.Join(dirPath, fsutil.NormalizePathname(data.ParsedItem.Title))
|
||||
}
|
||||
shortcut.CreateAndAddParsedTaskWithEdit(ctx, selectedStorage, dirPath, data.ParsedItem, msgID, userID)
|
||||
default:
|
||||
log.FromContext(ctx).Errorf("Unsupported task type: %s", data.TaskType)
|
||||
|
||||
144
client/bot/handlers/config.go
Normal file
144
client/bot/handlers/config.go
Normal file
@@ -0,0 +1,144 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"text/template"
|
||||
|
||||
"github.com/celestix/gotgproto/dispatcher"
|
||||
"github.com/celestix/gotgproto/ext"
|
||||
"github.com/gotd/td/tg"
|
||||
"github.com/krau/SaveAny-Bot/database"
|
||||
"github.com/krau/SaveAny-Bot/pkg/enums/fnamest"
|
||||
"github.com/krau/SaveAny-Bot/pkg/tcbdata"
|
||||
)
|
||||
|
||||
func handleConfigCmd(ctx *ext.Context, update *ext.Update) error {
|
||||
ctx.Reply(update, ext.ReplyTextString("请选择要配置的选项"), &ext.ReplyOpts{
|
||||
Markup: &tg.ReplyInlineMarkup{
|
||||
Rows: []tg.KeyboardButtonRow{
|
||||
{
|
||||
Buttons: []tg.KeyboardButtonClass{
|
||||
&tg.KeyboardButtonCallback{
|
||||
Text: "文件名策略",
|
||||
Data: fmt.Appendf(nil, "%s %s", tcbdata.TypeConfig, "fnamest"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
|
||||
func handleConfigCallback(ctx *ext.Context, update *ext.Update) error {
|
||||
args := strings.Fields(string(update.CallbackQuery.Data))
|
||||
invaildDataAnswer := func() error {
|
||||
ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{
|
||||
QueryID: update.CallbackQuery.GetQueryID(),
|
||||
Alert: true,
|
||||
Message: "无效的回调数据",
|
||||
CacheTime: 5,
|
||||
})
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
if len(args) < 2 {
|
||||
return invaildDataAnswer()
|
||||
}
|
||||
switch args[1] {
|
||||
case "fnamest":
|
||||
return handleConfigFnameSTCallback(ctx, update)
|
||||
default:
|
||||
return invaildDataAnswer()
|
||||
}
|
||||
}
|
||||
|
||||
func handleConfigFnameSTCallback(ctx *ext.Context, update *ext.Update) error {
|
||||
userID := update.CallbackQuery.GetUserID()
|
||||
user, err := database.GetUserByChatID(ctx, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
args := strings.Fields(string(update.CallbackQuery.Data))
|
||||
if len(args) == 3 {
|
||||
selected := args[2]
|
||||
st, err := fnamest.ParseFnameST(selected)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
user.FilenameStrategy = st.String()
|
||||
if err := database.UpdateUser(ctx, user); err != nil {
|
||||
return err
|
||||
}
|
||||
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
|
||||
ID: update.CallbackQuery.GetMsgID(),
|
||||
Message: fmt.Sprintf("已将文件名策略设置为: %s", fnamest.FnameSTDisplay[st]),
|
||||
})
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
opts := fnamest.FnameSTValues()
|
||||
buttons := make([]tg.KeyboardButtonClass, 0, len(opts))
|
||||
for _, opt := range opts {
|
||||
buttons = append(buttons, &tg.KeyboardButtonCallback{
|
||||
Text: fnamest.FnameSTDisplay[opt],
|
||||
Data: fmt.Appendf(nil, "%s %s %s", tcbdata.TypeConfig, "fnamest", opt),
|
||||
})
|
||||
}
|
||||
markup := &tg.ReplyInlineMarkup{Rows: []tg.KeyboardButtonRow{
|
||||
{Buttons: buttons},
|
||||
}}
|
||||
currentStStr := user.FilenameStrategy
|
||||
if currentStStr == "" {
|
||||
currentStStr = fnamest.Default.String()
|
||||
}
|
||||
currentSt, err := fnamest.ParseFnameST(currentStStr)
|
||||
if err != nil {
|
||||
currentSt = fnamest.Default
|
||||
}
|
||||
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
|
||||
ID: update.CallbackQuery.GetMsgID(),
|
||||
Message: fmt.Sprintf("请选择文件名策略, 当前策略: %s", fnamest.FnameSTDisplay[currentSt]),
|
||||
ReplyMarkup: markup,
|
||||
})
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
|
||||
func handleConfigFnameTmpl(ctx *ext.Context, update *ext.Update) error {
|
||||
userID := update.GetUserChat().GetID()
|
||||
user, err := database.GetUserByChatID(ctx, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
args := strings.Fields(string(update.EffectiveMessage.Text))
|
||||
if len(args) <= 1 {
|
||||
text := `使用该命令设置文件名模板, 示例:
|
||||
/fnametmpl 图片_{{.msgid}}_{{.msgdate}}.jpg
|
||||
|
||||
可用变量:
|
||||
- {{.msgid}}: 消息ID
|
||||
- {{.msgtags}}: 消息中的标签, 将以下划线分隔输出
|
||||
- {{.msggen}}: 根据消息生成的文件名
|
||||
- {{.msgdate}}: 消息日期, 格式 YYYY-MM-DD_HH-MM-SS
|
||||
- {{.origname}}: 媒体的原始文件名 (如果有)
|
||||
- {{.chatid}}: 消息的聊天ID
|
||||
`
|
||||
if user.FilenameTemplate != "" {
|
||||
text += fmt.Sprintf("\n\n当前模板: %s", user.FilenameTemplate)
|
||||
}
|
||||
text += "\n\n模板仅在文件名策略设置为 '自定义模板' 时生效, 且模板解析错误时会回退到默认文件名"
|
||||
ctx.Reply(update, ext.ReplyTextString(text), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
newTmpl := strings.Join(args[1:], " ")
|
||||
_, err = template.New("filename").Parse(newTmpl)
|
||||
if err != nil {
|
||||
ctx.Reply(update, ext.ReplyTextString("无效的模板, 请检查语法\n"+err.Error()), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
user.FilenameTemplate = newTmpl
|
||||
if err := database.UpdateUser(ctx, user); err != nil {
|
||||
return err
|
||||
}
|
||||
ctx.Reply(update, ext.ReplyTextString("已更新文件名模板"), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
@@ -5,29 +5,16 @@ import (
|
||||
|
||||
"github.com/celestix/gotgproto/dispatcher"
|
||||
"github.com/celestix/gotgproto/ext"
|
||||
"github.com/krau/SaveAny-Bot/pkg/consts"
|
||||
"github.com/krau/SaveAny-Bot/common/i18n"
|
||||
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
|
||||
"github.com/krau/SaveAny-Bot/config"
|
||||
)
|
||||
|
||||
func handleHelpCmd(ctx *ext.Context, update *ext.Update) error {
|
||||
const helpText string = `
|
||||
Save Any Bot - 转存你的 Telegram 文件
|
||||
版本: %s , 提交: %s
|
||||
|
||||
命令:
|
||||
/start - 开始使用
|
||||
/help - 显示帮助
|
||||
/silent - 开关静默模式
|
||||
/storage - 设置默认存储位置
|
||||
/save [自定义文件名] - 保存文件
|
||||
/dir - 管理存储目录
|
||||
/rule - 管理规则
|
||||
|
||||
使用帮助: https://sabot.unv.app/usage/
|
||||
`
|
||||
shortHash := consts.GitCommit
|
||||
shortHash := config.GitCommit
|
||||
if len(shortHash) > 7 {
|
||||
shortHash = shortHash[:7]
|
||||
}
|
||||
ctx.Reply(update, ext.ReplyTextString(fmt.Sprintf(helpText, consts.Version, shortHash)), nil)
|
||||
ctx.Reply(update, ext.ReplyTextString(fmt.Sprintf(i18n.T(i18nk.BotMsgHelpTextFmt), config.Version, shortHash)), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"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/tgutil"
|
||||
"github.com/krau/SaveAny-Bot/database"
|
||||
"github.com/krau/SaveAny-Bot/pkg/tcbdata"
|
||||
"github.com/krau/SaveAny-Bot/pkg/tfile"
|
||||
"github.com/krau/SaveAny-Bot/storage"
|
||||
@@ -26,12 +27,23 @@ func handleMediaMessage(ctx *ext.Context, update *ext.Update) error {
|
||||
return handleGroupMediaMessage(ctx, update, message, groupID)
|
||||
}
|
||||
logger.Debugf("Got media: %s", message.Media.TypeName())
|
||||
|
||||
msg, file, err := shortcut.GetFileFromMessageWithReply(ctx, update, message)
|
||||
userId := update.GetUserChat().GetID()
|
||||
userDB, err := database.GetUserByChatID(ctx, userId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
userId := update.GetUserChat().GetID()
|
||||
// tfOpts := make([]tfile.TGFileOption, 0)
|
||||
// switch userDB.FilenameStrategy {
|
||||
// case fnamest.Message.String():
|
||||
// tfOpts = append(tfOpts, tfile.WithName(tgutil.GenFileNameFromMessage(*message)))
|
||||
// default:
|
||||
// }
|
||||
tfOpts := mediautil.TfileOptions(ctx, userDB, message)
|
||||
msg, file, err := shortcut.GetFileFromMessageWithReply(ctx, update, message, tfOpts...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
stors := storage.GetUserStorages(ctx, userId)
|
||||
req, err := msgelem.BuildAddOneSelectStorageMessage(ctx, stors, file, msg.ID)
|
||||
if err != nil {
|
||||
@@ -58,7 +70,18 @@ func handleSilentSaveMedia(ctx *ext.Context, update *ext.Update) error {
|
||||
}
|
||||
logger.Debugf("Got media: %s", message.Media.TypeName())
|
||||
userID := update.GetUserChat().GetID()
|
||||
msg, file, err := shortcut.GetFileFromMessageWithReply(ctx, update, message)
|
||||
userDB, err := database.GetUserByChatID(ctx, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// tfOpts := make([]tfile.TGFileOption, 0)
|
||||
// switch userDB.FilenameStrategy {
|
||||
// case fnamest.Message.String():
|
||||
// tfOpts = append(tfOpts, tfile.WithName(tgutil.GenFileNameFromMessage(*message)))
|
||||
// default:
|
||||
// }
|
||||
tfOpts := mediautil.TfileOptions(ctx, userDB, message)
|
||||
msg, file, err := shortcut.GetFileFromMessageWithReply(ctx, update, message, tfOpts...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
|
||||
func checkPermission(ctx *ext.Context, update *ext.Update) error {
|
||||
userID := update.GetUserChat().GetID()
|
||||
if !slice.Contain(config.Cfg.GetUsersID(), userID) {
|
||||
if !slice.Contain(config.C().GetUsersID(), userID) {
|
||||
const noPermissionText string = `
|
||||
您不在白名单中, 无法使用此 Bot.
|
||||
您可以部署自己的实例: https://github.com/krau/SaveAny-Bot
|
||||
|
||||
@@ -4,6 +4,7 @@ package handlers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"github.com/celestix/gotgproto/dispatcher"
|
||||
"github.com/celestix/gotgproto/ext"
|
||||
@@ -11,6 +12,8 @@ import (
|
||||
"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/common/utils/fsutil"
|
||||
"github.com/krau/SaveAny-Bot/common/utils/tgutil"
|
||||
"github.com/krau/SaveAny-Bot/parsers"
|
||||
"github.com/krau/SaveAny-Bot/pkg/enums/tasktype"
|
||||
"github.com/krau/SaveAny-Bot/pkg/tcbdata"
|
||||
@@ -20,7 +23,20 @@ import (
|
||||
func handleTextMessage(ctx *ext.Context, u *ext.Update) error {
|
||||
logger := log.FromContext(ctx)
|
||||
text := u.EffectiveMessage.Text
|
||||
item, err := parsers.ParseWithContext(ctx, text)
|
||||
entityUrls := tgutil.ExtractMessageEntityUrls(u.EffectiveMessage.Message)
|
||||
if len(entityUrls) > 0 {
|
||||
text += "\n" + strings.Join(entityUrls, "\n")
|
||||
}
|
||||
ok, pser := parsers.CanHandle(text)
|
||||
if !ok {
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
msg, err := ctx.Reply(u, ext.ReplyTextString("正在解析..."), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
item, err := pser.Parse(ctx, text)
|
||||
if errors.Is(err, parsers.ErrNoParserFound) {
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
@@ -29,7 +45,7 @@ func handleTextMessage(ctx *ext.Context, u *ext.Update) error {
|
||||
ctx.Reply(u, ext.ReplyTextString("Failed to parse text: "+err.Error()), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
logger.Debug("Parsed item from text message", "text", text, "item", item)
|
||||
logger.Debug("Parsed item from text message", "title", item.Title, "url", item.URL)
|
||||
userID := u.GetUserChat().GetID()
|
||||
markup, err := msgelem.BuildAddSelectStorageKeyboard(storage.GetUserStorages(ctx, userID), tcbdata.Add{
|
||||
TaskType: tasktype.TaskTypeParseditem,
|
||||
@@ -46,14 +62,11 @@ func handleTextMessage(ctx *ext.Context, u *ext.Update) error {
|
||||
ctx.Reply(u, ext.ReplyTextString("Failed to build parsed text entity: "+err.Error()), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
ctx.SendMessage(userID, &tg.MessagesSendMessageRequest{
|
||||
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
|
||||
Message: text,
|
||||
ReplyMarkup: markup,
|
||||
Entities: entities,
|
||||
ReplyTo: &tg.InputReplyToMessage{
|
||||
ReplyToMsgID: u.EffectiveMessage.ID,
|
||||
ReplyToPeerID: u.GetUserChat().AsInputPeer(),
|
||||
},
|
||||
ID: msg.ID,
|
||||
})
|
||||
|
||||
return dispatcher.EndGroups
|
||||
@@ -80,7 +93,7 @@ func handleSilentSaveText(ctx *ext.Context, u *ext.Update) error {
|
||||
ctx.Reply(u, ext.ReplyTextString("Failed to parse text: "+err.Error()), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
logger.Debug("Parsed item from text message", "text", text, "item", item)
|
||||
logger.Debug("Parsed item from text message", "title", item.Title, "url", item.URL)
|
||||
userID := u.GetUserChat().GetID()
|
||||
text, entities, err := msgelem.BuildParsedTextEntity(*item)
|
||||
if err != nil {
|
||||
@@ -100,5 +113,9 @@ func handleSilentSaveText(ctx *ext.Context, u *ext.Update) error {
|
||||
logger.Errorf("Failed to send message: %s", err)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
return shortcut.CreateAndAddParsedTaskWithEdit(ctx, stor, "", item, msg.ID, userID)
|
||||
dirPath := ""
|
||||
if len(item.Resources) > 1 {
|
||||
dirPath = fsutil.NormalizePathname(item.Title)
|
||||
}
|
||||
return shortcut.CreateAndAddParsedTaskWithEdit(ctx, stor, dirPath, item, msg.ID, userID)
|
||||
}
|
||||
|
||||
@@ -1,28 +1,40 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"path"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"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/charmbracelet/log"
|
||||
sabotfilters "github.com/krau/SaveAny-Bot/client/bot/handlers/utils/filters"
|
||||
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/re"
|
||||
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/ruleutil"
|
||||
userclient "github.com/krau/SaveAny-Bot/client/user"
|
||||
"github.com/krau/SaveAny-Bot/common/utils/tgutil"
|
||||
"github.com/krau/SaveAny-Bot/config"
|
||||
"github.com/krau/SaveAny-Bot/core"
|
||||
"github.com/krau/SaveAny-Bot/core/tasks/tfile"
|
||||
"github.com/krau/SaveAny-Bot/database"
|
||||
"github.com/krau/SaveAny-Bot/pkg/tcbdata"
|
||||
"github.com/krau/SaveAny-Bot/storage"
|
||||
"github.com/rs/xid"
|
||||
)
|
||||
|
||||
type DescCommandHandler struct {
|
||||
Cmd string
|
||||
Desc string
|
||||
handler func(ctx *ext.Context, u *ext.Update) error
|
||||
}
|
||||
|
||||
var CommandHandlers = []DescCommandHandler{
|
||||
{"start", "开始使用", handleHelpCmd},
|
||||
{"silent", "切换静默模式", handleSilentCmd},
|
||||
{"storage", "设置默认存储端", handleStorageCmd},
|
||||
{"dir", "管理存储文件夹", handleDirCmd},
|
||||
{"rule", "管理自动存储规则", handleRuleCmd},
|
||||
{"watch", "监听聊天(UserBot)", handleWatchCmd},
|
||||
{"unwatch", "取消监听聊天(UserBot)", handleUnwatchCmd},
|
||||
{"save", "保存文件", handleSilentMode(handleSaveCmd, handleSilentSaveReplied)},
|
||||
{"config", "修改配置", handleConfigCmd},
|
||||
{"fnametmpl", "设置文件命名模板", handleConfigFnameTmpl},
|
||||
{"update", "检查更新", handleUpdateCmd},
|
||||
{"help", "显示帮助", handleHelpCmd},
|
||||
}
|
||||
|
||||
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
|
||||
@@ -31,108 +43,20 @@ func Register(disp dispatcher.Dispatcher) {
|
||||
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("watch", handleWatchCmd))
|
||||
disp.AddHandler(handlers.NewCommand("unwatch", handleUnwatchCmd))
|
||||
disp.AddHandler(handlers.NewCommand("save", handleSilentMode(handleSaveCmd, handleSilentSaveReplied)))
|
||||
for _, info := range CommandHandlers {
|
||||
disp.AddHandler(handlers.NewCommand(info.Cmd, info.handler))
|
||||
}
|
||||
disp.AddHandler(handlers.NewCallbackQuery(filters.CallbackQuery.Prefix("update"), handleUpdateCallback))
|
||||
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.NewCallbackQuery(filters.CallbackQuery.Prefix(tcbdata.TypeCancel), handleCancelCallback))
|
||||
disp.AddHandler(handlers.NewCallbackQuery(filters.CallbackQuery.Prefix(tcbdata.TypeConfig), handleConfigCallback))
|
||||
disp.AddHandler(handlers.NewMessage(sabotfilters.RegexUrl(regexp.MustCompile(re.TgMessageLinkRegexString)), handleSilentMode(handleMessageLink, handleSilentSaveLink)))
|
||||
disp.AddHandler(handlers.NewMessage(sabotfilters.RegexUrl(regexp.MustCompile(re.TelegraphUrlRegexString)), handleSilentMode(handleTelegraphUrlMessage, handleSilentSaveTelegraph)))
|
||||
disp.AddHandler(handlers.NewMessage(filters.Message.Media, handleSilentMode(handleMediaMessage, handleSilentSaveMedia)))
|
||||
disp.AddHandler(handlers.NewMessage(filters.Message.Text, handleSilentMode(handleTextMessage, handleSilentSaveText)))
|
||||
|
||||
if config.Cfg.Telegram.Userbot.Enable {
|
||||
if config.C().Telegram.Userbot.Enable {
|
||||
go listenMediaMessageEvent(userclient.GetMediaMessageCh())
|
||||
}
|
||||
}
|
||||
|
||||
func listenMediaMessageEvent(ch chan userclient.MediaMessageEvent) {
|
||||
logger := log.FromContext(userclient.GetCtx())
|
||||
for event := range ch {
|
||||
logger.Debug("Received media message event", "chat_id", event.ChatID, "file_name", event.File.Name())
|
||||
ctx := event.Ctx
|
||||
file := event.File
|
||||
chats, err := database.GetWatchChatsByChatID(ctx, event.ChatID)
|
||||
if err != nil {
|
||||
logger.Errorf("Failed to get watch chats for chat ID %d: %v", event.ChatID, err)
|
||||
continue
|
||||
}
|
||||
msgText := event.File.Message().GetMessage()
|
||||
for _, chat := range chats {
|
||||
if chat.Filter != "" {
|
||||
filter := strings.Split(chat.Filter, ":")
|
||||
if len(filter) != 2 {
|
||||
logger.Warnf("Invalid filter format in chat %d, skipping", chat.ChatID)
|
||||
continue
|
||||
}
|
||||
filterType := filter[0]
|
||||
filterData := filter[1]
|
||||
switch filterType {
|
||||
case "msgre": // [TODO] enums for filter types
|
||||
if ok, err := regexp.MatchString(filterData, msgText); err != nil {
|
||||
continue
|
||||
} else if !ok {
|
||||
continue
|
||||
}
|
||||
default:
|
||||
logger.Warnf("Unsupported filter type %s in chat %d, skipping", filterType, chat.ChatID)
|
||||
continue
|
||||
}
|
||||
}
|
||||
user, err := database.GetUserByID(ctx, chat.UserID)
|
||||
if err != nil {
|
||||
logger.Errorf("Failed to get user by ID %d: %v", chat.UserID, err)
|
||||
continue
|
||||
}
|
||||
if user.DefaultStorage == "" {
|
||||
logger.Warnf("User %d has no default storage set, skipping media message handling", chat.UserID)
|
||||
continue
|
||||
}
|
||||
stor, err := storage.GetStorageByUserIDAndName(ctx, user.ChatID, user.DefaultStorage)
|
||||
if err != nil {
|
||||
logger.Errorf("Failed to get storage by user ID %d and name %s: %v", user.ChatID, user.DefaultStorage, err)
|
||||
continue
|
||||
}
|
||||
var dirPath string
|
||||
if user.ApplyRule && user.Rules != nil {
|
||||
matchedStorageName, matchedDirPath := ruleutil.ApplyRule(ctx, user.Rules, ruleutil.NewInput(file))
|
||||
dirPath = matchedDirPath.String()
|
||||
if matchedStorageName.IsUsable() {
|
||||
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)
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
storagePath := stor.JoinStoragePath(path.Join(dirPath, file.Name()))
|
||||
injectCtx := tgutil.ExtWithContext(ctx.Context, ctx)
|
||||
taskid := xid.New().String()
|
||||
task, err := tfile.NewTGFileTask(taskid, injectCtx, file, stor, storagePath, nil)
|
||||
if err != nil {
|
||||
logger.Errorf("create task failed: %s", err)
|
||||
continue
|
||||
}
|
||||
if err := core.AddTask(injectCtx, task); err != nil {
|
||||
logger.Errorf("add task failed: %s", err)
|
||||
continue
|
||||
}
|
||||
logger.Infof("Added media message task for user %d in chat %d: %s", chat.UserID, event.ChatID, file.Name())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
"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"
|
||||
"github.com/krau/SaveAny-Bot/pkg/rule"
|
||||
)
|
||||
|
||||
func handleRuleCmd(ctx *ext.Context, update *ext.Update) error {
|
||||
|
||||
@@ -12,8 +12,11 @@ import (
|
||||
"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/i18n"
|
||||
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
|
||||
"github.com/krau/SaveAny-Bot/common/utils/strutil"
|
||||
"github.com/krau/SaveAny-Bot/common/utils/tgutil"
|
||||
"github.com/krau/SaveAny-Bot/database"
|
||||
"github.com/krau/SaveAny-Bot/pkg/tcbdata"
|
||||
"github.com/krau/SaveAny-Bot/pkg/tfile"
|
||||
|
||||
@@ -28,21 +31,30 @@ func handleSaveCmd(ctx *ext.Context, update *ext.Update) error {
|
||||
}
|
||||
replyTo := update.EffectiveMessage.ReplyToMessage
|
||||
if replyTo == nil || replyTo.Message == nil {
|
||||
ctx.Reply(update, ext.ReplyTextString(msgelem.SaveHelpText), nil)
|
||||
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgSaveHelpText)), 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)
|
||||
// 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)
|
||||
// }
|
||||
userDB, err := database.GetUserByChatID(ctx, update.GetUserChat().GetID())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
msg, file, err := shortcut.GetFileFromMessageWithReply(ctx, update, replyTo.Message, option)
|
||||
opts := mediautil.TfileOptions(ctx, userDB, replyTo.Message)
|
||||
if len(args) > 1 {
|
||||
// custom filename via command arg
|
||||
opts = append(opts, tfile.WithName(strings.Join(args[1:], " ")))
|
||||
}
|
||||
msg, file, err := shortcut.GetFileFromMessageWithReply(ctx, update, replyTo.Message, opts...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -72,21 +84,30 @@ func handleSilentSaveReplied(ctx *ext.Context, update *ext.Update) error {
|
||||
}
|
||||
replyTo := update.EffectiveMessage.ReplyToMessage
|
||||
if replyTo == nil || replyTo.Message == nil {
|
||||
ctx.Reply(update, ext.ReplyTextString(msgelem.SaveHelpText), nil)
|
||||
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgSaveHelpText)), 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)
|
||||
// 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)
|
||||
// }
|
||||
userDB, err := database.GetUserByChatID(ctx, update.GetUserChat().GetID())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
msg, file, err := shortcut.GetFileFromMessageWithReply(ctx, update, replyTo.Message, option)
|
||||
opts := mediautil.TfileOptions(ctx, userDB, replyTo.Message)
|
||||
if len(args) > 1 {
|
||||
// custom filename via command arg
|
||||
opts = append(opts, tfile.WithName(strings.Join(args[1:], " ")))
|
||||
}
|
||||
msg, file, err := shortcut.GetFileFromMessageWithReply(ctx, update, replyTo.Message, opts...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -124,7 +145,7 @@ func handleBatchSave(ctx *ext.Context, update *ext.Update, args []string) error
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
|
||||
// TODO: generator istead of get all messages
|
||||
// [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)
|
||||
|
||||
102
client/bot/handlers/update.go
Normal file
102
client/bot/handlers/update.go
Normal file
@@ -0,0 +1,102 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/blang/semver"
|
||||
"github.com/celestix/gotgproto/dispatcher"
|
||||
"github.com/celestix/gotgproto/ext"
|
||||
"github.com/gotd/td/telegram/message/html"
|
||||
"github.com/gotd/td/tg"
|
||||
"github.com/krau/SaveAny-Bot/config"
|
||||
"github.com/rhysd/go-github-selfupdate/selfupdate"
|
||||
)
|
||||
|
||||
func handleUpdateCmd(ctx *ext.Context, u *ext.Update) error {
|
||||
currentV, err := semver.Parse(config.Version)
|
||||
if err != nil {
|
||||
ctx.Reply(u, ext.ReplyTextString(fmt.Sprintf("You are in dev or the version var failed to inject: %v", err)), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
latest, ok, err := selfupdate.DetectLatest(config.GitRepo)
|
||||
if err != nil {
|
||||
ctx.Reply(u, ext.ReplyTextString(fmt.Sprintf("检测最新版本失败: %v", err)), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
if !ok {
|
||||
ctx.Reply(u, ext.ReplyTextString("没有找到版本信息"), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
if latest.Version.LT(currentV) || latest.Version.Equals(currentV) {
|
||||
ctx.Reply(u, ext.ReplyTextString(fmt.Sprintf("当前已经是最新版本: %s", config.Version)), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
ctx.Sender.To(u.GetUserChat().AsInputPeer()).StyledText(ctx, html.String(nil, func() string {
|
||||
md := latest.ReleaseNotes
|
||||
md = regexp.MustCompile(`(?m)^###\s+ (.+)$`).ReplaceAllString(md, "<b>$1</b>")
|
||||
md = regexp.MustCompile(`(?m)^#####\s+ (.+)$`).ReplaceAllString(md, "<i>$1</i>")
|
||||
|
||||
md = regexp.MustCompile(`(?m)^- `).ReplaceAllString(md, "• ")
|
||||
|
||||
md = regexp.MustCompile(`\[\((\w{6,})\)\]\((https?://[^\s)]+)\)`).ReplaceAllString(md, `(<a href="$2">$1</a>)`)
|
||||
|
||||
md = regexp.MustCompile(`\[(.+?)\]\((https?://[^\s)]+)\)`).ReplaceAllString(md, `<a href="$2">$1</a>`)
|
||||
|
||||
md = strings.ReplaceAll(md, " ", " ")
|
||||
|
||||
return `<blockquote expandable>` + md + `</blockquote>`
|
||||
}()))
|
||||
text := fmt.Sprintf(`发现新版本: %s
|
||||
当前版本: %s
|
||||
|
||||
文件大小: %.2f MB
|
||||
下载链接: %s
|
||||
发布时间: %s
|
||||
|
||||
升级将重启 Bot , 是否升级?`, latest.Version, config.Version,
|
||||
float64(latest.AssetByteSize)/(1024*1024), latest.AssetURL,
|
||||
latest.PublishedAt.Format("2006-01-02 15:04:05"),
|
||||
)
|
||||
ctx.Reply(u, ext.ReplyTextString(text), &ext.ReplyOpts{
|
||||
Markup: &tg.ReplyInlineMarkup{
|
||||
Rows: []tg.KeyboardButtonRow{
|
||||
{
|
||||
Buttons: []tg.KeyboardButtonClass{
|
||||
&tg.KeyboardButtonCallback{
|
||||
Text: "升级",
|
||||
Data: []byte("update"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
|
||||
func handleUpdateCallback(ctx *ext.Context, u *ext.Update) error {
|
||||
currentV, err := semver.Parse(config.Version)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ctx.EditMessage(u.GetUserChat().GetID(), &tg.MessagesEditMessageRequest{
|
||||
ID: u.CallbackQuery.GetMsgID(),
|
||||
Message: fmt.Sprintf("正在升级中, 当前版本: %s", config.Version),
|
||||
})
|
||||
latest, err := selfupdate.UpdateSelf(currentV, config.GitRepo)
|
||||
if err != nil {
|
||||
ctx.EditMessage(u.GetUserChat().GetID(), &tg.MessagesEditMessageRequest{
|
||||
ID: u.CallbackQuery.GetMsgID(),
|
||||
Message: fmt.Sprintf("升级失败: %v", err),
|
||||
})
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
ctx.EditMessage(u.GetUserChat().GetID(), &tg.MessagesEditMessageRequest{
|
||||
ID: u.CallbackQuery.GetMsgID(),
|
||||
Message: fmt.Sprintf("已升级至版本 %s\n若 Bot 未自动重启请手动启动", latest.Version),
|
||||
})
|
||||
return errors.New("SAVEANTBOT-RESTART")
|
||||
}
|
||||
26
client/bot/handlers/utils/filters/url.go
Normal file
26
client/bot/handlers/utils/filters/url.go
Normal file
@@ -0,0 +1,26 @@
|
||||
package filters
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"slices"
|
||||
|
||||
"github.com/celestix/gotgproto/dispatcher/handlers/filters"
|
||||
"github.com/celestix/gotgproto/types"
|
||||
"github.com/krau/SaveAny-Bot/common/utils/tgutil"
|
||||
)
|
||||
|
||||
func RegexUrl(r *regexp.Regexp) filters.MessageFilter {
|
||||
return func(m *types.Message) bool {
|
||||
if m.Text == "" {
|
||||
return false
|
||||
}
|
||||
if r.MatchString(m.Text) {
|
||||
return true
|
||||
}
|
||||
urls := tgutil.ExtractMessageEntityUrls(m.Message)
|
||||
if len(urls) == 0 {
|
||||
return false
|
||||
}
|
||||
return slices.ContainsFunc(urls, r.MatchString)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,20 @@
|
||||
package mediautil
|
||||
|
||||
import "github.com/gotd/td/tg"
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"text/template"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/log"
|
||||
"github.com/gotd/td/tg"
|
||||
"github.com/krau/SaveAny-Bot/common/utils/strutil"
|
||||
"github.com/krau/SaveAny-Bot/common/utils/tgutil"
|
||||
"github.com/krau/SaveAny-Bot/database"
|
||||
"github.com/krau/SaveAny-Bot/pkg/enums/fnamest"
|
||||
"github.com/krau/SaveAny-Bot/pkg/tfile"
|
||||
)
|
||||
|
||||
func IsSupported(media tg.MessageMediaClass) bool {
|
||||
switch media.(type) {
|
||||
@@ -10,3 +24,118 @@ func IsSupported(media tg.MessageMediaClass) bool {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
type FilenameTemplateData struct {
|
||||
MsgID string `json:"msgid,omitempty"`
|
||||
MsgTags string `json:"msgtags,omitempty"`
|
||||
MsgGen string `json:"msggen,omitempty"`
|
||||
MsgDate string `json:"msgdate,omitempty"`
|
||||
OrigName string `json:"origname,omitempty"`
|
||||
ChatID string `json:"chatid,omitempty"`
|
||||
}
|
||||
|
||||
func (f FilenameTemplateData) ToMap() map[string]string {
|
||||
return map[string]string{
|
||||
"msgid": f.MsgID,
|
||||
"msgtags": f.MsgTags,
|
||||
"msggen": f.MsgGen,
|
||||
"msgdate": f.MsgDate,
|
||||
"origname": f.OrigName,
|
||||
"chatid": f.ChatID,
|
||||
}
|
||||
}
|
||||
|
||||
func TfileOptions(ctx context.Context, user *database.User, message *tg.Message) []tfile.TGFileOption {
|
||||
opts := make([]tfile.TGFileOption, 0)
|
||||
var fnameOpt tfile.TGFileOption
|
||||
switch user.FilenameStrategy {
|
||||
case fnamest.Message.String():
|
||||
fnameOpt = tfile.WithName(tgutil.GenFileNameFromMessage(*message))
|
||||
case fnamest.Template.String():
|
||||
if user.FilenameTemplate == "" {
|
||||
log.FromContext(ctx).Warnf("empty filename template")
|
||||
fnameOpt = tfile.WithNameIfEmpty(tgutil.GenFileNameFromMessage(*message))
|
||||
break
|
||||
}
|
||||
tmpl, err := template.New("filename").Parse(user.FilenameTemplate)
|
||||
if err != nil {
|
||||
log.FromContext(ctx).Errorf("failed to parse filename template: %s", err)
|
||||
fnameOpt = tfile.WithNameIfEmpty(tgutil.GenFileNameFromMessage(*message))
|
||||
break
|
||||
}
|
||||
data := BuildFilenameTemplateData(message)
|
||||
var sb strings.Builder
|
||||
err = tmpl.Execute(&sb, data)
|
||||
if err != nil {
|
||||
log.FromContext(ctx).Errorf("failed to execute filename template: %s", err)
|
||||
fnameOpt = tfile.WithNameIfEmpty(tgutil.GenFileNameFromMessage(*message))
|
||||
break
|
||||
}
|
||||
fnameOpt = tfile.WithName(sb.String())
|
||||
default:
|
||||
fnameOpt = tfile.WithNameIfEmpty(tgutil.GenFileNameFromMessage(*message))
|
||||
}
|
||||
opts = append(opts, fnameOpt, tfile.WithMessage(message))
|
||||
return opts
|
||||
}
|
||||
|
||||
func BuildFilenameTemplateData(message *tg.Message) map[string]string {
|
||||
data := FilenameTemplateData{
|
||||
MsgID: func() string {
|
||||
id := message.GetID()
|
||||
if id == 0 {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("%d", id)
|
||||
}(),
|
||||
MsgTags: func() string {
|
||||
tags := strutil.ExtractTagsFromText(message.GetMessage())
|
||||
if len(tags) == 0 {
|
||||
return ""
|
||||
}
|
||||
return strings.Join(tags, "_")
|
||||
}(),
|
||||
MsgGen: tgutil.GenFileNameFromMessage(*message),
|
||||
OrigName: func() string {
|
||||
f, _ := tgutil.GetMediaFileName(message.Media)
|
||||
return f
|
||||
}(),
|
||||
MsgDate: func() string {
|
||||
date := message.GetDate()
|
||||
if date == 0 {
|
||||
return ""
|
||||
}
|
||||
t := time.Unix(int64(date), 0)
|
||||
return t.Format("2006-01-02_15-04-05")
|
||||
}(),
|
||||
ChatID: func() string {
|
||||
// 如果消息是频道的(从消息链接中fetch的) 直接使用其chat id, 无论它是否是从其他来源转发的
|
||||
if message.GetPost() {
|
||||
peer := message.GetPeerID()
|
||||
switch p := peer.(type) {
|
||||
case *tg.PeerChannel:
|
||||
return intToStringOmitZero(p.ChannelID)
|
||||
default: // impossible case
|
||||
return intToStringOmitZero(tgutil.ChatIdFromPeer(peer))
|
||||
}
|
||||
}
|
||||
fwdHeader, ok := message.GetFwdFrom()
|
||||
if !ok {
|
||||
return intToStringOmitZero(tgutil.ChatIdFromPeer(message.GetPeerID()))
|
||||
}
|
||||
fwdFrom, ok := fwdHeader.GetFromID()
|
||||
if !ok {
|
||||
return intToStringOmitZero(tgutil.ChatIdFromPeer(message.GetPeerID()))
|
||||
}
|
||||
return intToStringOmitZero(tgutil.ChatIdFromPeer(fwdFrom))
|
||||
}(),
|
||||
}.ToMap()
|
||||
return data
|
||||
}
|
||||
|
||||
func intToStringOmitZero(i int64) string {
|
||||
if i == 0 {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("%d", i)
|
||||
}
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
package msgelem
|
||||
|
||||
const (
|
||||
SaveHelpText = `
|
||||
使用方法:
|
||||
|
||||
1. 使用该命令回复要保存的文件, 可选文件名参数.
|
||||
示例:
|
||||
/save custom_file_name.mp4
|
||||
|
||||
2. 设置默认存储后, 发送 /save <频道ID/用户名> <消息ID范围> 来批量保存文件. 遵从存储规则, 若未匹配到任何规则则使用默认存储.
|
||||
示例:
|
||||
/save @acherkrau 114-514
|
||||
`
|
||||
)
|
||||
@@ -1,19 +0,0 @@
|
||||
package msgelem
|
||||
|
||||
const (
|
||||
WatchHelpText = `
|
||||
使用 /watch 命令监听一个聊天的消息, 并自动保存到默认存储中, 遵从存储规则.
|
||||
|
||||
命令语法:
|
||||
/watch <chat_id> [filter]
|
||||
|
||||
参数:
|
||||
- <chat_id>: 聊天的 ID 或用户名
|
||||
- [filter]: 可选, 格式为 过滤器类型:表达式 , 所有支持类型的过滤器请查看文档
|
||||
|
||||
命令示例:
|
||||
/watch 2229835658 msgre:.*plana.*
|
||||
|
||||
这将监听 ID 为 2229835658 的聊天, 并转存所有包含 "plana" 的媒体消息
|
||||
`
|
||||
)
|
||||
@@ -7,8 +7,6 @@ import (
|
||||
|
||||
"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"
|
||||
)
|
||||
@@ -37,7 +35,7 @@ func (m matchedStorName) String() string {
|
||||
|
||||
// can we use this storage name directly?
|
||||
func (m matchedStorName) IsUsable() bool {
|
||||
return m != "" && m != consts.RuleStorNameChosen
|
||||
return m != "" && m != rule.RuleStorNameChosen
|
||||
}
|
||||
|
||||
type MatchedDirPath string
|
||||
@@ -47,17 +45,17 @@ func (m MatchedDirPath) String() string {
|
||||
}
|
||||
|
||||
func (m MatchedDirPath) NeedNewForAlbum() bool {
|
||||
return m != "" && m == consts.RuleDirPathNewForAlbum
|
||||
return m != "" && m == rule.RuleDirPathNewForAlbum
|
||||
}
|
||||
|
||||
func ApplyRule(ctx context.Context, rules []database.Rule, inputs *ruleInput) (matchedStorageName matchedStorName, dirPath MatchedDirPath) {
|
||||
func ApplyRule(ctx context.Context, rules []database.Rule, inputs *ruleInput) (matched bool, matchedStorageName matchedStorName, dirPath MatchedDirPath) {
|
||||
if inputs == nil || len(rules) == 0 {
|
||||
return "", ""
|
||||
return false, "", ""
|
||||
}
|
||||
logger := log.FromContext(ctx)
|
||||
for _, ur := range rules {
|
||||
switch ur.Type {
|
||||
case ruleenum.FileNameRegex.String():
|
||||
case rule.FileNameRegex.String():
|
||||
ru, err := rule.NewRuleFileNameRegex(ur.StorageName, ur.DirPath, ur.Data)
|
||||
if err != nil {
|
||||
logger.Errorf("Failed to create rule: %s", err)
|
||||
@@ -72,7 +70,7 @@ func ApplyRule(ctx context.Context, rules []database.Rule, inputs *ruleInput) (m
|
||||
dirPath = MatchedDirPath(ru.StoragePath())
|
||||
matchedStorageName = matchedStorName(ru.StorageName())
|
||||
}
|
||||
case ruleenum.MessageRegex.String():
|
||||
case rule.MessageRegex.String():
|
||||
ru, err := rule.NewRuleMessageRegex(ur.StorageName, ur.DirPath, ur.Data)
|
||||
if err != nil {
|
||||
logger.Errorf("Failed to create rule: %s", err)
|
||||
@@ -87,7 +85,7 @@ func ApplyRule(ctx context.Context, rules []database.Rule, inputs *ruleInput) (m
|
||||
dirPath = MatchedDirPath(ru.StoragePath())
|
||||
matchedStorageName = matchedStorName(ru.StorageName())
|
||||
}
|
||||
case ruleenum.IsAlbum.String():
|
||||
case rule.IsAlbum.String():
|
||||
matchAlbum, err := convertor.ToBool(ur.Data)
|
||||
if err != nil {
|
||||
matchAlbum = false
|
||||
@@ -108,5 +106,8 @@ func ApplyRule(ctx context.Context, rules []database.Rule, inputs *ruleInput) (m
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
if matchedStorageName != "" || dirPath != "" {
|
||||
return true, matchedStorageName, dirPath
|
||||
}
|
||||
return false, "", ""
|
||||
}
|
||||
|
||||
@@ -20,12 +20,13 @@ import (
|
||||
"github.com/krau/SaveAny-Bot/common/utils/tgutil"
|
||||
"github.com/krau/SaveAny-Bot/common/utils/tphutil"
|
||||
"github.com/krau/SaveAny-Bot/config"
|
||||
"github.com/krau/SaveAny-Bot/database"
|
||||
"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,
|
||||
func GetFileFromMessageWithReply(ctx *ext.Context, update *ext.Update, message *tg.Message, tfileopts ...tfile.TGFileOption) (replied *types.Message,
|
||||
file tfile.TGFileMessage, err error,
|
||||
) {
|
||||
logger := log.FromContext(ctx)
|
||||
@@ -40,15 +41,15 @@ func GetFileFromMessageWithReply(ctx *ext.Context, update *ext.Update, message *
|
||||
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, ctx.Raw, message, options...)
|
||||
// options := []tfile.TGFileOption{
|
||||
// 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, ctx.Raw, message, tfileopts...)
|
||||
if err != nil {
|
||||
logger.Errorf("Failed to get file from media: %s", err)
|
||||
ctx.Reply(update, ext.ReplyTextString("获取文件失败: "+err.Error()), nil)
|
||||
@@ -62,7 +63,7 @@ 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)
|
||||
msgLinks := re.TgMessageLinkRegexp.FindAllString(tgutil.ExtractMessageEntityUrlsText(update.EffectiveMessage.Message), -1)
|
||||
if len(msgLinks) == 0 {
|
||||
logger.Warn("no matched message links but called handleMessageLink")
|
||||
return nil, nil, nil, dispatcher.EndGroups
|
||||
@@ -81,7 +82,12 @@ func GetFilesFromUpdateLinkMessageWithReplyEdit(ctx *ext.Context, update *ext.Up
|
||||
logger.Errorf("failed to edit message: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
user, err := database.GetUserByChatID(ctx, update.GetUserChat().GetID())
|
||||
if err != nil {
|
||||
logger.Errorf("failed to get user from db: %s", err)
|
||||
editReplied("获取用户信息失败: "+err.Error(), nil)
|
||||
return nil, nil, nil, dispatcher.EndGroups
|
||||
}
|
||||
files = make([]tfile.TGFileMessage, 0, len(msgLinks))
|
||||
addFile := func(client downloader.Client, msg *tg.Message) {
|
||||
if msg == nil || msg.Media == nil {
|
||||
@@ -93,7 +99,15 @@ func GetFilesFromUpdateLinkMessageWithReplyEdit(ctx *ext.Context, update *ext.Up
|
||||
logger.Debugf("message %d has no media", msg.GetID())
|
||||
return
|
||||
}
|
||||
file, err := tfile.FromMediaMessage(media, client, msg, tfile.WithNameIfEmpty(tgutil.GenFileNameFromMessage(*msg)))
|
||||
// var opt tfile.TGFileOption
|
||||
// switch user.FilenameStrategy {
|
||||
// case fnamest.Message.String():
|
||||
// opt = tfile.WithName(tgutil.GenFileNameFromMessage(*msg))
|
||||
// default:
|
||||
// opt = tfile.WithNameIfEmpty(tgutil.GenFileNameFromMessage(*msg))
|
||||
// }
|
||||
opts := mediautil.TfileOptions(ctx, user, msg)
|
||||
file, err := tfile.FromMediaMessage(media, client, msg, opts...)
|
||||
if err != nil {
|
||||
logger.Errorf("failed to create file from media: %s", err)
|
||||
return
|
||||
@@ -102,7 +116,7 @@ func GetFilesFromUpdateLinkMessageWithReplyEdit(ctx *ext.Context, update *ext.Up
|
||||
}
|
||||
|
||||
tctx := ctx
|
||||
if config.Cfg.Telegram.Userbot.Enable {
|
||||
if config.C().Telegram.Userbot.Enable {
|
||||
tctx = uc.GetCtx()
|
||||
}
|
||||
|
||||
@@ -164,7 +178,7 @@ type TelegraphResult struct {
|
||||
// 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
|
||||
tphurl := re.TelegraphUrlRegexp.FindString(tgutil.ExtractMessageEntityUrlsText(update.EffectiveMessage.Message))
|
||||
if tphurl == "" {
|
||||
logger.Warnf("No telegraph url found but called handleTelegraph")
|
||||
return nil, nil, dispatcher.ContinueGroups
|
||||
|
||||
@@ -34,8 +34,13 @@ func CreateAndAddTGFileTaskWithEdit(ctx *ext.Context, userID int64, stor storage
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
if user.ApplyRule && user.Rules != nil {
|
||||
matchedStorageName, matchedDirPath := ruleutil.ApplyRule(ctx, user.Rules, ruleutil.NewInput(file))
|
||||
dirPath = matchedDirPath.String()
|
||||
matched, matchedStorageName, matchedDirPath := ruleutil.ApplyRule(ctx, user.Rules, ruleutil.NewInput(file))
|
||||
if !matched {
|
||||
goto startCreateTask
|
||||
}
|
||||
if matchedDirPath != "" {
|
||||
dirPath = matchedDirPath.String()
|
||||
}
|
||||
if matchedStorageName.IsUsable() {
|
||||
stor, err = storage.GetStorageByUserIDAndName(ctx, user.ChatID, matchedStorageName.String())
|
||||
if err != nil {
|
||||
@@ -48,7 +53,7 @@ func CreateAndAddTGFileTaskWithEdit(ctx *ext.Context, userID int64, stor storage
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
startCreateTask:
|
||||
storagePath := stor.JoinStoragePath(path.Join(dirPath, file.Name()))
|
||||
injectCtx := tgutil.ExtWithContext(ctx.Context, ctx)
|
||||
taskid := xid.New().String()
|
||||
@@ -101,8 +106,10 @@ func CreateAndAddBatchTGFileTaskWithEdit(ctx *ext.Context, userID int64, stor st
|
||||
if !useRule {
|
||||
return stor.Name(), ruleutil.MatchedDirPath(dirPath)
|
||||
}
|
||||
storName, dirP := ruleutil.ApplyRule(ctx, user.Rules, ruleutil.NewInput(file))
|
||||
|
||||
matched, storName, dirP := ruleutil.ApplyRule(ctx, user.Rules, ruleutil.NewInput(file))
|
||||
if !matched {
|
||||
return stor.Name(), ruleutil.MatchedDirPath(dirPath)
|
||||
}
|
||||
storname := storName.String()
|
||||
if !storName.IsUsable() {
|
||||
storname = stor.Name()
|
||||
|
||||
@@ -1,22 +1,33 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"path"
|
||||
"regexp"
|
||||
"strings"
|
||||
"text/template"
|
||||
|
||||
"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/mediautil"
|
||||
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/ruleutil"
|
||||
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/tgutil"
|
||||
"github.com/krau/SaveAny-Bot/core"
|
||||
"github.com/krau/SaveAny-Bot/core/tasks/tfile"
|
||||
"github.com/krau/SaveAny-Bot/database"
|
||||
"github.com/krau/SaveAny-Bot/pkg/enums/fnamest"
|
||||
"github.com/krau/SaveAny-Bot/storage"
|
||||
"github.com/rs/xid"
|
||||
)
|
||||
|
||||
func handleWatchCmd(ctx *ext.Context, update *ext.Update) error {
|
||||
logger := log.FromContext(ctx)
|
||||
args := strings.Split(string(update.EffectiveMessage.Text), " ")
|
||||
args := strings.Split(update.EffectiveMessage.Text, " ")
|
||||
if len(args) < 2 {
|
||||
ctx.Reply(update, ext.ReplyTextString(msgelem.WatchHelpText), nil)
|
||||
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgWatchHelpText)), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
userChatID := update.GetUserChat().GetID()
|
||||
@@ -82,7 +93,7 @@ func handleWatchCmd(ctx *ext.Context, update *ext.Update) error {
|
||||
|
||||
func handleUnwatchCmd(ctx *ext.Context, update *ext.Update) error {
|
||||
logger := log.FromContext(ctx)
|
||||
args := strings.Split(string(update.EffectiveMessage.Text), " ")
|
||||
args := strings.Split(update.EffectiveMessage.Text, " ")
|
||||
if len(args) < 2 {
|
||||
ctx.Reply(update, ext.ReplyTextString("请提供要取消监听的聊天ID或用户名"), nil)
|
||||
return dispatcher.EndGroups
|
||||
@@ -108,3 +119,106 @@ func handleUnwatchCmd(ctx *ext.Context, update *ext.Update) error {
|
||||
ctx.Reply(update, ext.ReplyTextString("已取消监听聊天: "+chatArg), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
|
||||
func listenMediaMessageEvent(ch chan userclient.MediaMessageEvent) {
|
||||
logger := log.FromContext(userclient.GetCtx())
|
||||
for event := range ch {
|
||||
logger.Debug("Received media message event", "chat_id", event.ChatID, "file_name", event.File.Name())
|
||||
ctx := event.Ctx
|
||||
file := event.File
|
||||
chats, err := database.GetWatchChatsByChatID(ctx, event.ChatID)
|
||||
if err != nil {
|
||||
logger.Errorf("Failed to get watch chats for chat ID %d: %v", event.ChatID, err)
|
||||
continue
|
||||
}
|
||||
msgText := event.File.Message().GetMessage()
|
||||
for _, chat := range chats {
|
||||
if chat.Filter != "" {
|
||||
filter := strings.Split(chat.Filter, ":")
|
||||
if len(filter) != 2 {
|
||||
logger.Warnf("Invalid filter format in chat %d, skipping", chat.ChatID)
|
||||
continue
|
||||
}
|
||||
filterType := filter[0]
|
||||
filterData := filter[1]
|
||||
switch filterType {
|
||||
case "msgre": // [TODO] enums for filter types
|
||||
if ok, err := regexp.MatchString(filterData, msgText); err != nil {
|
||||
continue
|
||||
} else if !ok {
|
||||
continue
|
||||
}
|
||||
default:
|
||||
logger.Warnf("Unsupported filter type %s in chat %d, skipping", filterType, chat.ChatID)
|
||||
continue
|
||||
}
|
||||
}
|
||||
user, err := database.GetUserByID(ctx, chat.UserID)
|
||||
if err != nil {
|
||||
logger.Errorf("Failed to get user by ID %d: %v", chat.UserID, err)
|
||||
continue
|
||||
}
|
||||
if user.DefaultStorage == "" {
|
||||
logger.Warnf("User %d has no default storage set, skipping media message handling", chat.UserID)
|
||||
continue
|
||||
}
|
||||
stor, err := storage.GetStorageByUserIDAndName(ctx, user.ChatID, user.DefaultStorage)
|
||||
if err != nil {
|
||||
logger.Errorf("Failed to get storage by user ID %d and name %s: %v", user.ChatID, user.DefaultStorage, err)
|
||||
continue
|
||||
}
|
||||
switch user.FilenameStrategy {
|
||||
case fnamest.Message.String():
|
||||
file.SetName(tgutil.GenFileNameFromMessage(*file.Message()))
|
||||
case fnamest.Template.String():
|
||||
if user.FilenameTemplate == "" {
|
||||
logger.Warnf("Empty filename template for user %d, using default filename", user.ChatID)
|
||||
break
|
||||
}
|
||||
message := file.Message()
|
||||
tmpl, err := template.New("filename").Parse(user.FilenameTemplate)
|
||||
if err != nil {
|
||||
logger.Errorf("Failed to parse filename template for user %d: %s", user.ChatID, err)
|
||||
break
|
||||
}
|
||||
data := mediautil.BuildFilenameTemplateData(message)
|
||||
var sb strings.Builder
|
||||
err = tmpl.Execute(&sb, data)
|
||||
if err != nil {
|
||||
log.FromContext(ctx).Errorf("failed to execute filename template: %s", err)
|
||||
break
|
||||
}
|
||||
file.SetName(sb.String())
|
||||
}
|
||||
var dirPath string
|
||||
if user.ApplyRule && user.Rules != nil {
|
||||
matched, matchedStorageName, matchedDirPath := ruleutil.ApplyRule(ctx, user.Rules, ruleutil.NewInput(file))
|
||||
if !matched {
|
||||
goto startCreateTask
|
||||
}
|
||||
dirPath = matchedDirPath.String()
|
||||
if matchedStorageName.IsUsable() {
|
||||
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)
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
startCreateTask:
|
||||
storagePath := stor.JoinStoragePath(path.Join(dirPath, file.Name()))
|
||||
injectCtx := tgutil.ExtWithContext(ctx.Context, ctx)
|
||||
taskid := xid.New().String()
|
||||
task, err := tfile.NewTGFileTask(taskid, injectCtx, file, stor, storagePath, nil)
|
||||
if err != nil {
|
||||
logger.Errorf("create task failed: %s", err)
|
||||
continue
|
||||
}
|
||||
if err := core.AddTask(injectCtx, task); err != nil {
|
||||
logger.Errorf("add task failed: %s", err)
|
||||
continue
|
||||
}
|
||||
logger.Infof("Added media message task for user %d in chat %d: %s", chat.UserID, event.ChatID, file.Name())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ import (
|
||||
func NewDefaultMiddlewares(ctx context.Context, timeout time.Duration) []telegram.Middleware {
|
||||
return []telegram.Middleware{
|
||||
recovery.New(ctx, newBackoff(timeout)),
|
||||
retry.New(config.Cfg.Telegram.RpcRetry),
|
||||
retry.New(config.C().Telegram.RpcRetry),
|
||||
floodwait.NewSimpleWaiter(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,8 +54,8 @@ func Login(ctx context.Context) (*gotgproto.Client, error) {
|
||||
})
|
||||
go func() {
|
||||
var resolver dcs.Resolver
|
||||
if config.Cfg.Telegram.Proxy.Enable && config.Cfg.Telegram.Proxy.URL != "" {
|
||||
dialer, err := netutil.NewProxyDialer(config.Cfg.Telegram.Proxy.URL)
|
||||
if config.C().Telegram.Proxy.Enable && config.C().Telegram.Proxy.URL != "" {
|
||||
dialer, err := netutil.NewProxyDialer(config.C().Telegram.Proxy.URL)
|
||||
if err != nil {
|
||||
res <- struct {
|
||||
client *gotgproto.Client
|
||||
@@ -70,16 +70,16 @@ func Login(ctx context.Context) (*gotgproto.Client, error) {
|
||||
resolver = dcs.DefaultResolver()
|
||||
}
|
||||
tclient, err := gotgproto.NewClient(
|
||||
config.Cfg.Telegram.AppID,
|
||||
config.Cfg.Telegram.AppHash,
|
||||
config.C().Telegram.AppID,
|
||||
config.C().Telegram.AppHash,
|
||||
gotgproto.ClientTypePhone(""),
|
||||
&gotgproto.ClientOpts{
|
||||
Session: sessionMaker.SqlSession(gormlite.Open(config.Cfg.Telegram.Userbot.Session)),
|
||||
Session: sessionMaker.SqlSession(gormlite.Open(config.C().Telegram.Userbot.Session)),
|
||||
AuthConversator: &terminalAuthConversator{},
|
||||
Context: ctx,
|
||||
DisableCopyright: true,
|
||||
Resolver: resolver,
|
||||
MaxRetries: config.Cfg.Telegram.RpcRetry,
|
||||
MaxRetries: config.C().Telegram.RpcRetry,
|
||||
AutoFetchReply: true,
|
||||
Middlewares: middleware.NewDefaultMiddlewares(ctx, 5*time.Minute),
|
||||
ErrorHandler: func(ctx *ext.Context, u *ext.Update, s string) error {
|
||||
|
||||
@@ -8,9 +8,10 @@ import (
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/goccy/go-yaml"
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -20,28 +21,27 @@ func main() {
|
||||
flag.Parse()
|
||||
|
||||
keys := make(map[string]struct{})
|
||||
re := regexp.MustCompile(`^\s*\[+\s*([^\]\[]+)\s*\]+`)
|
||||
|
||||
err := filepath.WalkDir(*dir, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if d.IsDir() || !strings.HasSuffix(d.Name(), ".toml") {
|
||||
if d.IsDir() || !(strings.HasSuffix(d.Name(), ".yaml") || strings.HasSuffix(d.Name(), ".yml")) {
|
||||
return nil
|
||||
}
|
||||
f, err := os.Open(path)
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
s := bufio.NewScanner(f)
|
||||
for s.Scan() {
|
||||
if m := re.FindStringSubmatch(s.Text()); m != nil {
|
||||
keys[m[1]] = struct{}{}
|
||||
}
|
||||
var content map[string]interface{}
|
||||
if err := yaml.Unmarshal(data, &content); err != nil {
|
||||
return fmt.Errorf("failed to parse yaml %s: %w", path, err)
|
||||
}
|
||||
return s.Err()
|
||||
|
||||
collectKeys(content, "", keys)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error walking directory: %v\n", err)
|
||||
@@ -62,23 +62,44 @@ func main() {
|
||||
defer f.Close()
|
||||
|
||||
w := bufio.NewWriter(f)
|
||||
fmt.Fprintf(w, "// Code generated by cmd/gen_i18n. DO NOT EDIT.\n")
|
||||
fmt.Fprintf(w, "// Code generated by cmd/geni18n. DO NOT EDIT.\n")
|
||||
fmt.Fprintf(w, "package %s\n\n", *pkg)
|
||||
fmt.Fprintf(w, "type Key string\n\n")
|
||||
fmt.Fprintf(w, "const (\n")
|
||||
for _, key := range list {
|
||||
name := toPascal(key)
|
||||
fmt.Fprintf(w, "\t%s = %q\n", name, key)
|
||||
fmt.Fprintf(w, "\t%s Key = %q\n", name, key)
|
||||
}
|
||||
fmt.Fprintf(w, ")\n")
|
||||
w.Flush()
|
||||
}
|
||||
|
||||
func collectKeys(node map[string]interface{}, prefix string, keys map[string]struct{}) {
|
||||
for k, v := range node {
|
||||
fullKey := k
|
||||
if prefix != "" {
|
||||
fullKey = prefix + "." + k
|
||||
}
|
||||
switch val := v.(type) {
|
||||
case map[string]interface{}:
|
||||
collectKeys(val, fullKey, keys)
|
||||
default:
|
||||
keys[fullKey] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 转 PascalCase
|
||||
func toPascal(key string) string {
|
||||
parts := strings.Split(key, ".")
|
||||
for i, p := range parts {
|
||||
if len(p) > 0 {
|
||||
parts[i] = strings.ToUpper(string(p[0])) + p[1:]
|
||||
subs := strings.Split(p, "_")
|
||||
for j, s := range subs {
|
||||
if len(s) > 0 {
|
||||
subs[j] = strings.ToUpper(s[:1]) + s[1:]
|
||||
}
|
||||
}
|
||||
parts[i] = strings.Join(subs, "")
|
||||
}
|
||||
return strings.Join(parts, "")
|
||||
}
|
||||
|
||||
61
cmd/run.go
61
cmd/run.go
@@ -25,7 +25,7 @@ import (
|
||||
)
|
||||
|
||||
func Run(cmd *cobra.Command, _ []string) {
|
||||
ctx := cmd.Context()
|
||||
ctx, cancel := context.WithCancel(cmd.Context())
|
||||
logger := log.NewWithOptions(os.Stdout, log.Options{
|
||||
Level: log.DebugLevel,
|
||||
ReportTimestamp: true,
|
||||
@@ -34,75 +34,84 @@ func Run(cmd *cobra.Command, _ []string) {
|
||||
})
|
||||
ctx = log.WithContext(ctx, logger)
|
||||
|
||||
initAll(ctx)
|
||||
exitChan, err := initAll(ctx)
|
||||
if err != nil {
|
||||
logger.Fatal("Init failed", "error", err)
|
||||
}
|
||||
go func() {
|
||||
<-exitChan
|
||||
cancel()
|
||||
}()
|
||||
|
||||
core.Run(ctx)
|
||||
|
||||
<-ctx.Done()
|
||||
logger.Info(i18n.T(i18nk.Exiting))
|
||||
defer logger.Info(i18n.T(i18nk.Bye))
|
||||
logger.Info(i18n.T(i18nk.LifetimeExiting))
|
||||
defer logger.Info(i18n.T(i18nk.LifetimeBye))
|
||||
cleanCache()
|
||||
}
|
||||
|
||||
func initAll(ctx context.Context) {
|
||||
func initAll(ctx context.Context) (<-chan struct{}, error) {
|
||||
if err := config.Init(ctx); err != nil {
|
||||
fmt.Println("Failed to load config:", err)
|
||||
os.Exit(1)
|
||||
return nil, fmt.Errorf("failed to load config: %w", err)
|
||||
}
|
||||
cache.Init()
|
||||
logger := log.FromContext(ctx)
|
||||
i18n.Init(config.Cfg.Lang)
|
||||
logger.Info(i18n.T(i18nk.Initing))
|
||||
i18n.Init(config.C().Lang)
|
||||
logger.Info(i18n.T(i18nk.LifetimeIniting))
|
||||
database.Init(ctx)
|
||||
storage.LoadStorages(ctx)
|
||||
if config.Cfg.Parser.PluginEnable {
|
||||
for _, dir := range config.Cfg.Parser.PluginDirs {
|
||||
if config.C().Parser.PluginEnable {
|
||||
for _, dir := range config.C().Parser.PluginDirs {
|
||||
if err := parsers.LoadPlugins(ctx, dir); err != nil {
|
||||
logger.Error("Failed to load parser plugins", "dir", dir, "error", err)
|
||||
logger.Error(i18n.T(i18nk.ParserPluginLoadFailed), "dir", dir, "error", err)
|
||||
} else {
|
||||
logger.Debug("Loaded parser plugins", "dir", dir)
|
||||
logger.Debug(i18n.T(i18nk.ParserPluginLoadedDir), "dir", dir)
|
||||
}
|
||||
}
|
||||
}
|
||||
if config.Cfg.Telegram.Userbot.Enable {
|
||||
if config.C().Telegram.Userbot.Enable {
|
||||
_, err := userclient.Login(ctx)
|
||||
if err != nil {
|
||||
logger.Fatalf("User client login failed: %s", err)
|
||||
logger.Fatal(i18n.T(i18nk.LifetimeUserLoginFailed, map[string]any{
|
||||
"Error": err,
|
||||
}))
|
||||
}
|
||||
}
|
||||
bot.Init(ctx)
|
||||
return bot.Init(ctx), nil
|
||||
}
|
||||
|
||||
func cleanCache() {
|
||||
if config.Cfg.NoCleanCache {
|
||||
if config.C().NoCleanCache {
|
||||
return
|
||||
}
|
||||
if config.Cfg.Temp.BasePath != "" && !config.Cfg.Stream {
|
||||
if slices.Contains([]string{"/", ".", "\\", ".."}, filepath.Clean(config.Cfg.Temp.BasePath)) {
|
||||
log.Error(i18n.T(i18nk.InvalidCacheDir, map[string]any{
|
||||
"Path": config.Cfg.Temp.BasePath,
|
||||
if config.C().Temp.BasePath != "" && !config.C().Stream {
|
||||
if slices.Contains([]string{"/", ".", "\\", ".."}, filepath.Clean(config.C().Temp.BasePath)) {
|
||||
log.Error(i18n.T(i18nk.ConfigErrInvalidCacheDir, map[string]any{
|
||||
"Path": config.C().Temp.BasePath,
|
||||
}))
|
||||
return
|
||||
}
|
||||
currentDir, err := os.Getwd()
|
||||
if err != nil {
|
||||
log.Error(i18n.T(i18nk.GetWorkdirFailed, map[string]any{
|
||||
log.Error(i18n.T(i18nk.ErrGetWorkdirFailed, map[string]any{
|
||||
"Error": err,
|
||||
}))
|
||||
return
|
||||
}
|
||||
cachePath := filepath.Join(currentDir, config.Cfg.Temp.BasePath)
|
||||
cachePath := filepath.Join(currentDir, config.C().Temp.BasePath)
|
||||
cachePath, err = filepath.Abs(cachePath)
|
||||
if err != nil {
|
||||
log.Error(i18n.T(i18nk.GetCacheAbsPathFailed, map[string]any{
|
||||
log.Error(i18n.T(i18nk.ErrGetCacheAbsPathFailed, map[string]any{
|
||||
"Error": err,
|
||||
}))
|
||||
return
|
||||
}
|
||||
log.Info(i18n.T(i18nk.CleaningCache, map[string]any{
|
||||
log.Info(i18n.T(i18nk.LifetimeCleaningCache, map[string]any{
|
||||
"Path": cachePath,
|
||||
}))
|
||||
if err := fsutil.RemoveAllInDir(cachePath); err != nil {
|
||||
log.Error(i18n.T(i18nk.CleanCacheFailed, map[string]any{
|
||||
log.Error(i18n.T(i18nk.ErrCleanCacheFailed, map[string]any{
|
||||
"Error": err,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"fmt"
|
||||
"runtime"
|
||||
|
||||
"github.com/krau/SaveAny-Bot/pkg/consts"
|
||||
"github.com/krau/SaveAny-Bot/config"
|
||||
"github.com/rhysd/go-github-selfupdate/selfupdate"
|
||||
|
||||
"github.com/blang/semver"
|
||||
@@ -16,7 +16,7 @@ var VersionCmd = &cobra.Command{
|
||||
Aliases: []string{"v"},
|
||||
Short: "Print the version number of saveany-bot",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
fmt.Printf("saveany-bot version: %s %s/%s\nBuildTime: %s, Commit: %s\n", consts.Version, runtime.GOOS, runtime.GOARCH, consts.BuildTime, consts.GitCommit)
|
||||
fmt.Printf("saveany-bot version: %s %s/%s\nBuildTime: %s, Commit: %s\n", config.Version, runtime.GOOS, runtime.GOARCH, config.BuildTime, config.GitCommit)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -25,14 +25,14 @@ var upgradeCmd = &cobra.Command{
|
||||
Aliases: []string{"up"},
|
||||
Short: "Upgrade saveany-bot to the latest version",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
v := semver.MustParse(consts.Version)
|
||||
latest, err := selfupdate.UpdateSelf(v, "krau/SaveAny-Bot")
|
||||
v := semver.MustParse(config.Version)
|
||||
latest, err := selfupdate.UpdateSelf(v, config.GitRepo)
|
||||
if err != nil {
|
||||
fmt.Println("Binary update failed:", err)
|
||||
fmt.Println("Update failed:", err)
|
||||
return
|
||||
}
|
||||
if latest.Version.Equals(v) {
|
||||
fmt.Println("Current binary is the latest version", consts.Version)
|
||||
fmt.Println("Current binary is the latest version", config.Version)
|
||||
} else {
|
||||
fmt.Println("Successfully updated to version", latest.Version)
|
||||
fmt.Println("Release note:\n", latest.ReleaseNotes)
|
||||
|
||||
6
common/cache/ristretto.go
vendored
6
common/cache/ristretto.go
vendored
@@ -16,8 +16,8 @@ func Init() {
|
||||
panic("cache already initialized")
|
||||
}
|
||||
c, err := ristretto.NewCache(&ristretto.Config[string, any]{
|
||||
NumCounters: config.Cfg.Cache.NumCounters,
|
||||
MaxCost: config.Cfg.Cache.MaxCost,
|
||||
NumCounters: config.C().Cache.NumCounters,
|
||||
MaxCost: config.C().Cache.MaxCost,
|
||||
BufferItems: 64,
|
||||
OnReject: func(item *ristretto.Item[any]) {
|
||||
log.Warnf("Cache item rejected: key=%d, value=%v", item.Key, item.Value)
|
||||
@@ -30,7 +30,7 @@ func Init() {
|
||||
}
|
||||
|
||||
func Set(key string, value any) error {
|
||||
ok := cache.SetWithTTL(key, value, 0, time.Duration(config.Cfg.Cache.TTL)*time.Second)
|
||||
ok := cache.SetWithTTL(key, value, 0, time.Duration(config.C().Cache.TTL)*time.Second)
|
||||
if !ok {
|
||||
return fmt.Errorf("failed to set value in cache")
|
||||
}
|
||||
|
||||
@@ -5,12 +5,13 @@ import (
|
||||
|
||||
"maps"
|
||||
|
||||
"github.com/goccy/go-yaml"
|
||||
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
|
||||
"github.com/nicksnyder/go-i18n/v2/i18n"
|
||||
"github.com/pelletier/go-toml/v2"
|
||||
"golang.org/x/text/language"
|
||||
)
|
||||
|
||||
//go:embed locale/*.toml
|
||||
//go:embed locale/*
|
||||
var localesFS embed.FS
|
||||
|
||||
var (
|
||||
@@ -20,7 +21,7 @@ var (
|
||||
|
||||
func Init(lang string) {
|
||||
bundle = i18n.NewBundle(language.SimplifiedChinese)
|
||||
bundle.RegisterUnmarshalFunc("toml", toml.Unmarshal)
|
||||
bundle.RegisterUnmarshalFunc("yaml", yaml.Unmarshal)
|
||||
files, err := localesFS.ReadDir("locale")
|
||||
if err != nil {
|
||||
panic("failed to read locale directory: " + err.Error())
|
||||
@@ -39,7 +40,7 @@ func Init(lang string) {
|
||||
}
|
||||
}
|
||||
|
||||
func T(key string, templateData ...map[string]any) string {
|
||||
func T(key i18nk.Key, templateData ...map[string]any) string {
|
||||
if localizer == nil || bundle == nil {
|
||||
panic("localizer or bundle is not initialized, call Init() first")
|
||||
}
|
||||
@@ -48,11 +49,11 @@ func T(key string, templateData ...map[string]any) string {
|
||||
maps.Copy(templateDataMap, data)
|
||||
}
|
||||
msg, err := localizer.Localize(&i18n.LocalizeConfig{
|
||||
MessageID: key,
|
||||
MessageID: string(key),
|
||||
TemplateData: templateDataMap,
|
||||
})
|
||||
if err != nil {
|
||||
return key
|
||||
return string(key)
|
||||
}
|
||||
return msg
|
||||
}
|
||||
@@ -77,32 +78,32 @@ func TWithLang(lang, key string, templateData ...map[string]any) string {
|
||||
}
|
||||
|
||||
// Only use in tests or packages that load before i18n
|
||||
func TWithoutInit(lang, key string, templateData ...map[string]any) string {
|
||||
func TWithoutInit(lang string, key i18nk.Key, templateData ...map[string]any) string {
|
||||
bundle := i18n.NewBundle(language.SimplifiedChinese)
|
||||
bundle.RegisterUnmarshalFunc("toml", toml.Unmarshal)
|
||||
bundle.RegisterUnmarshalFunc("yaml", yaml.Unmarshal)
|
||||
files, err := localesFS.ReadDir("locale")
|
||||
if err != nil {
|
||||
return key
|
||||
return string(key)
|
||||
}
|
||||
for _, file := range files {
|
||||
if _, err := bundle.LoadMessageFileFS(localesFS, "locale/"+file.Name()); err != nil {
|
||||
return key
|
||||
return string(key)
|
||||
}
|
||||
}
|
||||
localizer := i18n.NewLocalizer(bundle, lang)
|
||||
if localizer == nil {
|
||||
return key
|
||||
return string(key)
|
||||
}
|
||||
templateDataMap := make(map[string]any)
|
||||
for _, data := range templateData {
|
||||
maps.Copy(templateDataMap, data)
|
||||
}
|
||||
msg, err := localizer.Localize(&i18n.LocalizeConfig{
|
||||
MessageID: key,
|
||||
MessageID: string(key),
|
||||
TemplateData: templateDataMap,
|
||||
})
|
||||
if err != nil {
|
||||
return key
|
||||
return string(key)
|
||||
}
|
||||
return msg
|
||||
}
|
||||
|
||||
@@ -1,19 +1,24 @@
|
||||
// Code generated by cmd/gen_i18n. DO NOT EDIT.
|
||||
// Code generated by cmd/geni18n. DO NOT EDIT.
|
||||
package i18nk
|
||||
|
||||
type Key string
|
||||
|
||||
const (
|
||||
CleanCacheFailed = "CleanCacheFailed"
|
||||
CleaningCache = "CleaningCache"
|
||||
ConfigInvalidDuplicateStorageName = "ConfigInvalid.DuplicateStorageName"
|
||||
ConfigInvalidWorkersOrRetry = "ConfigInvalid.WorkersOrRetry"
|
||||
CreateRmTimerFailed = "CreateRmTimerFailed"
|
||||
GetCacheAbsPathFailed = "GetCacheAbsPathFailed"
|
||||
GetWorkdirFailed = "GetWorkdirFailed"
|
||||
InvalidCacheDir = "InvalidCacheDir"
|
||||
LoadedStorages = "LoadedStorages"
|
||||
RemoveFileAfter = "RemoveFileAfter"
|
||||
RemoveFileFailed = "RemoveFileFailed"
|
||||
Bye = "bye"
|
||||
Exiting = "exiting"
|
||||
Initing = "initing"
|
||||
BotMsgHelpTextFmt Key = "bot.msg.help_text_fmt"
|
||||
BotMsgSaveHelpText Key = "bot.msg.save_help_text"
|
||||
BotMsgWatchHelpText Key = "bot.msg.watch_help_text"
|
||||
ConfigErrDuplicateStorageName Key = "config.err.duplicate_storage_name"
|
||||
ConfigErrInvalidCacheDir Key = "config.err.invalid_cache_dir"
|
||||
ConfigLoadedStorages Key = "config.loaded_storages"
|
||||
ErrCleanCacheFailed Key = "err.clean_cache_failed"
|
||||
ErrGetCacheAbsPathFailed Key = "err.get_cache_abs_path_failed"
|
||||
ErrGetWorkdirFailed Key = "err.get_workdir_failed"
|
||||
LifetimeBye Key = "lifetime.bye"
|
||||
LifetimeCleaningCache Key = "lifetime.cleaning_cache"
|
||||
LifetimeExiting Key = "lifetime.exiting"
|
||||
LifetimeInitfailed Key = "lifetime.initfailed"
|
||||
LifetimeIniting Key = "lifetime.initing"
|
||||
LifetimeUserLoginFailed Key = "lifetime.user_login_failed"
|
||||
ParserPluginLoadFailed Key = "parser.plugin.load_failed"
|
||||
ParserPluginLoadedDir Key = "parser.plugin.loaded_dir"
|
||||
)
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
[initing]
|
||||
other = "正在启动..."
|
||||
[exiting]
|
||||
other = "正在退出..."
|
||||
[bye]
|
||||
other = "已退出"
|
||||
[InvalidCacheDir]
|
||||
other = "无效的缓存文件夹: {{.Path}}"
|
||||
[GetWorkdirFailed]
|
||||
other = "获取工作目录失败: {{.Error}}"
|
||||
[GetCacheAbsPathFailed]
|
||||
other = "获取缓存绝对路径失败: {{.Error}}"
|
||||
[CleaningCache]
|
||||
other = "正在清理缓存文件夹: {{.Path}}"
|
||||
[CleanCacheFailed]
|
||||
other = "清理缓存失败: {{.Error}}"
|
||||
[CreateRmTimerFailed]
|
||||
other = "创建清理定时器失败, 路径: {{.Path}}, 错误: {{.Error}}"
|
||||
[RemoveFileAfter]
|
||||
other = "将在 {{.Duration}} 后删除文件: {{.Path}}"
|
||||
[RemoveFileFailed]
|
||||
other = "删除文件失败: {{.Path}}, 错误: {{.Error}}"
|
||||
[LoadedStorages]
|
||||
other = "已加载 {{.Count}} 个存储"
|
||||
[ConfigInvalid.WorkersOrRetry]
|
||||
other = "配置无效: workers 或 retry 必须大于 0, 但当前值为: workers={{.Workers}}, retry={{.Retry}}"
|
||||
[ConfigInvalid.DuplicateStorageName]
|
||||
other = "存储名称重复: {{.Name}}"
|
||||
62
common/i18n/locale/zh-Hans.yaml
Normal file
62
common/i18n/locale/zh-Hans.yaml
Normal file
@@ -0,0 +1,62 @@
|
||||
lifetime:
|
||||
initing: 正在启动
|
||||
initfailed: 初始化失败
|
||||
exiting: 正在退出
|
||||
user_login_failed: "用户登录失败: {{.Error}}"
|
||||
cleaning_cache: "正在清理缓存 {{.Path}}"
|
||||
bye: 已退出
|
||||
config:
|
||||
loaded_storages: "已加载 {{.Count}} 个存储后端"
|
||||
err:
|
||||
invalid_cache_dir: "无效的缓存目录: {{.Path}},请检查配置文件"
|
||||
duplicate_storage_name: "存储名称 '{{.Name}}' 重复,请检查配置文件"
|
||||
err:
|
||||
get_workdir_failed: "获取工作目录失败: {{.Error}}"
|
||||
get_cache_abs_path_failed: "获取缓存绝对路径失败: {{.Error}}"
|
||||
clean_cache_failed: "清理缓存失败: {{.Error}}"
|
||||
parser:
|
||||
plugin:
|
||||
load_failed: 加载解析器插件失败
|
||||
loaded_dir: 解析器插件已加载
|
||||
bot:
|
||||
msg:
|
||||
help_text_fmt: |
|
||||
Save Any Bot - 转存你的 Telegram 文件
|
||||
版本: %s , 提交: %s
|
||||
|
||||
命令:
|
||||
/start - 开始使用
|
||||
/help - 显示帮助
|
||||
/silent - 开关静默模式
|
||||
/storage - 设置默认存储位置
|
||||
/save [自定义文件名] - 保存文件
|
||||
/dir - 管理存储目录
|
||||
/rule - 管理规则
|
||||
/update - 检查更新并升级
|
||||
|
||||
使用帮助: https://sabot.unv.app/usage
|
||||
反馈群组: https://t.me/ProjectSaveAny
|
||||
save_help_text: |
|
||||
使用方法:
|
||||
|
||||
1. 使用该命令回复要保存的文件, 可选文件名参数.
|
||||
示例:
|
||||
/save custom_file_name.mp4
|
||||
|
||||
2. 设置默认存储后, 发送 /save <频道ID/用户名> <消息ID范围> 来批量保存文件. 遵从存储规则, 若未匹配到任何规则则使用默认存储.
|
||||
示例:
|
||||
/save @acherkrau 114-514
|
||||
watch_help_text: |
|
||||
使用 /watch 命令监听一个聊天的消息, 并自动保存到默认存储中, 遵从存储规则.
|
||||
|
||||
命令语法:
|
||||
/watch <chat_id> [filter]
|
||||
|
||||
参数:
|
||||
- <chat_id>: 聊天的 ID 或用户名
|
||||
- [filter]: 可选, 格式为 过滤器类型:表达式 , 所有支持类型的过滤器请查看文档
|
||||
|
||||
命令示例:
|
||||
/watch 2229835658 msgre:.*plana.*
|
||||
|
||||
这将监听 ID 为 2229835658 的聊天, 并转存所有包含 "plana" 的媒体消息
|
||||
@@ -3,6 +3,8 @@ package fsutil
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"github.com/gabriel-vasile/mimetype"
|
||||
)
|
||||
@@ -55,3 +57,21 @@ func CreateFile(fp string) (*File, error) {
|
||||
}
|
||||
return &File{File: file}, nil
|
||||
}
|
||||
|
||||
func NormalizePathname(s string) string {
|
||||
specials := `\/:*?"<>|` + "\n\r\t"
|
||||
var builder strings.Builder
|
||||
for _, ch := range s {
|
||||
if strings.ContainsRune(specials, ch) || unicode.IsControl(ch) {
|
||||
builder.WriteRune('_')
|
||||
} else {
|
||||
builder.WriteRune(ch)
|
||||
}
|
||||
}
|
||||
|
||||
result := strings.TrimRightFunc(builder.String(), func(r rune) bool {
|
||||
return r == '.' || r == '_' || unicode.IsSpace(r)
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
46
common/utils/fsutil/normalize_pathname_test.go
Normal file
46
common/utils/fsutil/normalize_pathname_test.go
Normal file
@@ -0,0 +1,46 @@
|
||||
package fsutil_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/krau/SaveAny-Bot/common/utils/fsutil"
|
||||
)
|
||||
|
||||
func TestNormalizePathname(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
input: "hello/world?.txt ",
|
||||
expected: "hello_world_.txt",
|
||||
},
|
||||
{
|
||||
input: "bad|name:\nfile\r.",
|
||||
expected: "bad_name__file",
|
||||
},
|
||||
{
|
||||
input: "normal.txt",
|
||||
expected: "normal.txt",
|
||||
},
|
||||
{
|
||||
input: "test.... ",
|
||||
expected: "test",
|
||||
},
|
||||
{
|
||||
input: "abc<>def",
|
||||
expected: "abc__def",
|
||||
},
|
||||
{
|
||||
input: "with\tcontrol",
|
||||
expected: "with_control",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
got := fsutil.NormalizePathname(tc.input)
|
||||
if got != tc.expected {
|
||||
t.Errorf("NormalizePathname(%q) = %q; want %q", tc.input, got, tc.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,15 @@
|
||||
package netutil
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sync"
|
||||
|
||||
"github.com/charmbracelet/log"
|
||||
"github.com/krau/SaveAny-Bot/config"
|
||||
"golang.org/x/net/proxy"
|
||||
)
|
||||
|
||||
@@ -13,3 +20,59 @@ func NewProxyDialer(proxyUrl string) (proxy.Dialer, error) {
|
||||
}
|
||||
return proxy.FromURL(url, proxy.Direct)
|
||||
}
|
||||
|
||||
func NewProxyHTTPClient(proxyUrl string) (*http.Client, error) {
|
||||
if proxyUrl == "" {
|
||||
return &http.Client{
|
||||
Transport: &http.Transport{
|
||||
Proxy: http.ProxyFromEnvironment,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
u, err := url.Parse(proxyUrl)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
switch u.Scheme {
|
||||
case "http", "https":
|
||||
return &http.Client{
|
||||
Transport: &http.Transport{
|
||||
Proxy: http.ProxyURL(u),
|
||||
},
|
||||
}, nil
|
||||
case "socks5":
|
||||
dialer, err := proxy.FromURL(u, proxy.Direct)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &http.Client{
|
||||
Transport: &http.Transport{
|
||||
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
return dialer.Dial(network, addr)
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported proxy scheme: %s", u.Scheme)
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
defaultProxyHttpClient *http.Client
|
||||
onceLoadDefaultProxyHttpClient sync.Once
|
||||
)
|
||||
|
||||
func DefaultParserHTTPClient() *http.Client {
|
||||
onceLoadDefaultProxyHttpClient.Do(func() {
|
||||
client, err := NewProxyHTTPClient(config.C().Parser.Proxy)
|
||||
if err != nil {
|
||||
log.Warn("Failed to create default proxy HTTP client, using http.DefaultClient", "error", err)
|
||||
defaultProxyHttpClient = http.DefaultClient
|
||||
} else {
|
||||
defaultProxyHttpClient = client
|
||||
}
|
||||
})
|
||||
return defaultProxyHttpClient
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode"
|
||||
"unicode/utf16"
|
||||
|
||||
"github.com/celestix/gotgproto/ext"
|
||||
"github.com/duke-git/lancet/v2/maputil"
|
||||
@@ -61,16 +63,12 @@ func GenFileNameFromMessage(message tg.Message) string {
|
||||
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':
|
||||
}
|
||||
if unicode.IsControl(r) || unicode.IsSpace(r) {
|
||||
return '_'
|
||||
}
|
||||
if validator.IsPrintable(string(r)) {
|
||||
@@ -309,3 +307,49 @@ func GetGroupedMessages(ctx *ext.Context, chatID int64, msg *tg.Message) ([]*tg.
|
||||
}
|
||||
return groupedMessages, nil
|
||||
}
|
||||
|
||||
func ExtractMessageEntityUrls(msg *tg.Message) []string {
|
||||
if len(msg.Entities) == 0 {
|
||||
return nil
|
||||
}
|
||||
msgText := msg.GetMessage()
|
||||
if msgText == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
runes := []rune(msgText)
|
||||
utf16Codes := utf16.Encode(runes)
|
||||
|
||||
var urls []string
|
||||
for _, entity := range msg.Entities {
|
||||
switch ent := entity.(type) {
|
||||
case *tg.MessageEntityTextURL:
|
||||
urls = append(urls, ent.GetURL())
|
||||
case *tg.MessageEntityURL:
|
||||
start := ent.Offset
|
||||
end := ent.Offset + ent.Length
|
||||
if start < 0 || end > len(utf16Codes) {
|
||||
continue
|
||||
}
|
||||
subRunes := utf16.Decode(utf16Codes[start:end])
|
||||
urls = append(urls, string(subRunes))
|
||||
}
|
||||
}
|
||||
return urls
|
||||
}
|
||||
|
||||
func ExtractMessageEntityUrlsText(msg *tg.Message) string {
|
||||
if msg == nil {
|
||||
return ""
|
||||
}
|
||||
urls := ExtractMessageEntityUrls(msg)
|
||||
if len(urls) == 0 {
|
||||
return msg.GetMessage()
|
||||
}
|
||||
var sb strings.Builder
|
||||
for _, url := range urls {
|
||||
sb.WriteString(url)
|
||||
sb.WriteString(" ")
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
16
common/utils/tgutil/peer.go
Normal file
16
common/utils/tgutil/peer.go
Normal file
@@ -0,0 +1,16 @@
|
||||
package tgutil
|
||||
|
||||
import "github.com/gotd/td/tg"
|
||||
|
||||
func ChatIdFromPeer(peer tg.PeerClass) int64 {
|
||||
switch peer := peer.(type) {
|
||||
case *tg.PeerChannel:
|
||||
return peer.ChannelID
|
||||
case *tg.PeerUser:
|
||||
return peer.UserID
|
||||
case *tg.PeerChat:
|
||||
return peer.ChatID
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
@@ -86,7 +86,7 @@ func ParseMessageLink(ctx *ext.Context, link string) (int64, int, error) {
|
||||
return chatID, msgID, nil
|
||||
case 3:
|
||||
// https://t.me/c/123456789/123
|
||||
// https://t.me/acherkrau/123/456 , 456: message thread ID
|
||||
// https://t.me/acherkrau/123/456 , 123: topic id
|
||||
chatPart, msgPart := paths[1], paths[2]
|
||||
if paths[0] != "c" {
|
||||
chatPart = paths[0]
|
||||
|
||||
@@ -13,8 +13,8 @@ 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
|
||||
if config.C().Telegram.Proxy.Enable && config.C().Telegram.Proxy.URL != "" {
|
||||
proxyUrl := config.C().Telegram.Proxy.URL
|
||||
var err error
|
||||
tphClient, err = telegraph.NewClientWithProxy(proxyUrl)
|
||||
if err != nil {
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
package config
|
||||
|
||||
type parserConfig struct {
|
||||
PluginEnable bool `toml:"plugin_enable" mapstructure:"plugin_enable" json:"plugin_enable"`
|
||||
PluginDirs []string `toml:"plugin_dirs" mapstructure:"plugin_dirs" json:"plugin_dirs"`
|
||||
PluginEnable bool `toml:"plugin_enable" mapstructure:"plugin_enable" json:"plugin_enable"`
|
||||
PluginDirs []string `toml:"plugin_dirs" mapstructure:"plugin_dirs" json:"plugin_dirs"`
|
||||
Proxy string `toml:"proxy" mapstructure:"proxy" json:"proxy"`
|
||||
ParserCfgs map[string]map[string]any `mapstructure:",remain"`
|
||||
}
|
||||
|
||||
func (c Config) GetParserConfigByName(name string) map[string]any {
|
||||
if c.Parser.ParserCfgs == nil {
|
||||
return nil
|
||||
}
|
||||
return c.Parser.ParserCfgs[name]
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
type TelegramStorageConfig struct {
|
||||
BaseConfig
|
||||
ChatID int64 `toml:"chat_id" mapstructure:"chat_id" json:"chat_id"`
|
||||
ForceFile bool `toml:"force_file" mapstructure:"force_file" json:"force_file"`
|
||||
RateLimit int `toml:"rate_limit" mapstructure:"rate_limit" json:"rate_limit"`
|
||||
RateBurst int `toml:"rate_burst" mapstructure:"rate_burst" json:"rate_burst"`
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ var userIDs []int64
|
||||
var storages []string
|
||||
var userStorages = make(map[int64][]string)
|
||||
|
||||
func (c *Config) GetStorageNamesByUserID(userID int64) []string {
|
||||
func (c Config) GetStorageNamesByUserID(userID int64) []string {
|
||||
us, ok := userStorages[userID]
|
||||
if ok {
|
||||
return us
|
||||
@@ -22,11 +22,11 @@ func (c *Config) GetStorageNamesByUserID(userID int64) []string {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Config) GetUsersID() []int64 {
|
||||
func (c Config) GetUsersID() []int64 {
|
||||
return userIDs
|
||||
}
|
||||
|
||||
func (c *Config) HasStorage(userID int64, storageName string) bool {
|
||||
func (c Config) HasStorage(userID int64, storageName string) bool {
|
||||
us, ok := userStorages[userID]
|
||||
if !ok {
|
||||
return false
|
||||
|
||||
13
config/version.go
Normal file
13
config/version.go
Normal file
@@ -0,0 +1,13 @@
|
||||
package config
|
||||
|
||||
// inject version by '-X' flag
|
||||
// go build -ldflags "-X github.com/krau/SaveAny-Bot/config.Version=${{ env.VERSION }}"
|
||||
var (
|
||||
Version string = "dev"
|
||||
BuildTime string = "unknown"
|
||||
GitCommit string = "unknown"
|
||||
)
|
||||
|
||||
const (
|
||||
GitRepo = "krau/SaveAny-Bot"
|
||||
)
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/duke-git/lancet/v2/slice"
|
||||
@@ -32,7 +31,11 @@ type Config struct {
|
||||
Hook hookConfig `toml:"hook" mapstructure:"hook" json:"hook"`
|
||||
}
|
||||
|
||||
var Cfg *Config = &Config{}
|
||||
var cfg = &Config{}
|
||||
|
||||
func C() Config {
|
||||
return *cfg
|
||||
}
|
||||
|
||||
func (c Config) GetStorageByName(name string) storage.StorageConfig {
|
||||
for _, storage := range c.Storages {
|
||||
@@ -92,48 +95,51 @@ func Init(ctx context.Context) error {
|
||||
|
||||
if err := viper.ReadInConfig(); err != nil {
|
||||
fmt.Println("Error reading config file, ", err)
|
||||
os.Exit(1)
|
||||
return err
|
||||
}
|
||||
|
||||
if err := viper.Unmarshal(Cfg); err != nil {
|
||||
if err := viper.Unmarshal(cfg); err != nil {
|
||||
fmt.Println("Error unmarshalling config file, ", err)
|
||||
os.Exit(1)
|
||||
return err
|
||||
}
|
||||
|
||||
storagesConfig, err := storage.LoadStorageConfigs(viper.GetViper())
|
||||
if err != nil {
|
||||
return fmt.Errorf("error loading storage configs: %w", err)
|
||||
}
|
||||
Cfg.Storages = storagesConfig
|
||||
cfg.Storages = storagesConfig
|
||||
|
||||
storageNames := make(map[string]struct{})
|
||||
for _, storage := range Cfg.Storages {
|
||||
for _, storage := range cfg.Storages {
|
||||
if _, ok := storageNames[storage.GetName()]; ok {
|
||||
return errors.New(i18n.TWithoutInit(Cfg.Lang, i18nk.ConfigInvalidDuplicateStorageName, map[string]any{
|
||||
return errors.New(i18n.TWithoutInit(cfg.Lang, i18nk.ConfigErrDuplicateStorageName, map[string]any{
|
||||
"Name": storage.GetName(),
|
||||
}))
|
||||
}
|
||||
storageNames[storage.GetName()] = struct{}{}
|
||||
}
|
||||
|
||||
fmt.Println(i18n.TWithoutInit(Cfg.Lang, i18nk.LoadedStorages, map[string]any{
|
||||
"Count": len(Cfg.Storages),
|
||||
fmt.Println(i18n.TWithoutInit(cfg.Lang, i18nk.ConfigLoadedStorages, map[string]any{
|
||||
"Count": len(cfg.Storages),
|
||||
}))
|
||||
for _, storage := range Cfg.Storages {
|
||||
for _, storage := range cfg.Storages {
|
||||
fmt.Printf(" - %s (%s)\n", storage.GetName(), storage.GetType())
|
||||
}
|
||||
|
||||
if Cfg.Workers < 1 || Cfg.Retry < 1 {
|
||||
return errors.New(i18n.TWithoutInit(Cfg.Lang, i18nk.ConfigInvalidWorkersOrRetry, map[string]any{
|
||||
"Workers": Cfg.Workers,
|
||||
"Retry": Cfg.Retry,
|
||||
}))
|
||||
if cfg.Workers < 1 {
|
||||
cfg.Workers = 1
|
||||
}
|
||||
if cfg.Threads < 1 {
|
||||
cfg.Threads = 1
|
||||
}
|
||||
if cfg.Retry < 1 {
|
||||
cfg.Retry = 1
|
||||
}
|
||||
|
||||
for _, storage := range Cfg.Storages {
|
||||
for _, storage := range cfg.Storages {
|
||||
storages = append(storages, storage.GetName())
|
||||
}
|
||||
for _, user := range Cfg.Users {
|
||||
for _, user := range cfg.Users {
|
||||
userIDs = append(userIDs, user.ID)
|
||||
if user.Blacklist {
|
||||
userStorages[user.ID] = slice.Compact(slice.Difference(storages, user.Storages))
|
||||
@@ -143,20 +149,3 @@ func Init(ctx context.Context) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func Set(key string, value any) {
|
||||
viper.Set(key, value)
|
||||
}
|
||||
|
||||
func ReloadConfig() error {
|
||||
if err := viper.WriteConfig(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := viper.ReadInConfig(); err != nil {
|
||||
return err
|
||||
}
|
||||
if error := viper.Unmarshal(Cfg); error != nil {
|
||||
return error
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ type Exectable interface {
|
||||
|
||||
func worker(ctx context.Context, qe *queue.TaskQueue[Exectable], semaphore chan struct{}) {
|
||||
logger := log.FromContext(ctx)
|
||||
execHooks := config.Cfg.Hook.Exec
|
||||
execHooks := config.C().Hook.Exec
|
||||
for {
|
||||
semaphore <- struct{}{}
|
||||
qtask, err := qe.Get()
|
||||
@@ -58,11 +58,11 @@ func worker(ctx context.Context, qe *queue.TaskQueue[Exectable], semaphore chan
|
||||
|
||||
func Run(ctx context.Context) {
|
||||
log.FromContext(ctx).Info("Start processing tasks...")
|
||||
semaphore := make(chan struct{}, config.Cfg.Workers)
|
||||
semaphore := make(chan struct{}, config.C().Workers)
|
||||
if queueInstance == nil {
|
||||
queueInstance = queue.NewTaskQueue[Exectable]()
|
||||
}
|
||||
for range config.Cfg.Workers {
|
||||
for range config.C().Workers {
|
||||
go worker(ctx, queueInstance, semaphore)
|
||||
}
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ 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
|
||||
workers := config.C().Workers
|
||||
eg, gctx := errgroup.WithContext(ctx)
|
||||
eg.SetLimit(workers)
|
||||
for _, elem := range t.Elems {
|
||||
@@ -124,6 +124,6 @@ func (t *Task) processElement(ctx context.Context, elem TaskElement) error {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}, retry.Context(vctx), retry.RetryTimes(uint(config.Cfg.Retry)))
|
||||
}, retry.Context(vctx), retry.RetryTimes(uint(config.C().Retry)))
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -47,8 +47,8 @@ func NewTaskElement(
|
||||
) (*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 !config.C().Stream || ok {
|
||||
cachePath, err := filepath.Abs(filepath.Join(config.C().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)
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ func (t *Task) Execute(ctx context.Context) error {
|
||||
t.progress.OnStart(ctx, t)
|
||||
}
|
||||
eg, gctx := errgroup.WithContext(ctx)
|
||||
eg.SetLimit(config.Cfg.Workers)
|
||||
eg.SetLimit(config.C().Workers)
|
||||
for _, resource := range t.item.Resources {
|
||||
eg.Go(func() error {
|
||||
t.processingMu.RLock()
|
||||
@@ -96,7 +96,7 @@ func (t *Task) processResource(ctx context.Context, resource parser.Resource) er
|
||||
if t.stream {
|
||||
return t.Stor.Save(ctx, resp.Body, path.Join(t.StorPath, resource.Filename))
|
||||
}
|
||||
cacheFile, err := fsutil.CreateFile(filepath.Join(config.Cfg.Temp.BasePath,
|
||||
cacheFile, err := fsutil.CreateFile(filepath.Join(config.C().Temp.BasePath,
|
||||
fmt.Sprintf("resource_%s_%s", t.ID, resource.Filename)))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create cache file for resource %s: %w", resource.URL, err)
|
||||
@@ -131,7 +131,7 @@ func (t *Task) processResource(ctx context.Context, resource parser.Resource) er
|
||||
return fmt.Errorf("failed to seek cache file for resource %s: %w", resource.URL, err)
|
||||
}
|
||||
return t.Stor.Save(ctx, cacheFile, path.Join(t.StorPath, resource.Filename))
|
||||
}, retry.Context(ctx), retry.RetryTimes(uint(config.Cfg.Retry)))
|
||||
}, retry.Context(ctx), retry.RetryTimes(uint(config.C().Retry)))
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/krau/SaveAny-Bot/common/utils/netutil"
|
||||
"github.com/krau/SaveAny-Bot/config"
|
||||
"github.com/krau/SaveAny-Bot/pkg/enums/tasktype"
|
||||
"github.com/krau/SaveAny-Bot/pkg/parser"
|
||||
@@ -47,14 +48,9 @@ func NewTask(
|
||||
item *parser.Item,
|
||||
progressTracker ProgressTracker,
|
||||
) *Task {
|
||||
client := &http.Client{
|
||||
Transport: &http.Transport{
|
||||
// [TODO] configure it via config
|
||||
Proxy: http.ProxyFromEnvironment,
|
||||
},
|
||||
}
|
||||
client := netutil.DefaultParserHTTPClient()
|
||||
_, ok := stor.(storage.StorageCannotStream)
|
||||
stream := config.Cfg.Stream && !ok
|
||||
stream := config.C().Stream && !ok
|
||||
return &Task{
|
||||
ID: id,
|
||||
Ctx: ctx,
|
||||
|
||||
@@ -20,7 +20,7 @@ func (t *Task) Execute(ctx context.Context) error {
|
||||
logger.Infof("Starting Telegraph task %s", t.PhPath)
|
||||
t.progress.OnStart(ctx, t)
|
||||
eg, gctx := errgroup.WithContext(ctx)
|
||||
eg.SetLimit(config.Cfg.Workers)
|
||||
eg.SetLimit(config.C().Workers)
|
||||
for i, pic := range t.Pics {
|
||||
eg.Go(func() error {
|
||||
err := t.processPic(gctx, pic, i)
|
||||
@@ -46,7 +46,7 @@ func (t *Task) Execute(ctx context.Context) error {
|
||||
func (t *Task) processPic(ctx context.Context, picUrl string, index int) error {
|
||||
retryOpts := []retry.Option{
|
||||
retry.Context(ctx),
|
||||
retry.RetryTimes(uint(config.Cfg.Retry)),
|
||||
retry.RetryTimes(uint(config.C().Retry)),
|
||||
}
|
||||
var lastErr error
|
||||
err := retry.Retry(func() error {
|
||||
@@ -59,7 +59,7 @@ func (t *Task) processPic(ctx context.Context, picUrl string, index int) error {
|
||||
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,
|
||||
cacheFile, err := fsutil.CreateFile(filepath.Join(config.C().Temp.BasePath,
|
||||
fmt.Sprintf("tph_%s_%s", t.TaskID(), filename),
|
||||
))
|
||||
if err != nil {
|
||||
|
||||
@@ -57,7 +57,7 @@ func (t *Task) Execute(ctx context.Context) error {
|
||||
return fmt.Errorf("failed to get file stat: %w", err)
|
||||
}
|
||||
vctx := context.WithValue(ctx, ctxkey.ContentLength, fileStat.Size())
|
||||
for i := range config.Cfg.Retry + 1 {
|
||||
for i := range config.C().Retry + 1 {
|
||||
if err = vctx.Err(); err != nil {
|
||||
return fmt.Errorf("context canceled while saving file: %w", err)
|
||||
}
|
||||
@@ -68,7 +68,7 @@ func (t *Task) Execute(ctx context.Context) error {
|
||||
}
|
||||
defer file.Close()
|
||||
if err = t.Storage.Save(vctx, file, t.Path); err != nil {
|
||||
if i == config.Cfg.Retry {
|
||||
if i == config.C().Retry {
|
||||
return fmt.Errorf("failed to save file: %w", err)
|
||||
}
|
||||
logger.Errorf("Failed to save file: %s, retrying...", err)
|
||||
|
||||
@@ -35,8 +35,8 @@ func NewTGFileTask(
|
||||
progress ProgressTracker,
|
||||
) (*Task, 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 !config.C().Stream || ok {
|
||||
cachePath, err := filepath.Abs(filepath.Join(config.C().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)
|
||||
}
|
||||
|
||||
@@ -19,11 +19,11 @@ var db *gorm.DB
|
||||
|
||||
func Init(ctx context.Context) {
|
||||
logger := log.FromContext(ctx)
|
||||
if err := os.MkdirAll(filepath.Dir(config.Cfg.DB.Path), 0755); err != nil {
|
||||
if err := os.MkdirAll(filepath.Dir(config.C().DB.Path), 0755); err != nil {
|
||||
logger.Fatal("Failed to create data directory: ", err)
|
||||
}
|
||||
var err error
|
||||
db, err = gorm.Open(gormlite.Open(config.Cfg.DB.Path), &gorm.Config{
|
||||
db, err = gorm.Open(gormlite.Open(config.C().DB.Path), &gorm.Config{
|
||||
Logger: glogger.New(logger, glogger.Config{
|
||||
Colorful: true,
|
||||
SlowThreshold: time.Second * 5,
|
||||
@@ -60,7 +60,7 @@ func syncUsers(ctx context.Context) error {
|
||||
}
|
||||
|
||||
cfgUserMap := make(map[int64]struct{})
|
||||
for _, u := range config.Cfg.Users {
|
||||
for _, u := range config.C().Users {
|
||||
cfgUserMap[u.ID] = struct{}{}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,13 +6,15 @@ import (
|
||||
|
||||
type User struct {
|
||||
gorm.Model
|
||||
ChatID int64 `gorm:"uniqueIndex;not null"`
|
||||
Silent bool
|
||||
DefaultStorage string
|
||||
Dirs []Dir
|
||||
ApplyRule bool
|
||||
Rules []Rule
|
||||
WatchChats []WatchChat
|
||||
ChatID int64 `gorm:"uniqueIndex;not null"`
|
||||
Silent bool
|
||||
DefaultStorage string
|
||||
Dirs []Dir
|
||||
ApplyRule bool
|
||||
Rules []Rule
|
||||
WatchChats []WatchChat
|
||||
FilenameStrategy string
|
||||
FilenameTemplate string
|
||||
}
|
||||
|
||||
type WatchChat struct {
|
||||
|
||||
@@ -33,7 +33,7 @@ After=systemd-user-sessions.service
|
||||
Type=simple
|
||||
WorkingDirectory=/yourpath/
|
||||
ExecStart=/yourpath/saveany-bot
|
||||
Restart=on-failure
|
||||
Restart=always
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
||||
@@ -29,6 +29,6 @@ weight: 20
|
||||
1. 在 `parsers` 目录下新建一个包, 编写解析器实现
|
||||
2. 在 `parsers/parser.go` 的 `init` 中注册解析器
|
||||
|
||||
如果使用 JavaScript 编写, 请参考 `plugins/example_parser.js` 的实现, 并在该文件夹下新建一个 js 文件, 实现你的解析逻辑.
|
||||
如果使用 JavaScript 编写, 请参考 `plugins/example_parser_basic.js` 的实现, 并在该文件夹下新建一个 js 文件, 实现你的解析逻辑.
|
||||
|
||||
需要注意, `plugins` 目录下解析器默认不会被编译到二进制文件中, 用户需要手动下载它们并放到本地指定目录下以启用它们.
|
||||
@@ -61,7 +61,9 @@ Stream 模式对于磁盘空间有限的部署环境十分有用, 但也有一
|
||||
{{< hint warning >}}
|
||||
启用 userbot 集成后, bot 可以下载私密频道和群组的文件, 但具有无法避免的账号被封禁的风险.
|
||||
<br />
|
||||
开启 userbot 集成后第一次启动 bot 时需要通过终端交互输入手机号, 2FA 和验证码, 如果你使用 docker 部署, 请进入容器内执行相关操作.
|
||||
开启 userbot 集成后第一次启动 bot 时需要通过终端交互输入手机号, 2FA 和验证码.
|
||||
<br />
|
||||
如果你使用 docker 部署, 请使用 -it 参数为容器提供交互式环境, 然后执行登录操作.
|
||||
{{< /hint >}}
|
||||
|
||||
```toml
|
||||
|
||||
@@ -4,7 +4,7 @@ title: "安装与更新"
|
||||
|
||||
# 安装与更新
|
||||
|
||||
## 从预编译文件部署
|
||||
## 从预编译文件部署(推荐)
|
||||
|
||||
在 [Release](https://github.com/krau/SaveAny-Bot/releases) 页面下载对应平台的二进制文件.
|
||||
|
||||
@@ -33,7 +33,7 @@ After=systemd-user-sessions.service
|
||||
Type=simple
|
||||
WorkingDirectory=/yourpath/
|
||||
ExecStart=/yourpath/saveany-bot
|
||||
Restart=on-failure
|
||||
Restart=always
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -131,13 +131,13 @@ docker run -d --name saveany-bot \
|
||||
|
||||
## 更新
|
||||
|
||||
使用 `upgrade` 或 `up` 升级到最新版
|
||||
向 Bot 发送 `/update` 指令检查更新并升级, 或者使用 CLI 命令更新:
|
||||
|
||||
```bash
|
||||
./saveany-bot upgrade
|
||||
./saveany-bot up
|
||||
```
|
||||
|
||||
如果是 Docker 部署, 使用以下命令更新:
|
||||
如果是 Docker 部署, 还可以使用以下命令更新:
|
||||
|
||||
```bash
|
||||
docker pull ghcr.io/krau/saveany-bot:latest
|
||||
|
||||
@@ -120,4 +120,5 @@ IS-ALBUM true MyWebdav NEW-FOR-ALBUM
|
||||
|
||||
只需向 Bot 发送符合解析器要求的链接即可使用, 当前内置的解析器:
|
||||
|
||||
- Twitter
|
||||
- Twitter
|
||||
- Kemono
|
||||
5
go.mod
5
go.mod
@@ -10,6 +10,7 @@ require (
|
||||
github.com/charmbracelet/log v0.4.2
|
||||
github.com/fatih/color v1.18.0
|
||||
github.com/gabriel-vasile/mimetype v1.4.9
|
||||
github.com/goccy/go-yaml v1.18.0
|
||||
github.com/gotd/contrib v0.21.0
|
||||
github.com/gotd/td v0.129.0
|
||||
github.com/minio/minio-go/v7 v7.0.95
|
||||
@@ -83,7 +84,7 @@ require (
|
||||
github.com/tcnksm/go-gitconfig v0.1.2 // indirect
|
||||
github.com/tetratelabs/wazero v1.9.0 // indirect
|
||||
github.com/tinylib/msgp v1.3.0 // indirect
|
||||
github.com/ulikunitz/xz v0.5.12 // indirect
|
||||
github.com/ulikunitz/xz v0.5.14 // indirect
|
||||
go.opentelemetry.io/otel v1.37.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.37.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.37.0 // indirect
|
||||
@@ -114,7 +115,7 @@ require (
|
||||
github.com/ncruces/go-sqlite3 v0.27.1
|
||||
github.com/ncruces/go-sqlite3/gormlite v0.24.0
|
||||
github.com/nicksnyder/go-i18n/v2 v2.6.0
|
||||
github.com/pelletier/go-toml/v2 v2.2.4
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/sagikazarmark/locafero v0.10.0 // indirect
|
||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
|
||||
github.com/spf13/afero v1.14.0 // indirect
|
||||
|
||||
6
go.sum
6
go.sum
@@ -109,6 +109,8 @@ github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9L
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
|
||||
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
@@ -245,8 +247,8 @@ github.com/tetratelabs/wazero v1.9.0/go.mod h1:TSbcXCfFP0L2FGkRPxHphadXPjo1T6W+C
|
||||
github.com/tinylib/msgp v1.3.0 h1:ULuf7GPooDaIlbyvgAxBV/FI7ynli6LZ1/nVUNu+0ww=
|
||||
github.com/tinylib/msgp v1.3.0/go.mod h1:ykjzy2wzgrlvpDCRc4LA8UXy6D8bzMSuAF3WD57Gok0=
|
||||
github.com/ulikunitz/xz v0.5.9/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
|
||||
github.com/ulikunitz/xz v0.5.12 h1:37Nm15o69RwBkXM0J6A5OlE67RZTfzUxTj8fB3dfcsc=
|
||||
github.com/ulikunitz/xz v0.5.12/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
|
||||
github.com/ulikunitz/xz v0.5.14 h1:uv/0Bq533iFdnMHZdRBTOlaNMdb1+ZxXIlHDZHIHcvg=
|
||||
github.com/ulikunitz/xz v0.5.14/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
|
||||
|
||||
2
main.go
2
main.go
@@ -8,6 +8,8 @@ import (
|
||||
"github.com/krau/SaveAny-Bot/cmd"
|
||||
)
|
||||
|
||||
//go:generate go run cmd/geni18n/main.go -dir ./common/i18n/locale -out common/i18n/i18nk/keys.go -pkg i18nk
|
||||
|
||||
func main() {
|
||||
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
|
||||
defer cancel()
|
||||
|
||||
@@ -7,24 +7,11 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/blang/semver"
|
||||
"github.com/charmbracelet/log"
|
||||
"github.com/dop251/goja"
|
||||
"github.com/krau/SaveAny-Bot/pkg/parser"
|
||||
)
|
||||
|
||||
var (
|
||||
LatestParserVersion = semver.MustParse("1.0.0")
|
||||
MinimumParserVersion = semver.MustParse("1.0.0")
|
||||
)
|
||||
|
||||
type PluginMeta struct {
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"` // [TODO] 分版本解析, 但是我们现在只有 v1 所以先不写
|
||||
Description string `json:"description"`
|
||||
Author string `json:"author"`
|
||||
}
|
||||
|
||||
type jsParser struct {
|
||||
meta PluginMeta
|
||||
vm *goja.Runtime
|
||||
@@ -32,7 +19,7 @@ type jsParser struct {
|
||||
}
|
||||
|
||||
type jsParserReq struct {
|
||||
method string
|
||||
method ParserMethod
|
||||
url string
|
||||
respCh chan jsParserResp
|
||||
}
|
||||
@@ -45,16 +32,20 @@ type jsParserResp struct {
|
||||
|
||||
func (p *jsParser) CanHandle(url string) bool {
|
||||
respCh := make(chan jsParserResp, 1)
|
||||
p.reqCh <- jsParserReq{method: "canHandle", url: url, respCh: respCh}
|
||||
p.reqCh <- jsParserReq{method: ParserMethodCanHandle, url: url, respCh: respCh}
|
||||
resp := <-respCh
|
||||
return resp.ok && resp.err == nil
|
||||
}
|
||||
|
||||
func (p *jsParser) Parse(url string) (*parser.Item, error) {
|
||||
func (p *jsParser) Parse(ctx context.Context, url string) (*parser.Item, error) {
|
||||
respCh := make(chan jsParserResp, 1)
|
||||
p.reqCh <- jsParserReq{method: "parse", url: url, respCh: respCh}
|
||||
resp := <-respCh
|
||||
return resp.item, resp.err
|
||||
p.reqCh <- jsParserReq{method: ParserMethodParse, url: url, respCh: respCh}
|
||||
select {
|
||||
case resp := <-respCh:
|
||||
return resp.item, resp.err
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
func newJSParser(vm *goja.Runtime, canHandleFunc, parseFunc goja.Value, metadata PluginMeta) *jsParser {
|
||||
@@ -67,7 +58,7 @@ func newJSParser(vm *goja.Runtime, canHandleFunc, parseFunc goja.Value, metadata
|
||||
go func() {
|
||||
for req := range p.reqCh {
|
||||
switch req.method {
|
||||
case "canHandle":
|
||||
case ParserMethodCanHandle:
|
||||
fn, _ := goja.AssertFunction(canHandleFunc)
|
||||
res, err := fn(goja.Undefined(), p.vm.ToValue(req.url))
|
||||
if err != nil {
|
||||
@@ -75,7 +66,7 @@ func newJSParser(vm *goja.Runtime, canHandleFunc, parseFunc goja.Value, metadata
|
||||
continue
|
||||
}
|
||||
req.respCh <- jsParserResp{ok: res.ToBoolean()}
|
||||
case "parse":
|
||||
case ParserMethodParse:
|
||||
fn, _ := goja.AssertFunction(parseFunc)
|
||||
result, err := fn(goja.Undefined(), p.vm.ToValue(req.url))
|
||||
if err != nil {
|
||||
@@ -107,50 +98,6 @@ func newJSParser(vm *goja.Runtime, canHandleFunc, parseFunc goja.Value, metadata
|
||||
return p
|
||||
}
|
||||
|
||||
func registerParser(vm *goja.Runtime) func(call goja.FunctionCall) goja.Value {
|
||||
return func(call goja.FunctionCall) goja.Value {
|
||||
jsObj := call.Argument(0)
|
||||
if jsObj == nil || goja.IsUndefined(jsObj) || goja.IsNull(jsObj) {
|
||||
panic("registerParser expects an object { canHandle, parse }")
|
||||
}
|
||||
|
||||
obj := jsObj.ToObject(vm)
|
||||
if obj == nil {
|
||||
panic("registerParser: cannot convert argument to object")
|
||||
}
|
||||
metaValue := obj.Get("metadata")
|
||||
if metaValue == nil || goja.IsUndefined(metaValue) {
|
||||
panic("parser must provide metadata")
|
||||
}
|
||||
var metadata PluginMeta
|
||||
if exported := metaValue.Export(); exported != nil {
|
||||
data, err := json.Marshal(exported)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("failed to marshal metadata to JSON: %v", err))
|
||||
}
|
||||
if err := json.Unmarshal(data, &metadata); err != nil {
|
||||
panic(fmt.Sprintf("failed to unmarshal JSON to PluginMeta: %v", err))
|
||||
}
|
||||
} else {
|
||||
panic("metadata cannot be null or undefined")
|
||||
}
|
||||
|
||||
pluginV := semver.MustParse(metadata.Version)
|
||||
if pluginV.LT(MinimumParserVersion) || pluginV.GT(LatestParserVersion) {
|
||||
panic(fmt.Sprintf("parser version %s is not supported, must be between %s and %s", metadata.Version, MinimumParserVersion, LatestParserVersion))
|
||||
}
|
||||
|
||||
handleFn := obj.Get("canHandle")
|
||||
parseFn := obj.Get("parse")
|
||||
if parseFn == nil || goja.IsUndefined(parseFn) {
|
||||
panic("parser must provide a parse function")
|
||||
}
|
||||
|
||||
parsers = append(parsers, newJSParser(vm, handleFn, parseFn, metadata))
|
||||
return goja.Undefined()
|
||||
}
|
||||
}
|
||||
|
||||
func LoadPlugins(ctx context.Context, dir string) error {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
@@ -168,14 +115,13 @@ func LoadPlugins(ctx context.Context, dir string) error {
|
||||
}
|
||||
|
||||
vm := goja.New()
|
||||
vm.Set("registerParser", jsRegisterParser(vm))
|
||||
// Inject some utils to vm
|
||||
logger := log.FromContext(ctx).WithPrefix(fmt.Sprintf("[plugin|parser]/%s", e.Name()))
|
||||
vm.Set("registerParser", registerParser(vm))
|
||||
vm.Set("console", map[string]any{
|
||||
"log": func(args ...any) {
|
||||
logger.Info(fmt.Sprint(args...))
|
||||
},
|
||||
})
|
||||
|
||||
vm.Set("console", jsConsole(logger))
|
||||
// http fetch funcs
|
||||
vm.Set("ghttp", jsGhttp(vm))
|
||||
|
||||
if _, err := vm.RunString(string(code)); err != nil {
|
||||
return fmt.Errorf("error loading plugin %s: %w", e.Name(), err)
|
||||
}
|
||||
|
||||
151
parsers/js_api.go
Normal file
151
parsers/js_api.go
Normal file
@@ -0,0 +1,151 @@
|
||||
package parsers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/blang/semver"
|
||||
"github.com/charmbracelet/log"
|
||||
"github.com/dop251/goja"
|
||||
"github.com/krau/SaveAny-Bot/common/utils/netutil"
|
||||
)
|
||||
|
||||
func jsRegisterParser(vm *goja.Runtime) func(call goja.FunctionCall) goja.Value {
|
||||
return func(call goja.FunctionCall) goja.Value {
|
||||
jsObj := call.Argument(0)
|
||||
if jsObj == nil || goja.IsUndefined(jsObj) || goja.IsNull(jsObj) {
|
||||
panic("registerParser expects an object { canHandle, parse }")
|
||||
}
|
||||
|
||||
obj := jsObj.ToObject(vm)
|
||||
if obj == nil {
|
||||
panic("registerParser: cannot convert argument to object")
|
||||
}
|
||||
metaValue := obj.Get("metadata")
|
||||
if metaValue == nil || goja.IsUndefined(metaValue) {
|
||||
panic("parser must provide metadata")
|
||||
}
|
||||
var metadata PluginMeta
|
||||
if exported := metaValue.Export(); exported != nil {
|
||||
data, err := json.Marshal(exported)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("failed to marshal metadata to JSON: %v", err))
|
||||
}
|
||||
if err := json.Unmarshal(data, &metadata); err != nil {
|
||||
panic(fmt.Sprintf("failed to unmarshal JSON to PluginMeta: %v", err))
|
||||
}
|
||||
} else {
|
||||
panic("metadata cannot be null or undefined")
|
||||
}
|
||||
|
||||
pluginV := semver.MustParse(metadata.Version)
|
||||
if pluginV.LT(MinimumParserVersion) || pluginV.GT(LatestParserVersion) {
|
||||
panic(fmt.Sprintf("parser version %s is not supported, must be between %s and %s", metadata.Version, MinimumParserVersion, LatestParserVersion))
|
||||
}
|
||||
|
||||
handleFn := obj.Get("canHandle")
|
||||
parseFn := obj.Get("parse")
|
||||
if parseFn == nil || goja.IsUndefined(parseFn) {
|
||||
panic("parser must provide a parse function")
|
||||
}
|
||||
|
||||
parsers = append(parsers, newJSParser(vm, handleFn, parseFn, metadata))
|
||||
return goja.Undefined()
|
||||
}
|
||||
}
|
||||
|
||||
var jsConsole = func(logger *log.Logger) map[string]any {
|
||||
return map[string]any{
|
||||
"log": func(args ...any) {
|
||||
if len(args) == 0 {
|
||||
return
|
||||
}
|
||||
if len(args) > 1 {
|
||||
logger.Info(args[0], args[1:]...)
|
||||
} else {
|
||||
logger.Info(args[0])
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
var jsGhttp = func(vm *goja.Runtime) *goja.Object {
|
||||
ghttp := vm.NewObject()
|
||||
client := netutil.DefaultParserHTTPClient()
|
||||
ghttp.Set("get", func(call goja.FunctionCall) goja.Value {
|
||||
url := call.Argument(0).String()
|
||||
resp, err := client.Get(url)
|
||||
if err != nil {
|
||||
return vm.ToValue(map[string]any{
|
||||
"error": fmt.Sprintf("failed to fetch %s: %v", url, err),
|
||||
})
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return vm.ToValue(map[string]any{
|
||||
"error": fmt.Sprintf("failed to fetch %s: %s", url, resp.Status),
|
||||
"status": resp.StatusCode,
|
||||
})
|
||||
}
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return vm.ToValue(map[string]any{
|
||||
"error": fmt.Errorf("failed to read response body: %w", err).Error(),
|
||||
})
|
||||
}
|
||||
return vm.ToValue(string(body))
|
||||
})
|
||||
ghttp.Set("getJSON", func(call goja.FunctionCall) goja.Value {
|
||||
url := call.Argument(0).String()
|
||||
|
||||
resp, err := client.Get(url)
|
||||
if err != nil {
|
||||
return vm.ToValue(map[string]any{
|
||||
"error": fmt.Sprintf("failed to fetch %s: %v", url, err),
|
||||
})
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return vm.ToValue(map[string]any{
|
||||
"error": fmt.Sprintf("failed to fetch %s: %s", url, resp.Status),
|
||||
"status": resp.StatusCode,
|
||||
})
|
||||
}
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return vm.ToValue(map[string]any{
|
||||
"error": fmt.Errorf("failed to read response body: %w", err).Error(),
|
||||
})
|
||||
}
|
||||
var jsonData map[string]any
|
||||
if err := json.Unmarshal(body, &jsonData); err != nil {
|
||||
return vm.ToValue(map[string]any{
|
||||
"error": fmt.Errorf("failed to unmarshal JSON: %w", err).Error(),
|
||||
})
|
||||
}
|
||||
return vm.ToValue(map[string]any{
|
||||
"data": jsonData,
|
||||
})
|
||||
})
|
||||
ghttp.Set("head", func(call goja.FunctionCall) goja.Value {
|
||||
url := call.Argument(0).String()
|
||||
resp, err := client.Head(url)
|
||||
if err != nil {
|
||||
return vm.ToValue(map[string]any{
|
||||
"error": fmt.Sprintf("failed to fetch %s: %v", url, err),
|
||||
})
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
headers := make(map[string]string)
|
||||
for k, v := range resp.Header {
|
||||
headers[k] = v[0]
|
||||
}
|
||||
return vm.ToValue(map[string]any{
|
||||
"status": resp.StatusCode,
|
||||
"headers": headers,
|
||||
})
|
||||
})
|
||||
return ghttp
|
||||
}
|
||||
36
parsers/kemono/download.go
Normal file
36
parsers/kemono/download.go
Normal file
@@ -0,0 +1,36 @@
|
||||
package kemono
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type DownloadInfo struct {
|
||||
ServiceName string
|
||||
UserID string
|
||||
PostID string
|
||||
}
|
||||
|
||||
func extractDownloadInfoFromURL(u string) *DownloadInfo {
|
||||
if !strings.HasPrefix(u, "http://") && !strings.HasPrefix(u, "https://") {
|
||||
u = "https://" + u
|
||||
}
|
||||
url, err := url.Parse(u)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
parts := strings.Split(strings.Trim(url.Path, "/"), "/")
|
||||
if len(parts) == 3 {
|
||||
return &DownloadInfo{
|
||||
ServiceName: parts[0],
|
||||
UserID: parts[2],
|
||||
}
|
||||
} else if len(parts) == 5 && parts[3] == "post" {
|
||||
return &DownloadInfo{
|
||||
ServiceName: parts[0],
|
||||
UserID: parts[2],
|
||||
PostID: parts[4],
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
173
parsers/kemono/kemono.go
Normal file
173
parsers/kemono/kemono.go
Normal file
@@ -0,0 +1,173 @@
|
||||
package kemono
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"github.com/duke-git/lancet/v2/strutil"
|
||||
"github.com/krau/SaveAny-Bot/common/utils/netutil"
|
||||
"github.com/krau/SaveAny-Bot/pkg/parser"
|
||||
)
|
||||
|
||||
type KemonoParser struct{}
|
||||
|
||||
var (
|
||||
kemonoDomains = []string{
|
||||
"kemono.su",
|
||||
"kemono.cr",
|
||||
}
|
||||
ErrFailedToExtractInfo = errors.New("failed to extract download info from URL")
|
||||
)
|
||||
|
||||
const (
|
||||
kemonoApiBase = "https://kemono.cr/api/v1"
|
||||
)
|
||||
|
||||
func (k *KemonoParser) CanHandle(text string) bool {
|
||||
text = strings.TrimPrefix(text, "https://")
|
||||
text = strings.TrimPrefix(text, "http://")
|
||||
|
||||
var matchesDomain bool
|
||||
for _, domain := range kemonoDomains {
|
||||
if strings.Contains(text, domain) {
|
||||
matchesDomain = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !matchesDomain {
|
||||
return false
|
||||
}
|
||||
|
||||
var path string
|
||||
for _, domain := range kemonoDomains {
|
||||
if idx := strings.Index(text, domain); idx != -1 {
|
||||
remaining := text[idx+len(domain):]
|
||||
if len(remaining) > 0 && remaining[0] == '/' {
|
||||
path = remaining[1:]
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if path == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
parts := strings.Split(path, "/")
|
||||
// servicename/user/id (user profile page)
|
||||
// servicename/user/id/post/id (post page)
|
||||
return len(parts) == 3 || (len(parts) == 5 && parts[3] == "post")
|
||||
}
|
||||
|
||||
func (k *KemonoParser) Parse(ctx context.Context, u string) (*parser.Item, error) {
|
||||
info := extractDownloadInfoFromURL(u)
|
||||
if info == nil {
|
||||
return nil, ErrFailedToExtractInfo
|
||||
}
|
||||
if info.PostID != "" {
|
||||
return k.parseOne(ctx, info)
|
||||
}
|
||||
return k.parseUserPage(ctx, info)
|
||||
}
|
||||
|
||||
func (k *KemonoParser) parseOne(ctx context.Context, info *DownloadInfo) (*parser.Item, error) {
|
||||
client := netutil.DefaultParserHTTPClient()
|
||||
endpoint := fmt.Sprintf("%s/%s/user/%s/post/%s", kemonoApiBase, info.ServiceName, info.UserID, info.PostID)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request to Kemono API: %w", err)
|
||||
}
|
||||
req.Header.Set("Accept", "text/css")
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch Kemono API: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("failed to fetch Kemono API, status code: %d", resp.StatusCode)
|
||||
}
|
||||
var postInfo PostInfo
|
||||
if err := json.NewDecoder(resp.Body).Decode(&postInfo); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode Kemono API response: %w", err)
|
||||
}
|
||||
item := &parser.Item{
|
||||
Site: "kemono",
|
||||
Title: postInfo.Post.Title,
|
||||
URL: fmt.Sprintf("https://kemono.cr/%s/user/%s/post/%s", info.ServiceName, info.UserID, info.PostID),
|
||||
Author: postInfo.Post.User, // [TODO] request user profile
|
||||
Description: postInfo.Post.Content,
|
||||
Tags: func() []string {
|
||||
if postInfo.Post.Tags != nil {
|
||||
return *postInfo.Post.Tags
|
||||
}
|
||||
return nil
|
||||
}(),
|
||||
}
|
||||
resources := make([]parser.Resource, 0)
|
||||
for _, attachment := range postInfo.Attachments {
|
||||
if attachment.Server == nil || attachment.Path == nil || attachment.Name == nil {
|
||||
continue
|
||||
}
|
||||
var size int64
|
||||
fileUrl := fmt.Sprintf("%s/data%s", *attachment.Server, *attachment.Path)
|
||||
headReq, err := http.NewRequestWithContext(ctx, http.MethodHead, fileUrl, nil)
|
||||
if err == nil {
|
||||
resp, err := client.Do(headReq)
|
||||
if err == nil {
|
||||
size = resp.ContentLength
|
||||
resp.Body.Close()
|
||||
}
|
||||
}
|
||||
resources = append(resources, parser.Resource{
|
||||
URL: fmt.Sprintf("%s/data%s", *attachment.Server, *attachment.Path),
|
||||
Filename: *attachment.Name,
|
||||
Size: size,
|
||||
})
|
||||
}
|
||||
picCdnMap := make(map[string]string)
|
||||
for _, preview := range postInfo.Previews {
|
||||
if preview.Type == nil || *preview.Type != "thumbnail" {
|
||||
continue
|
||||
}
|
||||
picCdnMap[*preview.Path] = *preview.Server
|
||||
}
|
||||
for _, attachment := range postInfo.Post.Attachments {
|
||||
if !isImageExt(*attachment.Path) {
|
||||
continue
|
||||
}
|
||||
picUrl, err := url.JoinPath(picCdnMap[*attachment.Path], "data", *attachment.Path)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
var size int64
|
||||
headReq, err := http.NewRequestWithContext(ctx, http.MethodHead, picUrl, nil)
|
||||
if err == nil {
|
||||
resp, err := client.Do(headReq)
|
||||
if err == nil {
|
||||
size = resp.ContentLength
|
||||
resp.Body.Close()
|
||||
}
|
||||
}
|
||||
resources = append(resources, parser.Resource{
|
||||
URL: picUrl,
|
||||
Filename: *attachment.Name,
|
||||
Size: size,
|
||||
})
|
||||
}
|
||||
item.Resources = resources
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func (k *KemonoParser) parseUserPage(_ context.Context, _ *DownloadInfo) (*parser.Item, error) {
|
||||
return nil, errors.New("kemono user page not implemented")
|
||||
}
|
||||
|
||||
func isImageExt(attachmentPath string) bool {
|
||||
return strutil.HasSuffixAny(path.Ext(strings.Split(attachmentPath, "?")[0]), []string{".jpg", ".jpeg", ".png", ".webp"})
|
||||
}
|
||||
62
parsers/kemono/post_info.go
Normal file
62
parsers/kemono/post_info.go
Normal file
@@ -0,0 +1,62 @@
|
||||
// https://github.com/kemono-rs/kemono
|
||||
|
||||
package kemono
|
||||
|
||||
type PostInfo struct {
|
||||
Post Post `json:"post"`
|
||||
Attachments []AttachmentLike `json:"attachments"`
|
||||
Previews []AttachmentLike `json:"previews"`
|
||||
}
|
||||
|
||||
type AttachmentLike struct {
|
||||
Type *string `json:"type,omitempty"`
|
||||
Server *string `json:"server,omitempty"`
|
||||
Name *string `json:"name,omitempty"`
|
||||
Path *string `json:"path,omitempty"`
|
||||
}
|
||||
|
||||
type Post struct {
|
||||
ID string `json:"id"`
|
||||
User string `json:"user"`
|
||||
Service string `json:"service"`
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"`
|
||||
Embed Embed `json:"embed"`
|
||||
SharedFile bool `json:"shared_file"`
|
||||
Added *string `json:"added,omitempty"`
|
||||
Published string `json:"published"`
|
||||
Edited *string `json:"edited,omitempty"`
|
||||
File File `json:"file"`
|
||||
Attachments []AttachmentLike `json:"attachments"`
|
||||
Poll *Poll `json:"poll,omitempty"`
|
||||
Captions *string `json:"captions,omitempty"`
|
||||
Tags *[]string `json:"tags,omitempty"`
|
||||
Next *string `json:"next,omitempty"`
|
||||
Prev *string `json:"prev,omitempty"`
|
||||
}
|
||||
|
||||
type File struct {
|
||||
Name *string `json:"name,omitempty"`
|
||||
Path *string `json:"path,omitempty"`
|
||||
}
|
||||
|
||||
type Embed struct {
|
||||
URL *string `json:"url,omitempty"`
|
||||
Subject *string `json:"subject,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
}
|
||||
|
||||
type Poll struct {
|
||||
Title string `json:"title"`
|
||||
Choices []Choice `json:"choices"`
|
||||
ClosesAt *string `json:"closes_at,omitempty"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
AllowsMultiple bool `json:"allows_multiple"`
|
||||
TotalVotes int64 `json:"total_votes"`
|
||||
}
|
||||
|
||||
type Choice struct {
|
||||
Text string `json:"text"`
|
||||
Votes int64 `json:"votes"`
|
||||
}
|
||||
16
parsers/kemono/post_legacy.go
Normal file
16
parsers/kemono/post_legacy.go
Normal file
@@ -0,0 +1,16 @@
|
||||
package kemono
|
||||
|
||||
type PostLegacy struct {
|
||||
Props Props `json:"props"`
|
||||
Results []Result `json:"results"`
|
||||
}
|
||||
|
||||
type Props struct {
|
||||
Count uint `json:"count"`
|
||||
Limit uint `json:"limit"`
|
||||
}
|
||||
|
||||
type Result struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
}
|
||||
8
parsers/kemono/user_profile.go
Normal file
8
parsers/kemono/user_profile.go
Normal file
@@ -0,0 +1,8 @@
|
||||
package kemono
|
||||
|
||||
type UserProfile struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Service string `json:"service"`
|
||||
PublicID *string `json:"public_id,omitempty"`
|
||||
}
|
||||
@@ -5,21 +5,31 @@ import (
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"github.com/krau/SaveAny-Bot/config"
|
||||
"github.com/krau/SaveAny-Bot/parsers/kemono"
|
||||
"github.com/krau/SaveAny-Bot/parsers/twitter"
|
||||
"github.com/krau/SaveAny-Bot/pkg/parser"
|
||||
)
|
||||
|
||||
var (
|
||||
parsers []parser.Parser
|
||||
parsersMu sync.Mutex
|
||||
parsers []parser.Parser
|
||||
parsersMu sync.Mutex
|
||||
doConfig sync.Once
|
||||
configParsers = func() {
|
||||
if len(parsers) == 0 {
|
||||
return
|
||||
}
|
||||
for _, pser := range parsers {
|
||||
if configurable, ok := pser.(parser.ConfigurableParser); ok {
|
||||
cfg := config.C().GetParserConfigByName(configurable.Name())
|
||||
if err := configurable.Configure(cfg); err != nil {
|
||||
fmt.Printf("Error configuring parser %s: %v\n", configurable.Name(), err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
func GetParsers() []parser.Parser {
|
||||
parsersMu.Lock()
|
||||
defer parsersMu.Unlock()
|
||||
return parsers
|
||||
}
|
||||
|
||||
func AddParser(p ...parser.Parser) {
|
||||
parsersMu.Lock()
|
||||
defer parsersMu.Unlock()
|
||||
@@ -27,7 +37,7 @@ func AddParser(p ...parser.Parser) {
|
||||
}
|
||||
|
||||
func init() {
|
||||
AddParser(new(twitter.TwitterParser))
|
||||
AddParser(new(twitter.TwitterParser), new(kemono.KemonoParser))
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -35,6 +45,7 @@ var (
|
||||
)
|
||||
|
||||
func ParseWithContext(ctx context.Context, url string) (*parser.Item, error) {
|
||||
doConfig.Do(configParsers)
|
||||
ch := make(chan *parser.Item, 1)
|
||||
errCh := make(chan error, 1)
|
||||
|
||||
@@ -43,7 +54,7 @@ func ParseWithContext(ctx context.Context, url string) (*parser.Item, error) {
|
||||
if !pser.CanHandle(url) {
|
||||
continue
|
||||
}
|
||||
item, err := pser.Parse(url)
|
||||
item, err := pser.Parse(ctx, url)
|
||||
if err != nil {
|
||||
errCh <- err
|
||||
return
|
||||
@@ -63,3 +74,13 @@ func ParseWithContext(ctx context.Context, url string) (*parser.Item, error) {
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
func CanHandle(url string) (bool, parser.Parser) {
|
||||
doConfig.Do(configParsers)
|
||||
for _, pser := range parsers {
|
||||
if pser.CanHandle(url) {
|
||||
return true, pser
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
23
parsers/plugin.go
Normal file
23
parsers/plugin.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package parsers
|
||||
|
||||
import "github.com/blang/semver"
|
||||
|
||||
var (
|
||||
LatestParserVersion = semver.MustParse("1.0.0")
|
||||
MinimumParserVersion = semver.MustParse("1.0.0")
|
||||
)
|
||||
|
||||
type PluginMeta struct {
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"` // [TODO] 分版本解析, 但是我们现在只有 v1 所以先不写
|
||||
Description string `json:"description"`
|
||||
Author string `json:"author"`
|
||||
}
|
||||
|
||||
type ParserMethod uint
|
||||
|
||||
const (
|
||||
_ ParserMethod = iota
|
||||
ParserMethodCanHandle
|
||||
ParserMethodParse
|
||||
)
|
||||
@@ -1,6 +1,7 @@
|
||||
package twitter
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -9,18 +10,20 @@ import (
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/krau/SaveAny-Bot/common/utils/netutil"
|
||||
"github.com/krau/SaveAny-Bot/pkg/parser"
|
||||
)
|
||||
|
||||
type TwitterParser struct {
|
||||
client http.Client
|
||||
client http.Client
|
||||
apiDomain string
|
||||
}
|
||||
|
||||
const (
|
||||
FxTwitterApi = "api.fxtwitter.com"
|
||||
fxTwitterApi = "api.fxtwitter.com"
|
||||
)
|
||||
|
||||
var _ parser.Parser = (*TwitterParser)(nil)
|
||||
var _ parser.ConfigurableParser = (*TwitterParser)(nil)
|
||||
|
||||
var (
|
||||
twitterSourceURLRegexp *regexp.Regexp = regexp.MustCompile(`(?:twitter|x)\.com/([^/]+)/status/(\d+)`)
|
||||
@@ -34,13 +37,17 @@ func getTweetID(sourceURL string) string {
|
||||
return matches[2]
|
||||
}
|
||||
|
||||
func (p *TwitterParser) Parse(u string) (*parser.Item, error) {
|
||||
func (p *TwitterParser) Parse(ctx context.Context, u string) (*parser.Item, error) {
|
||||
id := getTweetID(u)
|
||||
if id == "" {
|
||||
return nil, errors.New("invalid Twitter URL")
|
||||
}
|
||||
apiUrl := fmt.Sprintf("https://%s/_/status/%s", FxTwitterApi, id)
|
||||
resp, err := p.client.Get(apiUrl)
|
||||
apiUrl := fmt.Sprintf("https://%s/_/status/%s", p.apiDomain, id)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiUrl, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request to Twitter API: %w", err)
|
||||
}
|
||||
resp, err := p.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch Twitter API: %w", err)
|
||||
}
|
||||
@@ -61,7 +68,7 @@ func (p *TwitterParser) Parse(u string) (*parser.Item, error) {
|
||||
resources := make([]parser.Resource, 0, len(fxResp.Tweet.Media.All))
|
||||
for _, media := range fxResp.Tweet.Media.All {
|
||||
var size int64
|
||||
resp, err := p.client.Get(media.URL)
|
||||
resp, err := p.client.Head(media.URL)
|
||||
if err == nil {
|
||||
size = resp.ContentLength
|
||||
resp.Body.Close()
|
||||
@@ -88,3 +95,28 @@ func (p *TwitterParser) Parse(u string) (*parser.Item, error) {
|
||||
func (p *TwitterParser) CanHandle(u string) bool {
|
||||
return twitterSourceURLRegexp.MatchString(u)
|
||||
}
|
||||
|
||||
func (p *TwitterParser) Name() string {
|
||||
return "twitter"
|
||||
}
|
||||
|
||||
func (p *TwitterParser) Configure(config map[string]any) error {
|
||||
if config == nil {
|
||||
p.apiDomain = fxTwitterApi
|
||||
p.client = *netutil.DefaultParserHTTPClient()
|
||||
return nil
|
||||
}
|
||||
if domain, ok := config["api_domain"].(string); ok && domain != "" {
|
||||
p.apiDomain = domain
|
||||
} else {
|
||||
p.apiDomain = fxTwitterApi
|
||||
}
|
||||
if proxyUrl, ok := config["proxy"].(string); ok && proxyUrl != "" {
|
||||
proxyClient, err := netutil.NewProxyHTTPClient(proxyUrl)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create proxy client: %w", err)
|
||||
}
|
||||
p.client = *proxyClient
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -7,4 +7,5 @@ import (
|
||||
const (
|
||||
MaxPartSize = 1024 * 1024
|
||||
MaxUploadPartSize = uploader.MaximumPartSize
|
||||
MaxPhotoSize = 10 * 1024 * 1024
|
||||
)
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
package consts
|
||||
|
||||
// inject version by '-X' flag
|
||||
// go build -ldflags "-X github.com/krau/SaveAny-Bot/pkg/consts.Version=${{ env.VERSION }}"
|
||||
var (
|
||||
Version string = "dev"
|
||||
BuildTime string = "unknown"
|
||||
GitCommit string = "unknown"
|
||||
)
|
||||
15
pkg/enums/fnamest/filename_srategy.go
Normal file
15
pkg/enums/fnamest/filename_srategy.go
Normal file
@@ -0,0 +1,15 @@
|
||||
package fnamest
|
||||
|
||||
//go:generate go-enum --values --names --noprefix --flag --nocase
|
||||
|
||||
// FnameST
|
||||
/* ENUM(
|
||||
default, message, template
|
||||
) */
|
||||
type FnameST string
|
||||
|
||||
var FnameSTDisplay = map[FnameST]string{
|
||||
Default: "默认",
|
||||
Message: "优先从消息生成",
|
||||
Template: "自定义模板",
|
||||
}
|
||||
92
pkg/enums/fnamest/filename_srategy_enum.go
Normal file
92
pkg/enums/fnamest/filename_srategy_enum.go
Normal file
@@ -0,0 +1,92 @@
|
||||
// Code generated by go-enum DO NOT EDIT.
|
||||
// Version: 0.6.1
|
||||
// Revision: a6f63bddde05aca4221df9c8e9e6d7d9674b1cb4
|
||||
// Build Date: 2025-03-18T23:42:14Z
|
||||
// Built By: goreleaser
|
||||
|
||||
package fnamest
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
// Default is a FnameST of type default.
|
||||
Default FnameST = "default"
|
||||
// Message is a FnameST of type message.
|
||||
Message FnameST = "message"
|
||||
// Template is a FnameST of type template.
|
||||
Template FnameST = "template"
|
||||
)
|
||||
|
||||
var ErrInvalidFnameST = fmt.Errorf("not a valid FnameST, try [%s]", strings.Join(_FnameSTNames, ", "))
|
||||
|
||||
var _FnameSTNames = []string{
|
||||
string(Default),
|
||||
string(Message),
|
||||
string(Template),
|
||||
}
|
||||
|
||||
// FnameSTNames returns a list of possible string values of FnameST.
|
||||
func FnameSTNames() []string {
|
||||
tmp := make([]string, len(_FnameSTNames))
|
||||
copy(tmp, _FnameSTNames)
|
||||
return tmp
|
||||
}
|
||||
|
||||
// FnameSTValues returns a list of the values for FnameST
|
||||
func FnameSTValues() []FnameST {
|
||||
return []FnameST{
|
||||
Default,
|
||||
Message,
|
||||
Template,
|
||||
}
|
||||
}
|
||||
|
||||
// String implements the Stringer interface.
|
||||
func (x FnameST) String() string {
|
||||
return string(x)
|
||||
}
|
||||
|
||||
// IsValid provides a quick way to determine if the typed value is
|
||||
// part of the allowed enumerated values
|
||||
func (x FnameST) IsValid() bool {
|
||||
_, err := ParseFnameST(string(x))
|
||||
return err == nil
|
||||
}
|
||||
|
||||
var _FnameSTValue = map[string]FnameST{
|
||||
"default": Default,
|
||||
"message": Message,
|
||||
"template": Template,
|
||||
}
|
||||
|
||||
// ParseFnameST attempts to convert a string to a FnameST.
|
||||
func ParseFnameST(name string) (FnameST, error) {
|
||||
if x, ok := _FnameSTValue[name]; ok {
|
||||
return x, nil
|
||||
}
|
||||
// Case insensitive parse, do a separate lookup to prevent unnecessary cost of lowercasing a string if we don't need to.
|
||||
if x, ok := _FnameSTValue[strings.ToLower(name)]; ok {
|
||||
return x, nil
|
||||
}
|
||||
return FnameST(""), fmt.Errorf("%s is %w", name, ErrInvalidFnameST)
|
||||
}
|
||||
|
||||
// Set implements the Golang flag.Value interface func.
|
||||
func (x *FnameST) Set(val string) error {
|
||||
v, err := ParseFnameST(val)
|
||||
*x = v
|
||||
return err
|
||||
}
|
||||
|
||||
// Get implements the Golang flag.Getter interface func.
|
||||
func (x *FnameST) Get() interface{} {
|
||||
return *x
|
||||
}
|
||||
|
||||
// Type implements the github.com/spf13/pFlag Value interface.
|
||||
func (x *FnameST) Type() string {
|
||||
return "FnameST"
|
||||
}
|
||||
@@ -1,13 +1,20 @@
|
||||
package parser
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
type Parser interface {
|
||||
CanHandle(url string) bool
|
||||
Parse(url string) (*Item, error)
|
||||
Parse(ctx context.Context, url string) (*Item, error)
|
||||
}
|
||||
|
||||
type ConfigurableParser interface {
|
||||
Parser
|
||||
Configure(config map[string]any) error
|
||||
Name() string
|
||||
}
|
||||
|
||||
// Resource is a single downloadable resource with metadata.
|
||||
@@ -15,7 +22,7 @@ type Resource struct {
|
||||
URL string `json:"url"`
|
||||
Filename string `json:"filename"` // with ext
|
||||
MimeType string `json:"mime_type"`
|
||||
Extension string `json:"extension"`
|
||||
Extension string `json:"extension"` // e.g. "mp4"
|
||||
Size int64 `json:"size"` // 0 when unknown
|
||||
Hash map[string]string `json:"hash"` // {"md5": "...", "sha256": "..."}
|
||||
Headers map[string]string `json:"headers"` // HTTP headers when downloading
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package consts
|
||||
package rule
|
||||
|
||||
const (
|
||||
RuleStorNameChosen = "CHOSEN"
|
||||
@@ -3,7 +3,6 @@ package rule
|
||||
import (
|
||||
"regexp"
|
||||
|
||||
ruleenum "github.com/krau/SaveAny-Bot/pkg/enums/rule"
|
||||
"github.com/krau/SaveAny-Bot/pkg/tfile"
|
||||
)
|
||||
|
||||
@@ -14,8 +13,8 @@ type RuleFileNameRegex struct {
|
||||
|
||||
var _ RuleClass[tfile.TGFile] = (*RuleFileNameRegex)(nil)
|
||||
|
||||
func (r RuleFileNameRegex) Type() ruleenum.RuleType {
|
||||
return ruleenum.FileNameRegex
|
||||
func (r RuleFileNameRegex) Type() RuleType {
|
||||
return FileNameRegex
|
||||
}
|
||||
|
||||
func (r RuleFileNameRegex) Match(input tfile.TGFile) (bool, error) {
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
package rule
|
||||
|
||||
import (
|
||||
ruleenum "github.com/krau/SaveAny-Bot/pkg/enums/rule"
|
||||
)
|
||||
|
||||
var _ RuleClass[bool] = (*RuleMediaType)(nil)
|
||||
|
||||
type RuleMediaType struct {
|
||||
@@ -11,8 +7,8 @@ type RuleMediaType struct {
|
||||
matchAlbum bool
|
||||
}
|
||||
|
||||
func (r RuleMediaType) Type() ruleenum.RuleType {
|
||||
return ruleenum.IsAlbum
|
||||
func (r RuleMediaType) Type() RuleType {
|
||||
return IsAlbum
|
||||
}
|
||||
|
||||
func (r RuleMediaType) Match(input bool) (bool, error) {
|
||||
|
||||
@@ -2,8 +2,6 @@ package rule
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
|
||||
ruleenum "github.com/krau/SaveAny-Bot/pkg/enums/rule"
|
||||
)
|
||||
|
||||
var _ RuleClass[string] = (*RuleMessageRegex)(nil)
|
||||
@@ -13,8 +11,8 @@ type RuleMessageRegex struct {
|
||||
regex *regexp.Regexp
|
||||
}
|
||||
|
||||
func (r RuleMessageRegex) Type() ruleenum.RuleType {
|
||||
return ruleenum.MessageRegex
|
||||
func (r RuleMessageRegex) Type() RuleType {
|
||||
return MessageRegex
|
||||
}
|
||||
|
||||
func (r RuleMessageRegex) Match(input string) (bool, error) {
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
package rule
|
||||
|
||||
import (
|
||||
ruleenum "github.com/krau/SaveAny-Bot/pkg/enums/rule"
|
||||
)
|
||||
|
||||
type RuleClass[InputType any] interface {
|
||||
Type() ruleenum.RuleType
|
||||
Type() RuleType
|
||||
Match(input InputType) (bool, error)
|
||||
StorageName() string
|
||||
StoragePath() string
|
||||
|
||||
@@ -10,6 +10,8 @@ import (
|
||||
const (
|
||||
TypeAdd = "add"
|
||||
TypeSetDefault = "setdefault"
|
||||
TypeConfig = "config"
|
||||
TypeCancel = "cancel"
|
||||
)
|
||||
|
||||
// type TaskDataTGFiles struct {
|
||||
|
||||
@@ -9,5 +9,5 @@ import (
|
||||
|
||||
func NewDownloader(file TGFile) *downloader.Builder {
|
||||
return downloader.NewDownloader().WithPartSize(tglimit.MaxPartSize).
|
||||
Download(file.Dler(), file.Location()).WithThreads(dlutil.BestThreads(file.Size(), config.Cfg.Threads))
|
||||
Download(file.Dler(), file.Location()).WithThreads(dlutil.BestThreads(file.Size(), config.C().Threads))
|
||||
}
|
||||
|
||||
@@ -2,20 +2,21 @@ package tfile
|
||||
|
||||
import "github.com/gotd/td/tg"
|
||||
|
||||
type TGFileOptions func(*tgFile)
|
||||
type TGFileOption func(*tgFile)
|
||||
|
||||
func WithMessage(msg *tg.Message) TGFileOptions {
|
||||
func WithMessage(msg *tg.Message) TGFileOption {
|
||||
return func(f *tgFile) {
|
||||
f.message = msg
|
||||
}
|
||||
}
|
||||
func WithName(name string) TGFileOptions {
|
||||
|
||||
func WithName(name string) TGFileOption {
|
||||
return func(f *tgFile) {
|
||||
f.name = name
|
||||
}
|
||||
}
|
||||
|
||||
func WithNameIfEmpty(name string) TGFileOptions {
|
||||
func WithNameIfEmpty(name string) TGFileOption {
|
||||
return func(f *tgFile) {
|
||||
if f.name == "" {
|
||||
f.name = name
|
||||
@@ -23,13 +24,13 @@ func WithNameIfEmpty(name string) TGFileOptions {
|
||||
}
|
||||
}
|
||||
|
||||
func WithSize(size int64) TGFileOptions {
|
||||
func WithSize(size int64) TGFileOption {
|
||||
return func(f *tgFile) {
|
||||
f.size = size
|
||||
}
|
||||
}
|
||||
|
||||
func WithSizeIfZero(size int64) TGFileOptions {
|
||||
func WithSizeIfZero(size int64) TGFileOption {
|
||||
return func(f *tgFile) {
|
||||
if f.size == 0 {
|
||||
f.size = size
|
||||
|
||||
@@ -14,6 +14,7 @@ type TGFile interface {
|
||||
Dler() downloader.Client // witch client to use for downloading
|
||||
Size() int64
|
||||
Name() string
|
||||
SetName(name string)
|
||||
}
|
||||
|
||||
type TGFileMessage interface {
|
||||
@@ -29,6 +30,10 @@ type tgFile struct {
|
||||
dler downloader.Client
|
||||
}
|
||||
|
||||
func (f *tgFile) SetName(name string) {
|
||||
f.name = name
|
||||
}
|
||||
|
||||
func (f *tgFile) Location() tg.InputFileLocationClass {
|
||||
return f.location
|
||||
}
|
||||
@@ -54,7 +59,7 @@ func NewTGFile(
|
||||
dler downloader.Client,
|
||||
size int64,
|
||||
name string,
|
||||
opts ...TGFileOptions,
|
||||
opts ...TGFileOption,
|
||||
) TGFile {
|
||||
f := &tgFile{
|
||||
location: location,
|
||||
@@ -68,7 +73,7 @@ func NewTGFile(
|
||||
return f
|
||||
}
|
||||
|
||||
func FromMedia(media tg.MessageMediaClass, client downloader.Client, opts ...TGFileOptions) (TGFile, error) {
|
||||
func FromMedia(media tg.MessageMediaClass, client downloader.Client, opts ...TGFileOption) (TGFile, error) {
|
||||
switch m := media.(type) {
|
||||
case *tg.MessageMediaDocument:
|
||||
document, ok := m.Document.AsNotEmpty()
|
||||
@@ -125,7 +130,7 @@ func FromMedia(media tg.MessageMediaClass, client downloader.Client, opts ...TGF
|
||||
return nil, fmt.Errorf("unsupported media type: %T", media)
|
||||
}
|
||||
|
||||
func FromMediaMessage(media tg.MessageMediaClass, client downloader.Client, msg *tg.Message, opts ...TGFileOptions) (TGFileMessage, error) {
|
||||
func FromMediaMessage(media tg.MessageMediaClass, client downloader.Client, msg *tg.Message, opts ...TGFileOption) (TGFileMessage, error) {
|
||||
file, err := FromMedia(media, client, opts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
161
plugins/README.md
Normal file
161
plugins/README.md
Normal file
@@ -0,0 +1,161 @@
|
||||
# SaveAnyBot Plugins
|
||||
|
||||
SaveAnyBot 可通过插件扩展功能, 目前仅支持 Parser (解析器)插件.
|
||||
|
||||
## Parser
|
||||
|
||||
解析器为 SaveAnyBot 提供了处理非 Telegram 文件的能力, 例如下载其他网站的图片或视频.
|
||||
|
||||
当前解析器接口定义如下:
|
||||
|
||||
```go
|
||||
type Parser interface {
|
||||
CanHandle(url string) bool // 判断是否能处理给定的 URL
|
||||
Parse(ctx context.Context, url string) (*Item, error) // 解析 URL, 返回 Item
|
||||
}
|
||||
|
||||
// Resource is a single downloadable resource with metadata.
|
||||
type Resource struct {
|
||||
URL string `json:"url"`
|
||||
Filename string `json:"filename"` // with ext
|
||||
MimeType string `json:"mime_type"`
|
||||
Extension string `json:"extension"`
|
||||
Size int64 `json:"size"` // 0 when unknown
|
||||
Hash map[string]string `json:"hash"` // {"md5": "...", "sha256": "..."}
|
||||
Headers map[string]string `json:"headers"` // HTTP headers when downloading
|
||||
Extra map[string]any `json:"extra"`
|
||||
}
|
||||
|
||||
type Item struct {
|
||||
Site string `json:"site"`
|
||||
URL string `json:"url"` // original URL of the item
|
||||
Title string `json:"title"`
|
||||
Author string `json:"author"`
|
||||
Description string `json:"description"`
|
||||
Tags []string `json:"tags"`
|
||||
Resources []Resource `json:"resources"`
|
||||
Extra map[string]any `json:"extra"`
|
||||
}
|
||||
```
|
||||
|
||||
### Write a Parser Plugin
|
||||
|
||||
解析器插件可使用 JavaScript 编写, SaveAnyBot 使用 [goja](https://github.com/dop251/goja) 提供运行时, 并向其中注入了以下全局函数或对象:
|
||||
|
||||
- **registerParser**: 用于注册解析器, 每个插件必须调用此函数以注册
|
||||
- **console.log**: 调用 go 端的 logger 打印日志
|
||||
- **ghttp**: 提供 HTTP 请求功能
|
||||
|
||||
插件需要提供元数据 `metadata` 并实现 `canHandle` 和 `parse` 两个函数, 最后调用 `registerParser` 注册解析器.
|
||||
|
||||
#### Plugin Metadata
|
||||
|
||||
插件元数据是一个 JavaScript 对象:
|
||||
|
||||
```js
|
||||
const metadata = {
|
||||
version: "1.0.0", // 插件版本号, 必须提供, 其他字段可选
|
||||
name: "Example Parser", // 插件名称
|
||||
description: "A parser for example links", // 插件描述
|
||||
author: "Krau", // 插件作者
|
||||
}
|
||||
```
|
||||
|
||||
#### canHandle Function
|
||||
|
||||
`canHandle`: `canHandle(url: string): boolean` , 用于判断当前解析器能否解析给定的 URL, 返回布尔值, 例如:
|
||||
|
||||
```js
|
||||
const canHandle = function (url) {
|
||||
return url.includes("youtube.com/watch?v");
|
||||
};
|
||||
```
|
||||
|
||||
这将让 SaveAnyBot 在遇到包含 `youtube.com/watch?v` 的 url 时调用当前解析器的 `parse`.
|
||||
|
||||
#### parse Function
|
||||
|
||||
`parse`: `parse(url: string): Item` , 是核心解析函数, 用于解析给定的 url, 返回一个 `Item` 对象, 例:
|
||||
|
||||
```js
|
||||
const parse = function (url) {
|
||||
var result = {
|
||||
// 元信息
|
||||
site: "YouTube",
|
||||
url: url,
|
||||
title: "测试 YouTube 视频",
|
||||
author: "某视频作者",
|
||||
description: "这是一个测试视频",
|
||||
tags: ["test", "youtube"],
|
||||
// 资源(可下载的文件)列表
|
||||
resources: [
|
||||
{
|
||||
url: "https://example.com/video1.mp4", // 文件直链
|
||||
filename: "somevideo.mp4", // 文件名
|
||||
mime_type: "video/mp4", // 文件 MIME 类型, 可选
|
||||
extension: "mp4", // 文件扩展名, 可选
|
||||
size: 100 * 1024 * 1024, // 文件大小, 单位为字节, 未知可以设置为 0
|
||||
hash: {}, // 文件哈希, 可选, 格式为 {"md5": "xxx", "sha256": "xxx"} 等
|
||||
headers: {}, // 下载文件时所需的 HTTP 头部, 可选, 例如 {"User-Agent": "Mozilla/5.0"}
|
||||
extra: {} // 额外信息, 可选, 可以包含任何自定义数据
|
||||
},
|
||||
{
|
||||
url: "https://example.com/picture1.png",
|
||||
filename: "picture1.png",
|
||||
mime_type: "image/png",
|
||||
extension: "png",
|
||||
size: 1 * 1024 * 1024,
|
||||
hash: {},
|
||||
headers: {},
|
||||
extra: {}
|
||||
}
|
||||
],
|
||||
extra: {}
|
||||
};
|
||||
return result;
|
||||
}
|
||||
```
|
||||
|
||||
#### HTTP Requests
|
||||
|
||||
使用 `ghttp` 对象以发起 HTTP 请求.
|
||||
|
||||
**ghttp.get(url: string)** 发起 GET 请求, 当成功时返回响应体字符串, 失败时或响应状态码不为 200 时返回一个包含 `error` 字段的对象:
|
||||
|
||||
```js
|
||||
const response = ghttp.get("https://example.com/someapi");
|
||||
if (response.error) {
|
||||
console.log("Request failed:", response.error);
|
||||
}
|
||||
if (response.status) {
|
||||
console.log("Response status:", response.status);
|
||||
}
|
||||
```
|
||||
|
||||
**ghttp.getJSON(url: string)** 发起 GET 请求并将响应体解析为 JSON 对象, 始终返回以下对象:
|
||||
|
||||
```js
|
||||
{
|
||||
data?: any, // 当请求成功且响应体为合法 JSON 时包含解析后的数据
|
||||
error?: string, // 当请求失败或响应状态码不为 200 时包含错误信息
|
||||
status?: number, // 响应状态码, 仅当响应状态码不为 200 时包含
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
最后别忘了调用 `registerParser` 注册解析器:
|
||||
|
||||
```js
|
||||
registerParser({
|
||||
metadata,
|
||||
canHandle,
|
||||
parse
|
||||
});
|
||||
```
|
||||
|
||||
### Examples
|
||||
|
||||
请先查看 [example_parser_basic.js](./example_parser_basic.js) 了解最简示例解析器插件的实现.
|
||||
|
||||
然后查看 [example_parser_danbooru.js](./example_parser_danbooru.js) , 这是一个可直接使用的插件, 用于解析 Danbooru 图片页面并提取图片资源.
|
||||
@@ -1,7 +1,5 @@
|
||||
// 这是一个示例解析器插件, 模拟处理 YouTube 的视频链接
|
||||
|
||||
// 你可以使用 console.log 来在终端中使用 go 的 logger 打印信息
|
||||
console.log("Example parser loaded");
|
||||
// 这是一个最简示例解析器插件, 用于展示插件所需实现的基本功能
|
||||
// 此插件将会模拟处理 YouTube 的视频链接
|
||||
|
||||
/**
|
||||
* 插件元数据
|
||||
@@ -14,6 +12,9 @@ const metadata = {
|
||||
author: "Krau", // 插件作者
|
||||
}
|
||||
|
||||
// 你可以使用 console.log 来在终端中使用 go 的 logger 打印信息
|
||||
console.log("Parser loaded", "name", metadata.name);
|
||||
|
||||
/**
|
||||
* canHandle 函数用于判断当前解析器能否解析给定的 URL
|
||||
*/
|
||||
@@ -22,7 +23,6 @@ const canHandle = function (url) {
|
||||
return url.includes("youtube.com/watch?v");
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 解析 url 并返回一个 Item 对象, 类型定义在 pkg/parser.go 中
|
||||
*/
|
||||
@@ -63,8 +63,11 @@ const parse = function (url) {
|
||||
return result;
|
||||
}
|
||||
|
||||
// 最后需要调用 registerParser 来注册这个解析器
|
||||
registerParser({
|
||||
metadata,
|
||||
canHandle,
|
||||
parse
|
||||
});
|
||||
});
|
||||
|
||||
// 更进一步的插件编写信息, 请查看 plugins/example_parser_danbooru.js
|
||||
138
plugins/example_parser_danbooru.js
Normal file
138
plugins/example_parser_danbooru.js
Normal file
@@ -0,0 +1,138 @@
|
||||
// Danbooru post parser for SaveAnyBot
|
||||
// request https://danbooru.donmai.us/posts/{id}.json and parse the response
|
||||
|
||||
const metadata = {
|
||||
name: "Danbooru Post Parser",
|
||||
version: "1.0.0",
|
||||
description: "Parse Danbooru post links via official JSON API",
|
||||
author: "Krau",
|
||||
};
|
||||
|
||||
// some utils
|
||||
const danbooruSourceURLRegexp = /danbooru\.donmai\.us\/(posts|post\/show)\/(\d+)/;
|
||||
function getPostID(url) {
|
||||
const m = url.match(danbooruSourceURLRegexp);
|
||||
return m ? m[2] : "";
|
||||
}
|
||||
function normalizePostURL(id) {
|
||||
return `https://danbooru.donmai.us/posts/${id}`;
|
||||
}
|
||||
function apiURLFor(id) {
|
||||
return `https://danbooru.donmai.us/posts/${id}.json`;
|
||||
}
|
||||
|
||||
|
||||
function basenameFromURL(u) {
|
||||
try {
|
||||
const q = u.split("?")[0];
|
||||
const parts = q.split("/");
|
||||
const name = parts[parts.length - 1] || "";
|
||||
return name || "file";
|
||||
} catch (_) {
|
||||
return "file";
|
||||
}
|
||||
}
|
||||
function extFromFilename(name) {
|
||||
const idx = name.lastIndexOf(".");
|
||||
if (idx < 0) return "";
|
||||
return name.slice(idx + 1).toLowerCase();
|
||||
}
|
||||
function mimeFromExt(ext) {
|
||||
switch (ext) {
|
||||
case "jpg":
|
||||
case "jpeg":
|
||||
return "image/jpeg";
|
||||
case "png":
|
||||
return "image/png";
|
||||
case "gif":
|
||||
return "image/gif";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
// implement canHandle and parse
|
||||
const canHandle = function (url) {
|
||||
return danbooruSourceURLRegexp.test(url);
|
||||
};
|
||||
|
||||
const parse = function (sourceURL) {
|
||||
const id = getPostID(sourceURL);
|
||||
if (!id) {
|
||||
throw new Error("invalid danbooru post url");
|
||||
}
|
||||
|
||||
const normURL = normalizePostURL(id);
|
||||
|
||||
const apiURL = apiURLFor(id);
|
||||
console.log("Danbooru requesting", "url", apiURL);
|
||||
// You can use ghttp.getJSON to fetch and parse JSON in one step.
|
||||
// While the ghttp.get can be used to fetch raw response.
|
||||
const data = ghttp.getJSON(apiURL);
|
||||
|
||||
if (data && data.error) {
|
||||
throw new Error(data.message || "danbooru returned error");
|
||||
}
|
||||
|
||||
const fileURL = data.file_url || "";
|
||||
const largeURL = data.large_file_url || "";
|
||||
const width = data.image_width || 0;
|
||||
const height = data.image_height || 0;
|
||||
|
||||
if (!fileURL && !largeURL) {
|
||||
throw new Error("danbooru response has no file_url / large_file_url");
|
||||
}
|
||||
|
||||
const resources = [];
|
||||
if (fileURL) {
|
||||
const name = basenameFromURL(fileURL);
|
||||
const ext = extFromFilename(name);
|
||||
resources.push({
|
||||
url: fileURL,
|
||||
filename: name,
|
||||
mime_type: mimeFromExt(ext),
|
||||
extension: ext,
|
||||
size: 0,
|
||||
hash: {},
|
||||
headers: {},
|
||||
extra: { width, height, kind: "original" },
|
||||
});
|
||||
}
|
||||
if (largeURL && largeURL !== fileURL) {
|
||||
const name = basenameFromURL(largeURL);
|
||||
const ext = extFromFilename(name);
|
||||
resources.push({
|
||||
url: largeURL,
|
||||
filename: name,
|
||||
mime_type: mimeFromExt(ext),
|
||||
extension: ext,
|
||||
size: 0,
|
||||
hash: {},
|
||||
headers: {},
|
||||
extra: { width, height, kind: "large" },
|
||||
});
|
||||
}
|
||||
|
||||
const tags = (data.tag_string ? String(data.tag_string) : "")
|
||||
.split(" ")
|
||||
.filter(Boolean);
|
||||
|
||||
const item = {
|
||||
site: "Danbooru",
|
||||
url: normURL,
|
||||
title: `Danbooru/${data.id || id}`,
|
||||
author: "Danbooru",
|
||||
description: "",
|
||||
tags: tags,
|
||||
resources: resources,
|
||||
extra: {},
|
||||
};
|
||||
|
||||
return item;
|
||||
};
|
||||
|
||||
registerParser({
|
||||
metadata,
|
||||
canHandle,
|
||||
parse,
|
||||
});
|
||||
@@ -20,7 +20,7 @@ func getStorageByName(ctx context.Context, name string) (Storage, error) {
|
||||
if ok {
|
||||
return storage, nil
|
||||
}
|
||||
cfg := config.Cfg.GetStorageByName(name)
|
||||
cfg := config.C().GetStorageByName(name)
|
||||
if cfg == nil {
|
||||
return nil, fmt.Errorf("未找到存储 %s", name)
|
||||
}
|
||||
@@ -39,7 +39,7 @@ func GetStorageByUserIDAndName(ctx context.Context, chatID int64, name string) (
|
||||
return nil, ErrStorageNameEmpty
|
||||
}
|
||||
|
||||
if !config.Cfg.HasStorage(chatID, name) {
|
||||
if !config.C().HasStorage(chatID, name) {
|
||||
return nil, fmt.Errorf("没有找到用户 %d 的存储 %s", chatID, name)
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@ func GetUserStorages(ctx context.Context, chatID int64) []Storage {
|
||||
return storages
|
||||
}
|
||||
var storages []Storage
|
||||
for _, name := range config.Cfg.GetStorageNamesByUserID(chatID) {
|
||||
for _, name := range config.C().GetStorageNamesByUserID(chatID) {
|
||||
storage, err := getStorageByName(ctx, name)
|
||||
if err != nil {
|
||||
continue
|
||||
@@ -67,14 +67,14 @@ func GetUserStorages(ctx context.Context, chatID int64) []Storage {
|
||||
func LoadStorages(ctx context.Context) {
|
||||
logger := log.FromContext(ctx)
|
||||
logger.Info("加载存储...")
|
||||
for _, storage := range config.Cfg.Storages {
|
||||
for _, storage := range config.C().Storages {
|
||||
_, err := getStorageByName(ctx, storage.GetName())
|
||||
if err != nil {
|
||||
logger.Errorf("加载存储 %s 失败: %v", storage.GetName(), err)
|
||||
}
|
||||
}
|
||||
logger.Infof("成功加载 %d 个存储", len(Storages))
|
||||
for user := range config.Cfg.GetUsersID() {
|
||||
for user := range config.C().GetUsersID() {
|
||||
UserStorages[int64(user)] = GetUserStorages(ctx, int64(user))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,12 +5,14 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/duke-git/lancet/v2/convertor"
|
||||
"github.com/charmbracelet/log"
|
||||
"github.com/duke-git/lancet/v2/slice"
|
||||
"github.com/duke-git/lancet/v2/validator"
|
||||
"github.com/gabriel-vasile/mimetype"
|
||||
"github.com/gotd/td/constant"
|
||||
"github.com/gotd/td/telegram/message"
|
||||
"github.com/gotd/td/telegram/message/styling"
|
||||
"github.com/gotd/td/telegram/uploader"
|
||||
@@ -75,32 +77,52 @@ func (t *Telegram) Save(ctx context.Context, r io.Reader, storagePath string) er
|
||||
if tctx == nil {
|
||||
return fmt.Errorf("failed to get telegram context")
|
||||
}
|
||||
// 去除前导斜杠并分隔路径, 当 len(parts):
|
||||
// ==0, 存储到配置文件中的 chat_id, 随机文件名
|
||||
// ==1, 视作只有文件名, 存储到配置文件中的 chat_id
|
||||
// ==2, parts[0]: 视作要存储到的 chat_id, parts[1]: filename
|
||||
|
||||
parts := slice.Compact(strings.Split(strings.TrimPrefix(storagePath, "/"), "/"))
|
||||
filename := ""
|
||||
chatID := t.config.ChatID
|
||||
if after, ok0 := strings.CutPrefix(convertor.ToString(chatID), "-100"); ok0 {
|
||||
cid, err := strconv.ParseInt(after, 10, 64)
|
||||
if len(parts) >= 1 {
|
||||
filename = parts[len(parts)-1]
|
||||
}
|
||||
if len(parts) >= 2 && validator.IsAlphaNumeric(parts[0]) {
|
||||
cid, err := tgutil.ParseChatID(tctx, parts[0])
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse chat ID: %w", err)
|
||||
// id不合法时使用配置文件中的 chat_id
|
||||
log.FromContext(ctx).Warnf("Failed to parse chat ID from path, using configured chat_id: %s", err)
|
||||
cid = chatID
|
||||
} else {
|
||||
if cid > constant.MaxTDLibChannelID || cid > constant.MaxTDLibChatID || cid > constant.MaxTDLibUserID {
|
||||
cid = chatID
|
||||
}
|
||||
}
|
||||
chatID = cid
|
||||
}
|
||||
peer := tctx.PeerStorage.GetInputPeerById(chatID)
|
||||
if peer == nil {
|
||||
return fmt.Errorf("failed to get input peer for chat ID %d", chatID)
|
||||
}
|
||||
mtype, err := mimetype.DetectReader(rs)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to detect mimetype: %w", err)
|
||||
}
|
||||
filename := path.Base(storagePath)
|
||||
if filename == "" {
|
||||
filename = xid.New().String() + mtype.Extension()
|
||||
}
|
||||
|
||||
if chatID < 0 {
|
||||
chatID = chatID - constant.ZeroTDLibChannelID
|
||||
}
|
||||
peer := tctx.PeerStorage.GetInputPeerById(chatID)
|
||||
if peer == nil {
|
||||
return fmt.Errorf("failed to get input peer for chat ID %d", chatID)
|
||||
}
|
||||
|
||||
if _, err := rs.Seek(0, io.SeekStart); err != nil {
|
||||
return fmt.Errorf("failed to seek reader: %w", err)
|
||||
}
|
||||
upler := uploader.NewUploader(tctx.Raw).
|
||||
WithPartSize(tglimit.MaxUploadPartSize).
|
||||
WithThreads(config.Cfg.Threads)
|
||||
WithThreads(config.C().Threads)
|
||||
|
||||
var file tg.InputFileClass
|
||||
size := func() int64 {
|
||||
@@ -120,9 +142,13 @@ func (t *Telegram) Save(ctx context.Context, r io.Reader, storagePath string) er
|
||||
return fmt.Errorf("failed to upload file to telegram: %w", err)
|
||||
}
|
||||
caption := styling.Plain(filename)
|
||||
forceFile := t.config.ForceFile
|
||||
if strings.HasPrefix(mtype.String(), "image/") && size >= tglimit.MaxPhotoSize {
|
||||
forceFile = true
|
||||
}
|
||||
docb := message.UploadedDocument(file, caption).
|
||||
Filename(filename).
|
||||
ForceFile(false).
|
||||
ForceFile(forceFile).
|
||||
MIME(mtype.String())
|
||||
|
||||
var media message.MediaOption = docb
|
||||
@@ -135,7 +161,6 @@ func (t *Telegram) Save(ctx context.Context, r io.Reader, storagePath string) er
|
||||
case strings.HasPrefix(mtypeStr, "image/") && !strings.HasSuffix(mtypeStr, "webp"):
|
||||
media = message.UploadedPhoto(file, caption)
|
||||
}
|
||||
|
||||
sender := tctx.Sender
|
||||
_, err = sender.WithUploader(upler).To(peer).Media(ctx, media)
|
||||
return err
|
||||
|
||||
Reference in New Issue
Block a user