Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e5d1e143e0 | ||
|
|
03eb4f8a18 | ||
|
|
231eb61d25 | ||
|
|
fd1b586b8d | ||
|
|
d035a3409e | ||
|
|
6112f6c240 | ||
|
|
18eedf2edb | ||
|
|
5f9bba9ff7 | ||
|
|
0d3d2209be |
70
README.md
70
README.md
@@ -4,28 +4,70 @@
|
||||
|
||||
**简体中文** | [English](https://sabot.unv.app/en/)
|
||||
|
||||
把 Telegram 上的文件转存到多种存储端.
|
||||
> **把 Telegram 上的文件转存到多种存储端.**
|
||||
|
||||
[](https://github.com/krau/saveany-bot/releases)
|
||||
[](https://github.com/krau/saveany-bot/releases)
|
||||
[](https://github.com/krau/saveany-bot/actions/workflows/build-release.yml)
|
||||
[](https://github.com/krau/saveany-bot/stargazers)
|
||||
[](https://github.com/krau/saveany-bot/releases)
|
||||
[](https://github.com/krau/saveany-bot/issues)
|
||||
[](https://github.com/krau/saveany-bot/pulls)
|
||||
[](./LICENSE)
|
||||
|
||||
</div>
|
||||
|
||||
## 部署
|
||||
## 🎯 Features
|
||||
|
||||
请参考 [部署文档](https://sabot.unv.app/deployment/installation/)
|
||||
|
||||
## Features
|
||||
|
||||
- 支持文档/视频/图片/贴纸… 甚至还有 Telegraph
|
||||
- 支持文档/视频/图片/贴纸…甚至还有 [Telegraph](https://telegra.ph/)
|
||||
- 破解禁止保存的文件
|
||||
- 批量下载
|
||||
- 流式传输
|
||||
- 多用户
|
||||
- 多用户使用
|
||||
- 基于存储规则的自动整理
|
||||
- 支持多种存储端:
|
||||
- 监听并自动转存指定聊天的消息, 支持过滤
|
||||
- 使用 js 编写解析器插件以转存任意网站的文件
|
||||
- 存储端支持:
|
||||
- Alist
|
||||
- Minio (S3 兼容)
|
||||
- S3 (MinioSDK)
|
||||
- WebDAV
|
||||
- Telegram (重传回指定聊天)
|
||||
- 本地磁盘
|
||||
- Telegram (重传回指定聊天)
|
||||
|
||||
## 📦 Quick Start
|
||||
|
||||
创建文件 `config.toml` 并填入以下内容:
|
||||
|
||||
```toml
|
||||
[telegram]
|
||||
token = "" # 你的 Bot Token, 在 @BotFather 获取
|
||||
[telegram.proxy]
|
||||
# 启用代理连接 telegram, 当前只支持 socks5
|
||||
enable = false
|
||||
url = "socks5://127.0.0.1:7890"
|
||||
|
||||
[[storages]]
|
||||
name = "本地磁盘"
|
||||
type = "local"
|
||||
enable = true
|
||||
base_path = "./downloads"
|
||||
|
||||
[[users]]
|
||||
id = 114514 # 你的 Telegram 账号 id
|
||||
storages = []
|
||||
blacklist = true
|
||||
```
|
||||
|
||||
使用 Docker 运行 Save Any Bot:
|
||||
|
||||
```bash
|
||||
docker run -d --name saveany-bot \
|
||||
-v ./config.toml:/app/config.toml \
|
||||
-v ./downloads:/app/downloads \
|
||||
ghcr.io/krau/saveany-bot:latest
|
||||
```
|
||||
|
||||
请 [**查看文档**](https://sabot.unv.app/) 以获取更多配置选项和使用方法.
|
||||
|
||||
## Sponsors
|
||||
|
||||
@@ -88,3 +130,9 @@
|
||||
- [gotgproto](https://github.com/celestix/gotgproto)
|
||||
- [tdl](https://github.com/iyear/tdl)
|
||||
- All the dependencies
|
||||
|
||||
## Contact
|
||||
|
||||
- [](https://t.me/ProjectSaveAny)
|
||||
- [](https://github.com/krau/saveany-bot/discussions)
|
||||
- [](https://t.me/acherkrau)
|
||||
@@ -27,8 +27,8 @@ func Init(ctx context.Context) {
|
||||
})
|
||||
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,16 +43,16 @@ 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)
|
||||
@@ -79,7 +79,7 @@ func Init(ctx context.Context) {
|
||||
{Command: "dir", Description: "管理存储文件夹"},
|
||||
{Command: "rule", Description: "管理规则"},
|
||||
}
|
||||
if config.Cfg.Telegram.Userbot.Enable {
|
||||
if config.C().Telegram.Userbot.Enable {
|
||||
commands = append(commands, tg.BotCommand{Command: "watch", Description: "监听聊天"})
|
||||
commands = append(commands, tg.BotCommand{Command: "unwatch", Description: "取消监听聊天"})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -56,7 +56,7 @@ func Register(disp dispatcher.Dispatcher) {
|
||||
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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package msgelem
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/duke-git/lancet/v2/strutil"
|
||||
"github.com/gotd/td/telegram/message/entity"
|
||||
"github.com/gotd/td/telegram/message/styling"
|
||||
"github.com/gotd/td/tg"
|
||||
@@ -18,7 +19,7 @@ func BuildParsedTextEntity(item parser.Item) (string, []tg.MessageEntityClass, e
|
||||
styling.Plain("\n作者: "),
|
||||
styling.Code(item.Author),
|
||||
styling.Plain("\n描述: "),
|
||||
styling.Code(item.Description),
|
||||
styling.Code(strutil.Ellipsis(item.Description, 233)),
|
||||
styling.Plain("\n文件数量: "),
|
||||
styling.Code(fmt.Sprintf("%d", len(item.Resources))),
|
||||
styling.Plain("\n预计总大小: "),
|
||||
|
||||
@@ -102,7 +102,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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
18
cmd/run.go
18
cmd/run.go
@@ -50,12 +50,12 @@ func initAll(ctx context.Context) {
|
||||
}
|
||||
cache.Init()
|
||||
logger := log.FromContext(ctx)
|
||||
i18n.Init(config.Cfg.Lang)
|
||||
i18n.Init(config.C().Lang)
|
||||
logger.Info(i18n.T(i18nk.Initing))
|
||||
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)
|
||||
} else {
|
||||
@@ -63,7 +63,7 @@ func initAll(ctx context.Context) {
|
||||
}
|
||||
}
|
||||
}
|
||||
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)
|
||||
@@ -73,13 +73,13 @@ func initAll(ctx context.Context) {
|
||||
}
|
||||
|
||||
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)) {
|
||||
if config.C().Temp.BasePath != "" && !config.C().Stream {
|
||||
if slices.Contains([]string{"/", ".", "\\", ".."}, filepath.Clean(config.C().Temp.BasePath)) {
|
||||
log.Error(i18n.T(i18nk.InvalidCacheDir, map[string]any{
|
||||
"Path": config.Cfg.Temp.BasePath,
|
||||
"Path": config.C().Temp.BasePath,
|
||||
}))
|
||||
return
|
||||
}
|
||||
@@ -90,7 +90,7 @@ func cleanCache() {
|
||||
}))
|
||||
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{
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
package netutil
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"golang.org/x/net/proxy"
|
||||
@@ -13,3 +17,38 @@ func NewProxyDialer(proxyUrl string) (proxy.Dialer, error) {
|
||||
}
|
||||
return proxy.FromURL(url, proxy.Direct)
|
||||
}
|
||||
|
||||
func NewProxyHTTPClient(proxyUrl string) (*http.Client, error) {
|
||||
if proxyUrl == "" {
|
||||
return http.DefaultClient, 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.SOCKS5("tcp", u.Host, nil, 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -3,4 +3,13 @@ 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"`
|
||||
|
||||
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]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -32,7 +32,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 {
|
||||
@@ -95,7 +99,7 @@ func Init(ctx context.Context) error {
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if err := viper.Unmarshal(Cfg); err != nil {
|
||||
if err := viper.Unmarshal(cfg); err != nil {
|
||||
fmt.Println("Error unmarshalling config file, ", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
@@ -104,36 +108,36 @@ func Init(ctx context.Context) error {
|
||||
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.ConfigInvalidDuplicateStorageName, 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.LoadedStorages, 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.Retry < 1 {
|
||||
return errors.New(i18n.TWithoutInit(cfg.Lang, i18nk.ConfigInvalidWorkersOrRetry, map[string]any{
|
||||
"Workers": cfg.Workers,
|
||||
"Retry": cfg.Retry,
|
||||
}))
|
||||
}
|
||||
|
||||
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 +147,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()
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@ func NewTask(
|
||||
},
|
||||
}
|
||||
_, 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{}{}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,20 +11,22 @@ title: 介绍
|
||||
|
||||
把 Telegram 上的文件转存到多种存储端.
|
||||
|
||||
## 特性
|
||||
## 🎯 特性
|
||||
|
||||
- 支持文档/视频/图片/贴纸... 甚至还有 Telegraph
|
||||
- 支持文档/视频/图片/贴纸…甚至还有 [Telegraph](https://telegra.ph/)
|
||||
- 破解禁止保存的文件
|
||||
- 批量下载
|
||||
- 流式传输
|
||||
- 多用户
|
||||
- 多用户使用
|
||||
- 基于存储规则的自动整理
|
||||
- 支持多种存储端:
|
||||
- Alist
|
||||
- Minio (S3 兼容)
|
||||
- WebDAV
|
||||
- Telegram (重传回指定聊天)
|
||||
- 本地磁盘
|
||||
- 监听并自动转存指定聊天的消息, 支持过滤
|
||||
- 使用 js 编写解析器插件以转存任意网站的文件
|
||||
- 存储端支持:
|
||||
- Alist
|
||||
- S3 (MinioSDK)
|
||||
- WebDAV
|
||||
- 本地磁盘
|
||||
- Telegram (重传回指定聊天)
|
||||
|
||||
## [贡献者](https://github.com/krau/SaveAny-Bot/graphs/contributors)
|
||||
|
||||
|
||||
@@ -5,10 +5,30 @@ weight: 20
|
||||
|
||||
# 参与开发
|
||||
|
||||
在开始之前, 请 Fork 本项目, 并克隆到本地, 并确保 Go 版本 >= 1.23.
|
||||
|
||||
以下是一些贡献代码的指南或建议, 你不必完全遵守, 但将有助于快速 review 并合并你的提交:
|
||||
|
||||
- **新功能请先提交 Issue**, 以便讨论设计和实现细节, 并避免因与项目设计不符而被拒绝.
|
||||
- **使用现代开发工具**, 确保提交前格式化代码, 并保持风格一致.
|
||||
- **使用[语义化提交](https://www.conventionalcommits.org/zh-hans/v1.0.0/)**, 避免提交消息模糊或过于简单.
|
||||
|
||||
## 贡献新存储端
|
||||
|
||||
1. Fork 本项目, 克隆到本地
|
||||
2. 在 `pkg/enums/storage/storages.go` 中添加新的存储端类型, 并运行代码生成
|
||||
3. 在 `config/storage` 目录下定义存储端配置, 并添加到 `config/storage/factory.go` 中
|
||||
4. 在 `storage` 目录下新建一个包, 编写存储端实现, 然后在 `storage/storage.go` 中导入并添加它
|
||||
5. 更新文档, 添加配置说明
|
||||
1. 在 `pkg/enums/storage/storages.go` 中添加新的存储端类型, 并运行代码生成
|
||||
2. 在 `config/storage` 目录下定义存储端配置, 并添加到 `config/storage/factory.go` 中
|
||||
3. 在 `storage` 目录下新建一个包, 编写存储端实现, 然后在 `storage/storage.go` 中导入并添加它
|
||||
4. 更新文档, 添加配置说明
|
||||
|
||||
## 贡献新解析器
|
||||
|
||||
你可以选择使用 Go 编写原生的解析器实现(推荐), 或是使用 JavaScript 以插件的方式实现.
|
||||
|
||||
如果使用 Go 编写, 请:
|
||||
|
||||
1. 在 `parsers` 目录下新建一个包, 编写解析器实现
|
||||
2. 在 `parsers/parser.go` 的 `init` 中注册解析器
|
||||
|
||||
如果使用 JavaScript 编写, 请参考 `plugins/example_parser.js` 的实现, 并在该文件夹下新建一个 js 文件, 实现你的解析逻辑.
|
||||
|
||||
需要注意, `plugins` 目录下解析器默认不会被编译到二进制文件中, 用户需要手动下载它们并放到本地指定目录下以启用它们.
|
||||
@@ -164,6 +164,18 @@ task_fail = "curl -X POST https://example.com/api/notify -d '任务失败'"
|
||||
task_cancel = "bash /path/to/cancel_script.sh"
|
||||
```
|
||||
|
||||
### 解析器
|
||||
|
||||
解析器为 Bot 提供了处理非 Telegram 文件的能力, 例如从其他网站下载文件. 使用 `[parsers]` 配置.
|
||||
|
||||
```toml
|
||||
[parsers]
|
||||
plugin_enable = true # 是否启用解析器插件
|
||||
plugin_dirs = ["./plugins"] # 插件目录, 可以是多个目录
|
||||
```
|
||||
|
||||
上述两个配置项只用于控制以 JavaScript 编写的解析器插件, Bot 还有内置的使用 Go 实现的解析器, 目前默认开启.
|
||||
|
||||
### 杂项
|
||||
|
||||
```toml
|
||||
|
||||
@@ -9,12 +9,11 @@ weight: 10
|
||||
|
||||
## 转存文件
|
||||
|
||||
Bot 接受两种消息: 文件和链接.
|
||||
要使用 Bot 的转存 Telegram 文件功能, 需要向 Bot 发送或转发以下类型的消息.
|
||||
|
||||
对于链接, 目前支持以下类型的链接:
|
||||
|
||||
1. Telegram 消息链接, 例如: `https://t.me/acherkrau/1097`. **即使频道禁止了转发和保存, Bot 依然可以下载其文件.**
|
||||
2. Telegra.ph 的文章链接, Bot 将下载其中的所有图片
|
||||
1. 文件或媒体消息, 如图片, 视频, 文档等
|
||||
2. Telegram 消息链接, 例如: `https://t.me/acherkrau/1097`. **即使频道禁止了转发和保存, Bot 依然可以下载其文件.**
|
||||
3. Telegra.ph 的文章链接, Bot 将下载其中的所有图片
|
||||
|
||||
## 静默模式 (silent)
|
||||
|
||||
@@ -112,3 +111,13 @@ IS-ALBUM true MyWebdav NEW-FOR-ALBUM
|
||||
```
|
||||
|
||||
这将会监听 ID 为 12345678 的聊天, 并且只保存消息文本中包含 "hello" 的消息.
|
||||
|
||||
## 转存 Telegram 之外的文件
|
||||
|
||||
除了 Telegram 上的文件, Bot 还可通过 JavaScript 插件或内置解析器来支持转存其他网站的文件.
|
||||
|
||||
> 查看[贡献解析器](../contribute)文档了解详情
|
||||
|
||||
只需向 Bot 发送符合解析器要求的链接即可使用, 当前内置的解析器:
|
||||
|
||||
- Twitter
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
@@ -50,11 +52,15 @@ func (p *jsParser) CanHandle(url string) bool {
|
||||
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
|
||||
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 {
|
||||
@@ -168,13 +174,79 @@ func LoadPlugins(ctx context.Context, dir string) error {
|
||||
}
|
||||
|
||||
vm := goja.New()
|
||||
logger := log.FromContext(ctx).WithPrefix(fmt.Sprintf("[plugin|parser]/%s", e.Name()))
|
||||
vm.Set("registerParser", registerParser(vm))
|
||||
// Inject some utils to vm
|
||||
logger := log.FromContext(ctx).WithPrefix(fmt.Sprintf("[plugin|parser]/%s", e.Name()))
|
||||
vm.Set("console", map[string]any{
|
||||
"log": func(args ...any) {
|
||||
logger.Info(fmt.Sprint(args...))
|
||||
if len(args) == 0 {
|
||||
return
|
||||
}
|
||||
if len(args) > 1 {
|
||||
logger.Info(args[0], args[1:]...)
|
||||
} else {
|
||||
logger.Info(args[0])
|
||||
}
|
||||
},
|
||||
})
|
||||
// http fetch funcs
|
||||
ghttp := vm.NewObject()
|
||||
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 {
|
||||
return fmt.Errorf("error loading plugin %s: %w", e.Name(), err)
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"github.com/krau/SaveAny-Bot/config"
|
||||
"github.com/krau/SaveAny-Bot/parsers/twitter"
|
||||
"github.com/krau/SaveAny-Bot/pkg/parser"
|
||||
)
|
||||
@@ -12,18 +13,13 @@ import (
|
||||
var (
|
||||
parsers []parser.Parser
|
||||
parsersMu sync.Mutex
|
||||
doConfig sync.Once
|
||||
)
|
||||
|
||||
func GetParsers() []parser.Parser {
|
||||
func AddParser(p ...parser.Parser) {
|
||||
parsersMu.Lock()
|
||||
defer parsersMu.Unlock()
|
||||
return parsers
|
||||
}
|
||||
|
||||
func AddParser(p parser.Parser) {
|
||||
parsersMu.Lock()
|
||||
defer parsersMu.Unlock()
|
||||
parsers = append(parsers, p)
|
||||
parsers = append(parsers, p...)
|
||||
}
|
||||
|
||||
func init() {
|
||||
@@ -35,6 +31,23 @@ var (
|
||||
)
|
||||
|
||||
func ParseWithContext(ctx context.Context, url string) (*parser.Item, error) {
|
||||
doConfig.Do(func() {
|
||||
parsersMu.Lock()
|
||||
defer parsersMu.Unlock()
|
||||
if len(parsers) == 0 {
|
||||
return
|
||||
}
|
||||
for _, pser := range parsers {
|
||||
if configurable, ok := pser.(parser.ConfigurableParser); ok {
|
||||
cfg := config.C().GetParserConfigByName(configurable.Name())
|
||||
if cfg != nil {
|
||||
if err := configurable.Configure(cfg); err != nil {
|
||||
fmt.Printf("Error configuring parser %s: %v\n", configurable.Name(), err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
ch := make(chan *parser.Item, 1)
|
||||
errCh := make(chan error, 1)
|
||||
|
||||
@@ -43,7 +56,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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -60,9 +67,16 @@ 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)
|
||||
if err == nil {
|
||||
size = resp.ContentLength
|
||||
resp.Body.Close()
|
||||
}
|
||||
resources = append(resources, parser.Resource{
|
||||
URL: media.URL,
|
||||
Filename: path.Base(strings.Split(media.URL, "?")[0]),
|
||||
Size: size,
|
||||
})
|
||||
}
|
||||
item := &parser.Item{
|
||||
@@ -81,3 +95,23 @@ 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 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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,7 +100,7 @@ func (t *Telegram) Save(ctx context.Context, r io.Reader, storagePath string) er
|
||||
}
|
||||
upler := uploader.NewUploader(tctx.Raw).
|
||||
WithPartSize(tglimit.MaxUploadPartSize).
|
||||
WithThreads(config.Cfg.Threads)
|
||||
WithThreads(config.C().Threads)
|
||||
|
||||
var file tg.InputFileClass
|
||||
size := func() int64 {
|
||||
|
||||
Reference in New Issue
Block a user