mirror of
https://github.com/krau/SaveAny-Bot.git
synced 2026-05-11 01:59:40 +08:00
Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a7b93e57fc | ||
|
|
a4b3b459a9 | ||
|
|
06f326088a | ||
|
|
b7d3ec6230 | ||
|
|
f812990e1c | ||
|
|
492900bbef | ||
|
|
764be2a083 | ||
|
|
46c21b77e9 | ||
|
|
8b389a58d5 | ||
|
|
25ad9befa0 | ||
|
|
e824b210d1 | ||
|
|
ae0aa7db3f | ||
|
|
226c15ef08 | ||
|
|
9b3f955e48 | ||
|
|
4997ec408f | ||
|
|
0756cc9eb1 |
@@ -19,12 +19,13 @@ import (
|
|||||||
"golang.org/x/net/proxy"
|
"golang.org/x/net/proxy"
|
||||||
)
|
)
|
||||||
|
|
||||||
func Init(ctx context.Context) {
|
func Init(ctx context.Context) (<-chan struct{}) {
|
||||||
log.FromContext(ctx).Info("初始化 Bot...")
|
log.FromContext(ctx).Info("初始化 Bot...")
|
||||||
resultChan := make(chan struct {
|
resultChan := make(chan struct {
|
||||||
client *gotgproto.Client
|
client *gotgproto.Client
|
||||||
err error
|
err error
|
||||||
})
|
})
|
||||||
|
shouldRestart := make(chan struct{})
|
||||||
go func() {
|
go func() {
|
||||||
var resolver dcs.Resolver
|
var resolver dcs.Resolver
|
||||||
if config.C().Telegram.Proxy.Enable && config.C().Telegram.Proxy.URL != "" {
|
if config.C().Telegram.Proxy.Enable && config.C().Telegram.Proxy.URL != "" {
|
||||||
@@ -55,7 +56,11 @@ func Init(ctx context.Context) {
|
|||||||
MaxRetries: config.C().Telegram.RpcRetry,
|
MaxRetries: config.C().Telegram.RpcRetry,
|
||||||
AutoFetchReply: true,
|
AutoFetchReply: true,
|
||||||
ErrorHandler: func(ctx *ext.Context, u *ext.Update, s string) error {
|
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
|
return dispatcher.EndGroups
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -103,4 +108,5 @@ func Init(ctx context.Context) {
|
|||||||
handlers.Register(result.client.Dispatcher)
|
handlers.Register(result.client.Dispatcher)
|
||||||
log.FromContext(ctx).Info("Bot 初始化完成")
|
log.FromContext(ctx).Info("Bot 初始化完成")
|
||||||
}
|
}
|
||||||
|
return shouldRestart
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package handlers
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"path"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/celestix/gotgproto/dispatcher"
|
"github.com/celestix/gotgproto/dispatcher"
|
||||||
@@ -11,6 +12,7 @@ import (
|
|||||||
"github.com/gotd/td/tg"
|
"github.com/gotd/td/tg"
|
||||||
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/msgelem"
|
"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/client/bot/handlers/utils/shortcut"
|
||||||
|
"github.com/krau/SaveAny-Bot/common/utils/fsutil"
|
||||||
"github.com/krau/SaveAny-Bot/database"
|
"github.com/krau/SaveAny-Bot/database"
|
||||||
"github.com/krau/SaveAny-Bot/pkg/enums/tasktype"
|
"github.com/krau/SaveAny-Bot/pkg/enums/tasktype"
|
||||||
"github.com/krau/SaveAny-Bot/pkg/tcbdata"
|
"github.com/krau/SaveAny-Bot/pkg/tcbdata"
|
||||||
@@ -74,6 +76,9 @@ func handleAddCallback(ctx *ext.Context, update *ext.Update) error {
|
|||||||
case tasktype.TaskTypeTphpics:
|
case tasktype.TaskTypeTphpics:
|
||||||
return shortcut.CreateAndAddtelegraphWithEdit(ctx, userID, data.TphPageNode, data.TphDirPath, data.TphPics, selectedStorage, msgID)
|
return shortcut.CreateAndAddtelegraphWithEdit(ctx, userID, data.TphPageNode, data.TphDirPath, data.TphPics, selectedStorage, msgID)
|
||||||
case tasktype.TaskTypeParseditem:
|
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)
|
shortcut.CreateAndAddParsedTaskWithEdit(ctx, selectedStorage, dirPath, data.ParsedItem, msgID, userID)
|
||||||
default:
|
default:
|
||||||
log.FromContext(ctx).Errorf("Unsupported task type: %s", data.TaskType)
|
log.FromContext(ctx).Errorf("Unsupported task type: %s", data.TaskType)
|
||||||
|
|||||||
@@ -21,8 +21,10 @@ Save Any Bot - 转存你的 Telegram 文件
|
|||||||
/save [自定义文件名] - 保存文件
|
/save [自定义文件名] - 保存文件
|
||||||
/dir - 管理存储目录
|
/dir - 管理存储目录
|
||||||
/rule - 管理规则
|
/rule - 管理规则
|
||||||
|
/update - 检查更新并升级
|
||||||
|
|
||||||
使用帮助: https://sabot.unv.app/usage/
|
使用帮助: https://sabot.unv.app/usage
|
||||||
|
反馈群组: https://t.me/ProjectSaveAny
|
||||||
`
|
`
|
||||||
shortHash := config.GitCommit
|
shortHash := config.GitCommit
|
||||||
if len(shortHash) > 7 {
|
if len(shortHash) > 7 {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"github.com/gotd/td/tg"
|
"github.com/gotd/td/tg"
|
||||||
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/msgelem"
|
"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/client/bot/handlers/utils/shortcut"
|
||||||
|
"github.com/krau/SaveAny-Bot/common/utils/fsutil"
|
||||||
"github.com/krau/SaveAny-Bot/parsers"
|
"github.com/krau/SaveAny-Bot/parsers"
|
||||||
"github.com/krau/SaveAny-Bot/pkg/enums/tasktype"
|
"github.com/krau/SaveAny-Bot/pkg/enums/tasktype"
|
||||||
"github.com/krau/SaveAny-Bot/pkg/tcbdata"
|
"github.com/krau/SaveAny-Bot/pkg/tcbdata"
|
||||||
@@ -106,5 +107,9 @@ func handleSilentSaveText(ctx *ext.Context, u *ext.Update) error {
|
|||||||
logger.Errorf("Failed to send message: %s", err)
|
logger.Errorf("Failed to send message: %s", err)
|
||||||
return dispatcher.EndGroups
|
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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,6 +41,8 @@ func Register(disp dispatcher.Dispatcher) {
|
|||||||
disp.AddHandler(handlers.NewCommand("unwatch", handleUnwatchCmd))
|
disp.AddHandler(handlers.NewCommand("unwatch", handleUnwatchCmd))
|
||||||
disp.AddHandler(handlers.NewCommand("save", handleSilentMode(handleSaveCmd, handleSilentSaveReplied)))
|
disp.AddHandler(handlers.NewCommand("save", handleSilentMode(handleSaveCmd, handleSilentSaveReplied)))
|
||||||
disp.AddHandler(handlers.NewCommand("config", handleConfigCmd))
|
disp.AddHandler(handlers.NewCommand("config", handleConfigCmd))
|
||||||
|
disp.AddHandler(handlers.NewCommand("update", handleUpdateCmd))
|
||||||
|
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.TypeAdd), handleAddCallback))
|
||||||
disp.AddHandler(handlers.NewCallbackQuery(filters.CallbackQuery.Prefix(tcbdata.TypeSetDefault), handleSetDefaultCallback))
|
disp.AddHandler(handlers.NewCallbackQuery(filters.CallbackQuery.Prefix(tcbdata.TypeSetDefault), handleSetDefaultCallback))
|
||||||
disp.AddHandler(handlers.NewCallbackQuery(filters.CallbackQuery.Prefix(tcbdata.TypeCancel), handleCancelCallback))
|
disp.AddHandler(handlers.NewCallbackQuery(filters.CallbackQuery.Prefix(tcbdata.TypeCancel), handleCancelCallback))
|
||||||
@@ -112,7 +114,10 @@ func listenMediaMessageEvent(ch chan userclient.MediaMessageEvent) {
|
|||||||
}
|
}
|
||||||
var dirPath string
|
var dirPath string
|
||||||
if user.ApplyRule && user.Rules != nil {
|
if user.ApplyRule && user.Rules != nil {
|
||||||
matchedStorageName, matchedDirPath := ruleutil.ApplyRule(ctx, user.Rules, ruleutil.NewInput(file))
|
matched, matchedStorageName, matchedDirPath := ruleutil.ApplyRule(ctx, user.Rules, ruleutil.NewInput(file))
|
||||||
|
if !matched {
|
||||||
|
goto startCreateTask
|
||||||
|
}
|
||||||
dirPath = matchedDirPath.String()
|
dirPath = matchedDirPath.String()
|
||||||
if matchedStorageName.IsUsable() {
|
if matchedStorageName.IsUsable() {
|
||||||
stor, err = storage.GetStorageByUserIDAndName(ctx, user.ChatID, matchedStorageName.String())
|
stor, err = storage.GetStorageByUserIDAndName(ctx, user.ChatID, matchedStorageName.String())
|
||||||
@@ -122,6 +127,7 @@ func listenMediaMessageEvent(ch chan userclient.MediaMessageEvent) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
startCreateTask:
|
||||||
storagePath := stor.JoinStoragePath(path.Join(dirPath, file.Name()))
|
storagePath := stor.JoinStoragePath(path.Join(dirPath, file.Name()))
|
||||||
injectCtx := tgutil.ExtWithContext(ctx.Context, ctx)
|
injectCtx := tgutil.ExtWithContext(ctx.Context, ctx)
|
||||||
taskid := xid.New().String()
|
taskid := xid.New().String()
|
||||||
|
|||||||
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")
|
||||||
|
}
|
||||||
@@ -48,9 +48,9 @@ func (m MatchedDirPath) NeedNewForAlbum() bool {
|
|||||||
return m != "" && m == rule.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 {
|
if inputs == nil || len(rules) == 0 {
|
||||||
return "", ""
|
return false, "", ""
|
||||||
}
|
}
|
||||||
logger := log.FromContext(ctx)
|
logger := log.FromContext(ctx)
|
||||||
for _, ur := range rules {
|
for _, ur := range rules {
|
||||||
@@ -106,5 +106,8 @@ func ApplyRule(ctx context.Context, rules []database.Rule, inputs *ruleInput) (m
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return
|
if matchedStorageName != "" || dirPath != "" {
|
||||||
|
return true, matchedStorageName, dirPath
|
||||||
|
}
|
||||||
|
return false, "", ""
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,8 +34,13 @@ func CreateAndAddTGFileTaskWithEdit(ctx *ext.Context, userID int64, stor storage
|
|||||||
return dispatcher.EndGroups
|
return dispatcher.EndGroups
|
||||||
}
|
}
|
||||||
if user.ApplyRule && user.Rules != nil {
|
if user.ApplyRule && user.Rules != nil {
|
||||||
matchedStorageName, matchedDirPath := ruleutil.ApplyRule(ctx, user.Rules, ruleutil.NewInput(file))
|
matched, matchedStorageName, matchedDirPath := ruleutil.ApplyRule(ctx, user.Rules, ruleutil.NewInput(file))
|
||||||
dirPath = matchedDirPath.String()
|
if !matched {
|
||||||
|
goto startCreateTask
|
||||||
|
}
|
||||||
|
if matchedDirPath != "" {
|
||||||
|
dirPath = matchedDirPath.String()
|
||||||
|
}
|
||||||
if matchedStorageName.IsUsable() {
|
if matchedStorageName.IsUsable() {
|
||||||
stor, err = storage.GetStorageByUserIDAndName(ctx, user.ChatID, matchedStorageName.String())
|
stor, err = storage.GetStorageByUserIDAndName(ctx, user.ChatID, matchedStorageName.String())
|
||||||
if err != nil {
|
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()))
|
storagePath := stor.JoinStoragePath(path.Join(dirPath, file.Name()))
|
||||||
injectCtx := tgutil.ExtWithContext(ctx.Context, ctx)
|
injectCtx := tgutil.ExtWithContext(ctx.Context, ctx)
|
||||||
taskid := xid.New().String()
|
taskid := xid.New().String()
|
||||||
@@ -101,8 +106,10 @@ func CreateAndAddBatchTGFileTaskWithEdit(ctx *ext.Context, userID int64, stor st
|
|||||||
if !useRule {
|
if !useRule {
|
||||||
return stor.Name(), ruleutil.MatchedDirPath(dirPath)
|
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()
|
storname := storName.String()
|
||||||
if !storName.IsUsable() {
|
if !storName.IsUsable() {
|
||||||
storname = stor.Name()
|
storname = stor.Name()
|
||||||
|
|||||||
18
cmd/run.go
18
cmd/run.go
@@ -25,7 +25,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func Run(cmd *cobra.Command, _ []string) {
|
func Run(cmd *cobra.Command, _ []string) {
|
||||||
ctx := cmd.Context()
|
ctx, cancel := context.WithCancel(cmd.Context())
|
||||||
logger := log.NewWithOptions(os.Stdout, log.Options{
|
logger := log.NewWithOptions(os.Stdout, log.Options{
|
||||||
Level: log.DebugLevel,
|
Level: log.DebugLevel,
|
||||||
ReportTimestamp: true,
|
ReportTimestamp: true,
|
||||||
@@ -34,7 +34,15 @@ func Run(cmd *cobra.Command, _ []string) {
|
|||||||
})
|
})
|
||||||
ctx = log.WithContext(ctx, logger)
|
ctx = log.WithContext(ctx, logger)
|
||||||
|
|
||||||
initAll(ctx)
|
exitChan, err := initAll(ctx)
|
||||||
|
if err != nil {
|
||||||
|
logger.Fatal("Failed to initialize", "error", err)
|
||||||
|
}
|
||||||
|
go func() {
|
||||||
|
<-exitChan
|
||||||
|
cancel()
|
||||||
|
}()
|
||||||
|
|
||||||
core.Run(ctx)
|
core.Run(ctx)
|
||||||
|
|
||||||
<-ctx.Done()
|
<-ctx.Done()
|
||||||
@@ -43,10 +51,10 @@ func Run(cmd *cobra.Command, _ []string) {
|
|||||||
cleanCache()
|
cleanCache()
|
||||||
}
|
}
|
||||||
|
|
||||||
func initAll(ctx context.Context) {
|
func initAll(ctx context.Context) (<-chan struct{}, error) {
|
||||||
if err := config.Init(ctx); err != nil {
|
if err := config.Init(ctx); err != nil {
|
||||||
fmt.Println("Failed to load config:", err)
|
fmt.Println("Failed to load config:", err)
|
||||||
os.Exit(1)
|
return nil, err
|
||||||
}
|
}
|
||||||
cache.Init()
|
cache.Init()
|
||||||
logger := log.FromContext(ctx)
|
logger := log.FromContext(ctx)
|
||||||
@@ -69,7 +77,7 @@ func initAll(ctx context.Context) {
|
|||||||
logger.Fatalf("User client login failed: %s", err)
|
logger.Fatalf("User client login failed: %s", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
bot.Init(ctx)
|
return bot.Init(ctx), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func cleanCache() {
|
func cleanCache() {
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ var upgradeCmd = &cobra.Command{
|
|||||||
Short: "Upgrade saveany-bot to the latest version",
|
Short: "Upgrade saveany-bot to the latest version",
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
v := semver.MustParse(config.Version)
|
v := semver.MustParse(config.Version)
|
||||||
latest, err := selfupdate.UpdateSelf(v, "krau/SaveAny-Bot")
|
latest, err := selfupdate.UpdateSelf(v, config.GitRepo)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Println("Binary update failed:", err)
|
fmt.Println("Binary update failed:", err)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package fsutil
|
|||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
|
||||||
"github.com/gabriel-vasile/mimetype"
|
"github.com/gabriel-vasile/mimetype"
|
||||||
)
|
)
|
||||||
@@ -55,3 +57,21 @@ func CreateFile(fp string) (*File, error) {
|
|||||||
}
|
}
|
||||||
return &File{File: file}, nil
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
|
||||||
"github.com/celestix/gotgproto/ext"
|
"github.com/celestix/gotgproto/ext"
|
||||||
"github.com/duke-git/lancet/v2/maputil"
|
"github.com/duke-git/lancet/v2/maputil"
|
||||||
@@ -61,16 +62,12 @@ func GenFileNameFromMessage(message tg.Message) string {
|
|||||||
return fmt.Sprintf("%s_%s", tagStr, strconv.Itoa(message.GetID()))
|
return fmt.Sprintf("%s_%s", tagStr, strconv.Itoa(message.GetID()))
|
||||||
}
|
}
|
||||||
text = lcstrutil.Substring(strings.Map(func(r rune) rune {
|
text = lcstrutil.Substring(strings.Map(func(r rune) rune {
|
||||||
if r < 0x20 || r == 0x7F {
|
|
||||||
return '_'
|
|
||||||
}
|
|
||||||
switch r {
|
switch r {
|
||||||
// invalid characters
|
|
||||||
case '/', '\\',
|
case '/', '\\',
|
||||||
':', '*', '?', '"', '<', '>', '|':
|
':', '*', '?', '"', '<', '>', '|':
|
||||||
return '_'
|
return '_'
|
||||||
// empty
|
}
|
||||||
case ' ', '\t', '\r', '\n':
|
if unicode.IsControl(r) || unicode.IsSpace(r) {
|
||||||
return '_'
|
return '_'
|
||||||
}
|
}
|
||||||
if validator.IsPrintable(string(r)) {
|
if validator.IsPrintable(string(r)) {
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ func ParseMessageLink(ctx *ext.Context, link string) (int64, int, error) {
|
|||||||
return chatID, msgID, nil
|
return chatID, msgID, nil
|
||||||
case 3:
|
case 3:
|
||||||
// https://t.me/c/123456789/123
|
// 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]
|
chatPart, msgPart := paths[1], paths[2]
|
||||||
if paths[0] != "c" {
|
if paths[0] != "c" {
|
||||||
chatPart = paths[0]
|
chatPart = paths[0]
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
type TelegramStorageConfig struct {
|
type TelegramStorageConfig struct {
|
||||||
BaseConfig
|
BaseConfig
|
||||||
ChatID int64 `toml:"chat_id" mapstructure:"chat_id" json:"chat_id"`
|
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"`
|
RateLimit int `toml:"rate_limit" mapstructure:"rate_limit" json:"rate_limit"`
|
||||||
RateBurst int `toml:"rate_burst" mapstructure:"rate_burst" json:"rate_burst"`
|
RateBurst int `toml:"rate_burst" mapstructure:"rate_burst" json:"rate_burst"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,3 +7,7 @@ var (
|
|||||||
BuildTime string = "unknown"
|
BuildTime string = "unknown"
|
||||||
GitCommit string = "unknown"
|
GitCommit string = "unknown"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
GitRepo = "krau/SaveAny-Bot"
|
||||||
|
)
|
||||||
@@ -4,7 +4,6 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/duke-git/lancet/v2/slice"
|
"github.com/duke-git/lancet/v2/slice"
|
||||||
@@ -96,12 +95,12 @@ func Init(ctx context.Context) error {
|
|||||||
|
|
||||||
if err := viper.ReadInConfig(); err != nil {
|
if err := viper.ReadInConfig(); err != nil {
|
||||||
fmt.Println("Error reading config file, ", err)
|
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)
|
fmt.Println("Error unmarshalling config file, ", err)
|
||||||
os.Exit(1)
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
storagesConfig, err := storage.LoadStorageConfigs(viper.GetViper())
|
storagesConfig, err := storage.LoadStorageConfigs(viper.GetViper())
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
|
|
||||||
|
"github.com/krau/SaveAny-Bot/common/utils/netutil"
|
||||||
"github.com/krau/SaveAny-Bot/config"
|
"github.com/krau/SaveAny-Bot/config"
|
||||||
"github.com/krau/SaveAny-Bot/pkg/enums/tasktype"
|
"github.com/krau/SaveAny-Bot/pkg/enums/tasktype"
|
||||||
"github.com/krau/SaveAny-Bot/pkg/parser"
|
"github.com/krau/SaveAny-Bot/pkg/parser"
|
||||||
@@ -47,12 +48,7 @@ func NewTask(
|
|||||||
item *parser.Item,
|
item *parser.Item,
|
||||||
progressTracker ProgressTracker,
|
progressTracker ProgressTracker,
|
||||||
) *Task {
|
) *Task {
|
||||||
client := &http.Client{
|
client := netutil.DefaultParserHTTPClient()
|
||||||
Transport: &http.Transport{
|
|
||||||
// [TODO] configure it via config
|
|
||||||
Proxy: http.ProxyFromEnvironment,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
_, ok := stor.(storage.StorageCannotStream)
|
_, ok := stor.(storage.StorageCannotStream)
|
||||||
stream := config.C().Stream && !ok
|
stream := config.C().Stream && !ok
|
||||||
return &Task{
|
return &Task{
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ After=systemd-user-sessions.service
|
|||||||
Type=simple
|
Type=simple
|
||||||
WorkingDirectory=/yourpath/
|
WorkingDirectory=/yourpath/
|
||||||
ExecStart=/yourpath/saveany-bot
|
ExecStart=/yourpath/saveany-bot
|
||||||
Restart=on-failure
|
Restart=always
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
WantedBy=multi-user.target
|
WantedBy=multi-user.target
|
||||||
|
|||||||
@@ -29,6 +29,6 @@ weight: 20
|
|||||||
1. 在 `parsers` 目录下新建一个包, 编写解析器实现
|
1. 在 `parsers` 目录下新建一个包, 编写解析器实现
|
||||||
2. 在 `parsers/parser.go` 的 `init` 中注册解析器
|
2. 在 `parsers/parser.go` 的 `init` 中注册解析器
|
||||||
|
|
||||||
如果使用 JavaScript 编写, 请参考 `plugins/example_parser.js` 的实现, 并在该文件夹下新建一个 js 文件, 实现你的解析逻辑.
|
如果使用 JavaScript 编写, 请参考 `plugins/example_parser_basic.js` 的实现, 并在该文件夹下新建一个 js 文件, 实现你的解析逻辑.
|
||||||
|
|
||||||
需要注意, `plugins` 目录下解析器默认不会被编译到二进制文件中, 用户需要手动下载它们并放到本地指定目录下以启用它们.
|
需要注意, `plugins` 目录下解析器默认不会被编译到二进制文件中, 用户需要手动下载它们并放到本地指定目录下以启用它们.
|
||||||
@@ -61,7 +61,9 @@ Stream 模式对于磁盘空间有限的部署环境十分有用, 但也有一
|
|||||||
{{< hint warning >}}
|
{{< hint warning >}}
|
||||||
启用 userbot 集成后, bot 可以下载私密频道和群组的文件, 但具有无法避免的账号被封禁的风险.
|
启用 userbot 集成后, bot 可以下载私密频道和群组的文件, 但具有无法避免的账号被封禁的风险.
|
||||||
<br />
|
<br />
|
||||||
开启 userbot 集成后第一次启动 bot 时需要通过终端交互输入手机号, 2FA 和验证码, 如果你使用 docker 部署, 请进入容器内执行相关操作.
|
开启 userbot 集成后第一次启动 bot 时需要通过终端交互输入手机号, 2FA 和验证码.
|
||||||
|
<br />
|
||||||
|
如果你使用 docker 部署, 请进入容器内执行相关操作.
|
||||||
{{< /hint >}}
|
{{< /hint >}}
|
||||||
|
|
||||||
```toml
|
```toml
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ title: "安装与更新"
|
|||||||
|
|
||||||
# 安装与更新
|
# 安装与更新
|
||||||
|
|
||||||
## 从预编译文件部署
|
## 从预编译文件部署(推荐)
|
||||||
|
|
||||||
在 [Release](https://github.com/krau/SaveAny-Bot/releases) 页面下载对应平台的二进制文件.
|
在 [Release](https://github.com/krau/SaveAny-Bot/releases) 页面下载对应平台的二进制文件.
|
||||||
|
|
||||||
@@ -33,7 +33,7 @@ After=systemd-user-sessions.service
|
|||||||
Type=simple
|
Type=simple
|
||||||
WorkingDirectory=/yourpath/
|
WorkingDirectory=/yourpath/
|
||||||
ExecStart=/yourpath/saveany-bot
|
ExecStart=/yourpath/saveany-bot
|
||||||
Restart=on-failure
|
Restart=always
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
WantedBy=multi-user.target
|
WantedBy=multi-user.target
|
||||||
@@ -131,13 +131,13 @@ docker run -d --name saveany-bot \
|
|||||||
|
|
||||||
## 更新
|
## 更新
|
||||||
|
|
||||||
使用 `upgrade` 或 `up` 升级到最新版
|
向 Bot 发送 `/update` 指令检查更新并升级, 或者使用 CLI 命令更新:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./saveany-bot upgrade
|
./saveany-bot up
|
||||||
```
|
```
|
||||||
|
|
||||||
如果是 Docker 部署, 使用以下命令更新:
|
如果是 Docker 部署, 还可以使用以下命令更新:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker pull ghcr.io/krau/saveany-bot:latest
|
docker pull ghcr.io/krau/saveany-bot:latest
|
||||||
|
|||||||
@@ -120,4 +120,5 @@ IS-ALBUM true MyWebdav NEW-FOR-ALBUM
|
|||||||
|
|
||||||
只需向 Bot 发送符合解析器要求的链接即可使用, 当前内置的解析器:
|
只需向 Bot 发送符合解析器要求的链接即可使用, 当前内置的解析器:
|
||||||
|
|
||||||
- Twitter
|
- Twitter
|
||||||
|
- Kemono
|
||||||
122
parsers/js.go
122
parsers/js.go
@@ -4,12 +4,9 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
"github.com/blang/semver"
|
|
||||||
"github.com/charmbracelet/log"
|
"github.com/charmbracelet/log"
|
||||||
"github.com/dop251/goja"
|
"github.com/dop251/goja"
|
||||||
"github.com/krau/SaveAny-Bot/pkg/parser"
|
"github.com/krau/SaveAny-Bot/pkg/parser"
|
||||||
@@ -101,50 +98,6 @@ func newJSParser(vm *goja.Runtime, canHandleFunc, parseFunc goja.Value, metadata
|
|||||||
return p
|
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 {
|
func LoadPlugins(ctx context.Context, dir string) error {
|
||||||
entries, err := os.ReadDir(dir)
|
entries, err := os.ReadDir(dir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -162,80 +115,13 @@ func LoadPlugins(ctx context.Context, dir string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
vm := goja.New()
|
vm := goja.New()
|
||||||
vm.Set("registerParser", registerParser(vm))
|
vm.Set("registerParser", jsRegisterParser(vm))
|
||||||
// Inject some utils to vm
|
// Inject some utils to vm
|
||||||
logger := log.FromContext(ctx).WithPrefix(fmt.Sprintf("[plugin|parser]/%s", e.Name()))
|
logger := log.FromContext(ctx).WithPrefix(fmt.Sprintf("[plugin|parser]/%s", e.Name()))
|
||||||
vm.Set("console", map[string]any{
|
vm.Set("console", jsConsole(logger))
|
||||||
"log": func(args ...any) {
|
|
||||||
if len(args) == 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if len(args) > 1 {
|
|
||||||
logger.Info(args[0], args[1:]...)
|
|
||||||
} else {
|
|
||||||
logger.Info(args[0])
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
// http fetch funcs
|
// http fetch funcs
|
||||||
ghttp := vm.NewObject()
|
vm.Set("ghttp", jsGhttp(vm))
|
||||||
ghttp.Set("get", func(call goja.FunctionCall) goja.Value {
|
|
||||||
url := call.Argument(0).String()
|
|
||||||
resp, err := http.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 := http.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,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
vm.Set("ghttp", ghttp)
|
|
||||||
|
|
||||||
if _, err := vm.RunString(string(code)); err != nil {
|
if _, err := vm.RunString(string(code)); err != nil {
|
||||||
return fmt.Errorf("error loading plugin %s: %w", e.Name(), err)
|
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
|
||||||
|
}
|
||||||
@@ -22,10 +22,8 @@ var (
|
|||||||
for _, pser := range parsers {
|
for _, pser := range parsers {
|
||||||
if configurable, ok := pser.(parser.ConfigurableParser); ok {
|
if configurable, ok := pser.(parser.ConfigurableParser); ok {
|
||||||
cfg := config.C().GetParserConfigByName(configurable.Name())
|
cfg := config.C().GetParserConfigByName(configurable.Name())
|
||||||
if cfg != nil {
|
if err := configurable.Configure(cfg); err != nil {
|
||||||
if err := configurable.Configure(cfg); err != nil {
|
fmt.Printf("Error configuring parser %s: %v\n", configurable.Name(), err)
|
||||||
fmt.Printf("Error configuring parser %s: %v\n", configurable.Name(), err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -101,6 +101,11 @@ func (p *TwitterParser) Name() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (p *TwitterParser) Configure(config map[string]any) error {
|
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 != "" {
|
if domain, ok := config["api_domain"].(string); ok && domain != "" {
|
||||||
p.apiDomain = domain
|
p.apiDomain = domain
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -7,4 +7,5 @@ import (
|
|||||||
const (
|
const (
|
||||||
MaxPartSize = 1024 * 1024
|
MaxPartSize = 1024 * 1024
|
||||||
MaxUploadPartSize = uploader.MaximumPartSize
|
MaxUploadPartSize = uploader.MaximumPartSize
|
||||||
|
MaxPhotoSize = 10 * 1024 * 1024
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,12 +5,14 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"path"
|
"path"
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"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/gabriel-vasile/mimetype"
|
||||||
|
"github.com/gotd/td/constant"
|
||||||
"github.com/gotd/td/telegram/message"
|
"github.com/gotd/td/telegram/message"
|
||||||
"github.com/gotd/td/telegram/message/styling"
|
"github.com/gotd/td/telegram/message/styling"
|
||||||
"github.com/gotd/td/telegram/uploader"
|
"github.com/gotd/td/telegram/uploader"
|
||||||
@@ -75,26 +77,46 @@ func (t *Telegram) Save(ctx context.Context, r io.Reader, storagePath string) er
|
|||||||
if tctx == nil {
|
if tctx == nil {
|
||||||
return fmt.Errorf("failed to get telegram context")
|
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
|
chatID := t.config.ChatID
|
||||||
if after, ok0 := strings.CutPrefix(convertor.ToString(chatID), "-100"); ok0 {
|
if len(parts) >= 1 {
|
||||||
cid, err := strconv.ParseInt(after, 10, 64)
|
filename = parts[len(parts)-1]
|
||||||
|
}
|
||||||
|
if len(parts) >= 2 && validator.IsAlphaNumeric(parts[0]) {
|
||||||
|
cid, err := tgutil.ParseChatID(tctx, parts[0])
|
||||||
if err != nil {
|
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
|
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)
|
mtype, err := mimetype.DetectReader(rs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to detect mimetype: %w", err)
|
return fmt.Errorf("failed to detect mimetype: %w", err)
|
||||||
}
|
}
|
||||||
filename := path.Base(storagePath)
|
|
||||||
if filename == "" {
|
if filename == "" {
|
||||||
filename = xid.New().String() + mtype.Extension()
|
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 {
|
if _, err := rs.Seek(0, io.SeekStart); err != nil {
|
||||||
return fmt.Errorf("failed to seek reader: %w", err)
|
return fmt.Errorf("failed to seek reader: %w", err)
|
||||||
}
|
}
|
||||||
@@ -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)
|
return fmt.Errorf("failed to upload file to telegram: %w", err)
|
||||||
}
|
}
|
||||||
caption := styling.Plain(filename)
|
caption := styling.Plain(filename)
|
||||||
|
forceFile := t.config.ForceFile
|
||||||
|
if strings.HasPrefix(mtype.String(), "image/") && size >= tglimit.MaxPhotoSize {
|
||||||
|
forceFile = true
|
||||||
|
}
|
||||||
docb := message.UploadedDocument(file, caption).
|
docb := message.UploadedDocument(file, caption).
|
||||||
Filename(filename).
|
Filename(filename).
|
||||||
ForceFile(false).
|
ForceFile(forceFile).
|
||||||
MIME(mtype.String())
|
MIME(mtype.String())
|
||||||
|
|
||||||
var media message.MediaOption = docb
|
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"):
|
case strings.HasPrefix(mtypeStr, "image/") && !strings.HasSuffix(mtypeStr, "webp"):
|
||||||
media = message.UploadedPhoto(file, caption)
|
media = message.UploadedPhoto(file, caption)
|
||||||
}
|
}
|
||||||
|
|
||||||
sender := tctx.Sender
|
sender := tctx.Sender
|
||||||
_, err = sender.WithUploader(upler).To(peer).Media(ctx, media)
|
_, err = sender.WithUploader(upler).To(peer).Media(ctx, media)
|
||||||
return err
|
return err
|
||||||
|
|||||||
Reference in New Issue
Block a user