Compare commits

...

37 Commits

Author SHA1 Message Date
Krau
f05dd883e3 feat: enhance URL handling by adding utility functions and filters for message entities (#105) 2025-09-09 20:16:56 +08:00
dependabot[bot]
9cb866de8c chore(deps): bump github.com/ulikunitz/xz from 0.5.12 to 0.5.14 (#102)
Bumps [github.com/ulikunitz/xz](https://github.com/ulikunitz/xz) from 0.5.12 to 0.5.14.
- [Commits](https://github.com/ulikunitz/xz/compare/v0.5.12...v0.5.14)

---
updated-dependencies:
- dependency-name: github.com/ulikunitz/xz
  dependency-version: 0.5.14
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-31 12:14:49 +08:00
krau
980455fd24 fix: remove go generate command from build process in Dockerfile and workflow 2025-08-27 11:17:03 +08:00
krau
24978470cd feat: add go generate command to build process and update go.mod dependencies 2025-08-27 11:12:42 +08:00
krau
215e082028 feat: implement internationalization support and update help commands 2025-08-27 11:09:38 +08:00
krau
a7b93e57fc refactor: js api 2025-08-24 22:49:44 +08:00
krau
a4b3b459a9 docs: change service restart policy to always 2025-08-24 14:47:13 +08:00
krau
06f326088a fix: remove redundant check for current version equality in update command 2025-08-24 14:37:01 +08:00
krau
b7d3ec6230 docs: update upgrade command 2025-08-24 14:26:47 +08:00
krau
f812990e1c feat: update help command to include usage and feedback group links 2025-08-24 14:18:43 +08:00
krau
492900bbef feat: add update command and callback for version checking and upgrading 2025-08-24 14:16:26 +08:00
krau
764be2a083 fix: improve error handling in config initialization 2025-08-24 14:16:23 +08:00
krau
46c21b77e9 feat: enhance Save method to validate chat ID and adjust ForceFile logic for image uploads 2025-08-24 12:02:27 +08:00
krau
8b389a58d5 fix: improve chat ID parsing in Save method and fallback to configured chat_id 2025-08-24 11:46:12 +08:00
krau
25ad9befa0 feat: add ForceFile option to TelegramStorageConfig and update Save method 2025-08-24 11:38:44 +08:00
krau
e824b210d1 feat: improve telegram storage chat ID parsing logic 2025-08-24 11:29:19 +08:00
krau
ae0aa7db3f fix: skip overwrite dirpath and storage when rule miss match 2025-08-24 11:28:37 +08:00
krau
226c15ef08 feat: add NormalizePathname function and update task handling for parsed items 2025-08-24 10:28:50 +08:00
krau
9b3f955e48 feat: use default HTTP client from netutil for task creation 2025-08-24 09:37:52 +08:00
krau
4997ec408f docs: update parsers 2025-08-23 20:42:51 +08:00
krau
0756cc9eb1 fix: improve parser configuration handling and default values 2025-08-23 20:40:13 +08:00
krau
37c32a23d4 feat: add Kemono parser with download info extraction and API handling 2025-08-23 20:18:02 +08:00
krau
3aa1e2eaed feat: enhance text message handling and parser configuration 2025-08-23 20:17:56 +08:00
krau
b87dd68880 feat: proxy client for parser 2025-08-23 20:17:24 +08:00
krau
68e5a51300 feat: file name staregy 2025-08-23 17:16:51 +08:00
krau
7300e54c40 refactor: rule package 2025-08-23 16:14:12 +08:00
krau
94f796d0e8 refactor: move version to config package 2025-08-23 16:10:02 +08:00
krau
c023fd869d feat: refactor jsParser to use ParserMethod constants and remove redundant locking in ParseWithContext 2025-08-23 16:04:32 +08:00
krau
e5d1e143e0 feat: configurable parser and refactor config 2025-08-23 14:29:32 +08:00
krau
03eb4f8a18 feat: update parser interface to include context in Parse method 2025-08-23 14:01:00 +08:00
krau
231eb61d25 docs: update parser plugin readme and add example danbooru parser 2025-08-23 12:35:04 +08:00
krau
fd1b586b8d feat: inject ghttp to js vm 2025-08-23 12:34:39 +08:00
krau
d035a3409e feat: support multiple parser additions and include media size in Twitter parser 2025-08-22 23:11:16 +08:00
krau
6112f6c240 feat: truncate item description to 233 characters in parsed text 2025-08-22 15:00:09 +08:00
krau
18eedf2edb docs: update parsers configuration and features 2025-08-22 10:02:44 +08:00
krau
5f9bba9ff7 docs: update contribute and parser help 2025-08-22 09:56:53 +08:00
krau
0d3d2209be docs: update readme 2025-08-22 09:28:24 +08:00
92 changed files with 2023 additions and 516 deletions

View File

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

View File

@@ -17,9 +17,9 @@ RUN --mount=type=cache,target=/root/.cache/go-build \
go build -trimpath \ go build -trimpath \
-ldflags=" \ -ldflags=" \
-s -w \ -s -w \
-X 'github.com/krau/SaveAny-Bot/common.Version=${VERSION}' \ -X 'github.com/krau/SaveAny-Bot/config.Version=${VERSION}' \
-X 'github.com/krau/SaveAny-Bot/common.GitCommit=${GitCommit}' \ -X 'github.com/krau/SaveAny-Bot/config.GitCommit=${GitCommit}' \
-X 'github.com/krau/SaveAny-Bot/common.BuildTime=${BuildTime}' \ -X 'github.com/krau/SaveAny-Bot/config.BuildTime=${BuildTime}' \
" \ " \
-o saveany-bot . -o saveany-bot .

View File

@@ -4,28 +4,70 @@
**简体中文** | [English](https://sabot.unv.app/en/) **简体中文** | [English](https://sabot.unv.app/en/)
把 Telegram 上的文件转存到多种存储端. > **把 Telegram 上的文件转存到多种存储端.**
[![Release Date](https://img.shields.io/github/release-date/krau/saveany-bot?label=release)](https://github.com/krau/saveany-bot/releases)
[![tag](https://img.shields.io/github/v/tag/krau/saveany-bot.svg)](https://github.com/krau/saveany-bot/releases)
[![Build Status](https://img.shields.io/github/actions/workflow/status/krau/saveany-bot/build-release.yml)](https://github.com/krau/saveany-bot/actions/workflows/build-release.yml)
[![Stars](https://img.shields.io/github/stars/krau/saveany-bot?style=flat)](https://github.com/krau/saveany-bot/stargazers)
[![Downloads](https://img.shields.io/github/downloads/krau/saveany-bot/total)](https://github.com/krau/saveany-bot/releases)
[![Issues](https://img.shields.io/github/issues/krau/saveany-bot)](https://github.com/krau/saveany-bot/issues)
[![Pull Requests](https://img.shields.io/github/issues-pr/krau/saveany-bot?label=pr)](https://github.com/krau/saveany-bot/pulls)
[![License](https://img.shields.io/github/license/krau/saveany-bot)](./LICENSE)
</div> </div>
## 部署 ## 🎯 Features
请参考 [部署文档](https://sabot.unv.app/deployment/installation/) - 支持文档/视频/图片/贴纸…甚至还有 [Telegraph](https://telegra.ph/)
## Features
- 支持文档/视频/图片/贴纸… 甚至还有 Telegraph
- 破解禁止保存的文件 - 破解禁止保存的文件
- 批量下载 - 批量下载
- 流式传输 - 流式传输
- 多用户 - 多用户使用
- 基于存储规则的自动整理 - 基于存储规则的自动整理
- 支持多种存储端: - 监听并自动转存指定聊天的消息, 支持过滤
- 使用 js 编写解析器插件以转存任意网站的文件
- 存储端支持:
- Alist - Alist
- Minio (S3 兼容) - S3 (MinioSDK)
- WebDAV - 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 ## Sponsors
@@ -88,3 +130,9 @@
- [gotgproto](https://github.com/celestix/gotgproto) - [gotgproto](https://github.com/celestix/gotgproto)
- [tdl](https://github.com/iyear/tdl) - [tdl](https://github.com/iyear/tdl)
- All the dependencies - All the dependencies
## Contact
- [![Group](https://img.shields.io/badge/ProjectSaveAny-Group-blue)](https://t.me/ProjectSaveAny)
- [![Discussion](https://img.shields.io/badge/Github-Discussion-white)](https://github.com/krau/saveany-bot/discussions)
- [![PersonalChannel](https://img.shields.io/badge/Krau-PersonalChannel-cyan)](https://t.me/acherkrau)

View File

@@ -19,16 +19,17 @@ 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.Cfg.Telegram.Proxy.Enable && config.Cfg.Telegram.Proxy.URL != "" { if config.C().Telegram.Proxy.Enable && config.C().Telegram.Proxy.URL != "" {
dialer, err := netutil.NewProxyDialer(config.Cfg.Telegram.Proxy.URL) dialer, err := netutil.NewProxyDialer(config.C().Telegram.Proxy.URL)
if err != nil { if err != nil {
resultChan <- struct { resultChan <- struct {
client *gotgproto.Client client *gotgproto.Client
@@ -43,19 +44,23 @@ func Init(ctx context.Context) {
resolver = dcs.DefaultResolver() resolver = dcs.DefaultResolver()
} }
client, err := gotgproto.NewClient( client, err := gotgproto.NewClient(
config.Cfg.Telegram.AppID, config.C().Telegram.AppID,
config.Cfg.Telegram.AppHash, config.C().Telegram.AppHash,
gotgproto.ClientTypeBot(config.Cfg.Telegram.Token), gotgproto.ClientTypeBot(config.C().Telegram.Token),
&gotgproto.ClientOpts{ &gotgproto.ClientOpts{
Session: sessionMaker.SqlSession(gormlite.Open(config.Cfg.DB.Session)), Session: sessionMaker.SqlSession(gormlite.Open(config.C().DB.Session)),
DisableCopyright: true, DisableCopyright: true,
Middlewares: middleware.NewDefaultMiddlewares(ctx, 5*time.Minute), Middlewares: middleware.NewDefaultMiddlewares(ctx, 5*time.Minute),
Resolver: resolver, Resolver: resolver,
Context: ctx, Context: ctx,
MaxRetries: config.Cfg.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
}, },
}, },
@@ -79,7 +84,7 @@ func Init(ctx context.Context) {
{Command: "dir", Description: "管理存储文件夹"}, {Command: "dir", Description: "管理存储文件夹"},
{Command: "rule", 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: "watch", Description: "监听聊天"})
commands = append(commands, tg.BotCommand{Command: "unwatch", Description: "取消监听聊天"}) commands = append(commands, tg.BotCommand{Command: "unwatch", Description: "取消监听聊天"})
} }
@@ -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
} }

View File

@@ -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)

View File

@@ -0,0 +1,103 @@
package handlers
import (
"fmt"
"strings"
"github.com/celestix/gotgproto/dispatcher"
"github.com/celestix/gotgproto/ext"
"github.com/gotd/td/tg"
"github.com/krau/SaveAny-Bot/database"
"github.com/krau/SaveAny-Bot/pkg/enums/fnamest"
"github.com/krau/SaveAny-Bot/pkg/tcbdata"
)
func handleConfigCmd(ctx *ext.Context, update *ext.Update) error {
ctx.Reply(update, ext.ReplyTextString("请选择要配置的选项"), &ext.ReplyOpts{
Markup: &tg.ReplyInlineMarkup{
Rows: []tg.KeyboardButtonRow{
{
Buttons: []tg.KeyboardButtonClass{
&tg.KeyboardButtonCallback{
Text: "文件名策略",
Data: fmt.Appendf(nil, "%s %s", tcbdata.TypeConfig, "fnamest"),
},
},
},
},
},
})
return dispatcher.EndGroups
}
func handleConfigCallback(ctx *ext.Context, update *ext.Update) error {
args := strings.Fields(string(update.CallbackQuery.Data))
invaildDataAnswer := func() error {
ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{
QueryID: update.CallbackQuery.GetQueryID(),
Alert: true,
Message: "无效的回调数据",
CacheTime: 5,
})
return dispatcher.EndGroups
}
if len(args) < 2 {
return invaildDataAnswer()
}
switch args[1] {
case "fnamest":
return handleConfigFnameSTCallback(ctx, update)
default:
return invaildDataAnswer()
}
}
func handleConfigFnameSTCallback(ctx *ext.Context, update *ext.Update) error {
userID := update.CallbackQuery.GetUserID()
user, err := database.GetUserByChatID(ctx, userID)
if err != nil {
return err
}
args := strings.Fields(string(update.CallbackQuery.Data))
if len(args) == 3 {
selected := args[2]
st, err := fnamest.ParseFnameST(selected)
if err != nil {
return err
}
user.FilenameStrategy = st.String()
if err := database.UpdateUser(ctx, user); err != nil {
return err
}
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
ID: update.CallbackQuery.GetMsgID(),
Message: fmt.Sprintf("已将文件名策略设置为: %s", fnamest.FnameSTDisplay[st]),
})
return dispatcher.EndGroups
}
opts := fnamest.FnameSTValues()
buttons := make([]tg.KeyboardButtonClass, 0, len(opts))
for _, opt := range opts {
buttons = append(buttons, &tg.KeyboardButtonCallback{
Text: fnamest.FnameSTDisplay[opt],
Data: fmt.Appendf(nil, "%s %s %s", tcbdata.TypeConfig, "fnamest", opt),
})
}
markup := &tg.ReplyInlineMarkup{Rows: []tg.KeyboardButtonRow{
{Buttons: buttons},
}}
currentStStr := user.FilenameStrategy
if currentStStr == "" {
currentStStr = fnamest.Default.String()
}
currentSt, err := fnamest.ParseFnameST(currentStStr)
if err != nil {
currentSt = fnamest.Default
}
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
ID: update.CallbackQuery.GetMsgID(),
Message: fmt.Sprintf("请选择文件名策略, 当前策略: %s", fnamest.FnameSTDisplay[currentSt]),
ReplyMarkup: markup,
})
return dispatcher.EndGroups
}

View File

@@ -5,29 +5,16 @@ import (
"github.com/celestix/gotgproto/dispatcher" "github.com/celestix/gotgproto/dispatcher"
"github.com/celestix/gotgproto/ext" "github.com/celestix/gotgproto/ext"
"github.com/krau/SaveAny-Bot/pkg/consts" "github.com/krau/SaveAny-Bot/common/i18n"
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
"github.com/krau/SaveAny-Bot/config"
) )
func handleHelpCmd(ctx *ext.Context, update *ext.Update) error { func handleHelpCmd(ctx *ext.Context, update *ext.Update) error {
const helpText string = ` shortHash := config.GitCommit
Save Any Bot - 转存你的 Telegram 文件
版本: %s , 提交: %s
命令:
/start - 开始使用
/help - 显示帮助
/silent - 开关静默模式
/storage - 设置默认存储位置
/save [自定义文件名] - 保存文件
/dir - 管理存储目录
/rule - 管理规则
使用帮助: https://sabot.unv.app/usage/
`
shortHash := consts.GitCommit
if len(shortHash) > 7 { if len(shortHash) > 7 {
shortHash = shortHash[:7] shortHash = shortHash[:7]
} }
ctx.Reply(update, ext.ReplyTextString(fmt.Sprintf(helpText, consts.Version, shortHash)), nil) ctx.Reply(update, ext.ReplyTextString(fmt.Sprintf(i18n.T(i18nk.BotMsgHelpTextFmt), config.Version, shortHash)), nil)
return dispatcher.EndGroups return dispatcher.EndGroups
} }

View File

@@ -13,6 +13,8 @@ import (
"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/tgutil" "github.com/krau/SaveAny-Bot/common/utils/tgutil"
"github.com/krau/SaveAny-Bot/database"
"github.com/krau/SaveAny-Bot/pkg/enums/fnamest"
"github.com/krau/SaveAny-Bot/pkg/tcbdata" "github.com/krau/SaveAny-Bot/pkg/tcbdata"
"github.com/krau/SaveAny-Bot/pkg/tfile" "github.com/krau/SaveAny-Bot/pkg/tfile"
"github.com/krau/SaveAny-Bot/storage" "github.com/krau/SaveAny-Bot/storage"
@@ -26,12 +28,22 @@ func handleMediaMessage(ctx *ext.Context, update *ext.Update) error {
return handleGroupMediaMessage(ctx, update, message, groupID) return handleGroupMediaMessage(ctx, update, message, groupID)
} }
logger.Debugf("Got media: %s", message.Media.TypeName()) logger.Debugf("Got media: %s", message.Media.TypeName())
userId := update.GetUserChat().GetID()
msg, file, err := shortcut.GetFileFromMessageWithReply(ctx, update, message) userDB, err := database.GetUserByChatID(ctx, userId)
if err != nil { if err != nil {
return err return err
} }
userId := update.GetUserChat().GetID() tfOpts := make([]tfile.TGFileOption, 0)
switch userDB.FilenameStrategy {
case fnamest.Message.String():
tfOpts = append(tfOpts, tfile.WithName(tgutil.GenFileNameFromMessage(*message)))
default:
}
msg, file, err := shortcut.GetFileFromMessageWithReply(ctx, update, message, tfOpts...)
if err != nil {
return err
}
stors := storage.GetUserStorages(ctx, userId) stors := storage.GetUserStorages(ctx, userId)
req, err := msgelem.BuildAddOneSelectStorageMessage(ctx, stors, file, msg.ID) req, err := msgelem.BuildAddOneSelectStorageMessage(ctx, stors, file, msg.ID)
if err != nil { if err != nil {
@@ -58,7 +70,17 @@ func handleSilentSaveMedia(ctx *ext.Context, update *ext.Update) error {
} }
logger.Debugf("Got media: %s", message.Media.TypeName()) logger.Debugf("Got media: %s", message.Media.TypeName())
userID := update.GetUserChat().GetID() userID := update.GetUserChat().GetID()
msg, file, err := shortcut.GetFileFromMessageWithReply(ctx, update, message) userDB, err := database.GetUserByChatID(ctx, userID)
if err != nil {
return err
}
tfOpts := make([]tfile.TGFileOption, 0)
switch userDB.FilenameStrategy {
case fnamest.Message.String():
tfOpts = append(tfOpts, tfile.WithName(tgutil.GenFileNameFromMessage(*message)))
default:
}
msg, file, err := shortcut.GetFileFromMessageWithReply(ctx, update, message, tfOpts...)
if err != nil { if err != nil {
return err return err
} }

View File

@@ -11,7 +11,7 @@ import (
func checkPermission(ctx *ext.Context, update *ext.Update) error { func checkPermission(ctx *ext.Context, update *ext.Update) error {
userID := update.GetUserChat().GetID() userID := update.GetUserChat().GetID()
if !slice.Contain(config.Cfg.GetUsersID(), userID) { if !slice.Contain(config.C().GetUsersID(), userID) {
const noPermissionText string = ` const noPermissionText string = `
您不在白名单中, 无法使用此 Bot. 您不在白名单中, 无法使用此 Bot.
您可以部署自己的实例: https://github.com/krau/SaveAny-Bot 您可以部署自己的实例: https://github.com/krau/SaveAny-Bot

View File

@@ -4,6 +4,7 @@ package handlers
import ( import (
"errors" "errors"
"strings"
"github.com/celestix/gotgproto/dispatcher" "github.com/celestix/gotgproto/dispatcher"
"github.com/celestix/gotgproto/ext" "github.com/celestix/gotgproto/ext"
@@ -11,6 +12,8 @@ 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/common/utils/tgutil"
"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"
@@ -20,7 +23,20 @@ import (
func handleTextMessage(ctx *ext.Context, u *ext.Update) error { func handleTextMessage(ctx *ext.Context, u *ext.Update) error {
logger := log.FromContext(ctx) logger := log.FromContext(ctx)
text := u.EffectiveMessage.Text text := u.EffectiveMessage.Text
item, err := parsers.ParseWithContext(ctx, text) entityUrls := tgutil.ExtractMessageEntityUrls(u.EffectiveMessage.Message)
if len(entityUrls) > 0 {
text += "\n" + strings.Join(entityUrls, "\n")
}
ok, pser := parsers.CanHandle(text)
if !ok {
return dispatcher.EndGroups
}
msg, err := ctx.Reply(u, ext.ReplyTextString("正在解析..."), nil)
if err != nil {
return err
}
item, err := pser.Parse(ctx, text)
if errors.Is(err, parsers.ErrNoParserFound) { if errors.Is(err, parsers.ErrNoParserFound) {
return dispatcher.EndGroups return dispatcher.EndGroups
} }
@@ -29,7 +45,7 @@ func handleTextMessage(ctx *ext.Context, u *ext.Update) error {
ctx.Reply(u, ext.ReplyTextString("Failed to parse text: "+err.Error()), nil) ctx.Reply(u, ext.ReplyTextString("Failed to parse text: "+err.Error()), nil)
return dispatcher.EndGroups return dispatcher.EndGroups
} }
logger.Debug("Parsed item from text message", "text", text, "item", item) logger.Debug("Parsed item from text message", "title", item.Title, "url", item.URL)
userID := u.GetUserChat().GetID() userID := u.GetUserChat().GetID()
markup, err := msgelem.BuildAddSelectStorageKeyboard(storage.GetUserStorages(ctx, userID), tcbdata.Add{ markup, err := msgelem.BuildAddSelectStorageKeyboard(storage.GetUserStorages(ctx, userID), tcbdata.Add{
TaskType: tasktype.TaskTypeParseditem, TaskType: tasktype.TaskTypeParseditem,
@@ -46,14 +62,11 @@ func handleTextMessage(ctx *ext.Context, u *ext.Update) error {
ctx.Reply(u, ext.ReplyTextString("Failed to build parsed text entity: "+err.Error()), nil) ctx.Reply(u, ext.ReplyTextString("Failed to build parsed text entity: "+err.Error()), nil)
return dispatcher.EndGroups return dispatcher.EndGroups
} }
ctx.SendMessage(userID, &tg.MessagesSendMessageRequest{ ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
Message: text, Message: text,
ReplyMarkup: markup, ReplyMarkup: markup,
Entities: entities, Entities: entities,
ReplyTo: &tg.InputReplyToMessage{ ID: msg.ID,
ReplyToMsgID: u.EffectiveMessage.ID,
ReplyToPeerID: u.GetUserChat().AsInputPeer(),
},
}) })
return dispatcher.EndGroups return dispatcher.EndGroups
@@ -80,7 +93,7 @@ func handleSilentSaveText(ctx *ext.Context, u *ext.Update) error {
ctx.Reply(u, ext.ReplyTextString("Failed to parse text: "+err.Error()), nil) ctx.Reply(u, ext.ReplyTextString("Failed to parse text: "+err.Error()), nil)
return dispatcher.EndGroups return dispatcher.EndGroups
} }
logger.Debug("Parsed item from text message", "text", text, "item", item) logger.Debug("Parsed item from text message", "title", item.Title, "url", item.URL)
userID := u.GetUserChat().GetID() userID := u.GetUserChat().GetID()
text, entities, err := msgelem.BuildParsedTextEntity(*item) text, entities, err := msgelem.BuildParsedTextEntity(*item)
if err != nil { if err != nil {
@@ -100,5 +113,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)
} }

View File

@@ -10,6 +10,7 @@ import (
"github.com/celestix/gotgproto/dispatcher/handlers/filters" "github.com/celestix/gotgproto/dispatcher/handlers/filters"
"github.com/celestix/gotgproto/ext" "github.com/celestix/gotgproto/ext"
"github.com/charmbracelet/log" "github.com/charmbracelet/log"
sabotfilters "github.com/krau/SaveAny-Bot/client/bot/handlers/utils/filters"
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/re" "github.com/krau/SaveAny-Bot/client/bot/handlers/utils/re"
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/ruleutil" "github.com/krau/SaveAny-Bot/client/bot/handlers/utils/ruleutil"
userclient "github.com/krau/SaveAny-Bot/client/user" userclient "github.com/krau/SaveAny-Bot/client/user"
@@ -40,23 +41,19 @@ func Register(disp dispatcher.Dispatcher) {
disp.AddHandler(handlers.NewCommand("watch", handleWatchCmd)) disp.AddHandler(handlers.NewCommand("watch", handleWatchCmd))
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("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("cancel"), handleCancelCallback)) disp.AddHandler(handlers.NewCallbackQuery(filters.CallbackQuery.Prefix(tcbdata.TypeCancel), handleCancelCallback))
linkRegexFilter, err := filters.Message.Regex(re.TgMessageLinkRegexString) disp.AddHandler(handlers.NewCallbackQuery(filters.CallbackQuery.Prefix(tcbdata.TypeConfig), handleConfigCallback))
if err != nil { disp.AddHandler(handlers.NewMessage(sabotfilters.RegexUrl(regexp.MustCompile(re.TgMessageLinkRegexString)), handleSilentMode(handleMessageLink, handleSilentSaveLink)))
panic("failed to create regex filter: " + err.Error()) disp.AddHandler(handlers.NewMessage(sabotfilters.RegexUrl(regexp.MustCompile(re.TelegraphUrlRegexString)), handleSilentMode(handleTelegraphUrlMessage, handleSilentSaveTelegraph)))
}
disp.AddHandler(handlers.NewMessage(linkRegexFilter, handleSilentMode(handleMessageLink, handleSilentSaveLink)))
telegraphUrlRegexFilter, err := filters.Message.Regex(re.TelegraphUrlRegexString)
if err != nil {
panic("failed to create Telegraph URL regex filter: " + err.Error())
}
disp.AddHandler(handlers.NewMessage(telegraphUrlRegexFilter, handleSilentMode(handleTelegraphUrlMessage, handleSilentSaveTelegraph)))
disp.AddHandler(handlers.NewMessage(filters.Message.Media, handleSilentMode(handleMediaMessage, handleSilentSaveMedia))) disp.AddHandler(handlers.NewMessage(filters.Message.Media, handleSilentMode(handleMediaMessage, handleSilentSaveMedia)))
disp.AddHandler(handlers.NewMessage(filters.Message.Text, handleSilentMode(handleTextMessage, handleSilentSaveText))) 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()) go listenMediaMessageEvent(userclient.GetMediaMessageCh())
} }
} }
@@ -110,7 +107,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())
@@ -120,6 +120,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()

View File

@@ -11,7 +11,7 @@ import (
"github.com/duke-git/lancet/v2/slice" "github.com/duke-git/lancet/v2/slice"
"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/database" "github.com/krau/SaveAny-Bot/database"
"github.com/krau/SaveAny-Bot/pkg/enums/rule" "github.com/krau/SaveAny-Bot/pkg/rule"
) )
func handleRuleCmd(ctx *ext.Context, update *ext.Update) error { func handleRuleCmd(ctx *ext.Context, update *ext.Update) error {

View File

@@ -12,6 +12,8 @@ import (
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/mediautil" "github.com/krau/SaveAny-Bot/client/bot/handlers/utils/mediautil"
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/msgelem" "github.com/krau/SaveAny-Bot/client/bot/handlers/utils/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/i18n"
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
"github.com/krau/SaveAny-Bot/common/utils/strutil" "github.com/krau/SaveAny-Bot/common/utils/strutil"
"github.com/krau/SaveAny-Bot/common/utils/tgutil" "github.com/krau/SaveAny-Bot/common/utils/tgutil"
"github.com/krau/SaveAny-Bot/pkg/tcbdata" "github.com/krau/SaveAny-Bot/pkg/tcbdata"
@@ -28,7 +30,7 @@ func handleSaveCmd(ctx *ext.Context, update *ext.Update) error {
} }
replyTo := update.EffectiveMessage.ReplyToMessage replyTo := update.EffectiveMessage.ReplyToMessage
if replyTo == nil || replyTo.Message == nil { if replyTo == nil || replyTo.Message == nil {
ctx.Reply(update, ext.ReplyTextString(msgelem.SaveHelpText), nil) ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgSaveHelpText)), nil)
return dispatcher.EndGroups return dispatcher.EndGroups
} }
genFilename := func() string { genFilename := func() string {
@@ -72,7 +74,7 @@ func handleSilentSaveReplied(ctx *ext.Context, update *ext.Update) error {
} }
replyTo := update.EffectiveMessage.ReplyToMessage replyTo := update.EffectiveMessage.ReplyToMessage
if replyTo == nil || replyTo.Message == nil { if replyTo == nil || replyTo.Message == nil {
ctx.Reply(update, ext.ReplyTextString(msgelem.SaveHelpText), nil) ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgSaveHelpText)), nil)
return dispatcher.EndGroups return dispatcher.EndGroups
} }
genFilename := func() string { genFilename := func() string {

View 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+&nbsp;&nbsp;&nbsp;(.+)$`).ReplaceAllString(md, "<b>$1</b>")
md = regexp.MustCompile(`(?m)^#####\s+&nbsp;&nbsp;&nbsp;&nbsp;(.+)$`).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, "&nbsp;", " ")
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")
}

View File

@@ -0,0 +1,26 @@
package filters
import (
"regexp"
"slices"
"github.com/celestix/gotgproto/dispatcher/handlers/filters"
"github.com/celestix/gotgproto/types"
"github.com/krau/SaveAny-Bot/common/utils/tgutil"
)
func RegexUrl(r *regexp.Regexp) filters.MessageFilter {
return func(m *types.Message) bool {
if m.Text == "" {
return false
}
if r.MatchString(m.Text) {
return true
}
urls := tgutil.ExtractMessageEntityUrls(m.Message)
if len(urls) == 0 {
return false
}
return slices.ContainsFunc(urls, r.MatchString)
}
}

View File

@@ -3,6 +3,7 @@ package msgelem
import ( import (
"fmt" "fmt"
"github.com/duke-git/lancet/v2/strutil"
"github.com/gotd/td/telegram/message/entity" "github.com/gotd/td/telegram/message/entity"
"github.com/gotd/td/telegram/message/styling" "github.com/gotd/td/telegram/message/styling"
"github.com/gotd/td/tg" "github.com/gotd/td/tg"
@@ -18,7 +19,7 @@ func BuildParsedTextEntity(item parser.Item) (string, []tg.MessageEntityClass, e
styling.Plain("\n作者: "), styling.Plain("\n作者: "),
styling.Code(item.Author), styling.Code(item.Author),
styling.Plain("\n描述: "), styling.Plain("\n描述: "),
styling.Code(item.Description), styling.Code(strutil.Ellipsis(item.Description, 233)),
styling.Plain("\n文件数量: "), styling.Plain("\n文件数量: "),
styling.Code(fmt.Sprintf("%d", len(item.Resources))), styling.Code(fmt.Sprintf("%d", len(item.Resources))),
styling.Plain("\n预计总大小: "), styling.Plain("\n预计总大小: "),

View File

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

View File

@@ -1,19 +0,0 @@
package msgelem
const (
WatchHelpText = `
使用 /watch 命令监听一个聊天的消息, 并自动保存到默认存储中, 遵从存储规则.
命令语法:
/watch <chat_id> [filter]
参数:
- <chat_id>: 聊天的 ID 或用户名
- [filter]: 可选, 格式为 过滤器类型:表达式 , 所有支持类型的过滤器请查看文档
命令示例:
/watch 2229835658 msgre:.*plana.*
这将监听 ID 为 2229835658 的聊天, 并转存所有包含 "plana" 的媒体消息
`
)

View File

@@ -7,8 +7,6 @@ import (
"github.com/charmbracelet/log" "github.com/charmbracelet/log"
"github.com/krau/SaveAny-Bot/database" "github.com/krau/SaveAny-Bot/database"
"github.com/krau/SaveAny-Bot/pkg/consts"
ruleenum "github.com/krau/SaveAny-Bot/pkg/enums/rule"
"github.com/krau/SaveAny-Bot/pkg/rule" "github.com/krau/SaveAny-Bot/pkg/rule"
"github.com/krau/SaveAny-Bot/pkg/tfile" "github.com/krau/SaveAny-Bot/pkg/tfile"
) )
@@ -37,7 +35,7 @@ func (m matchedStorName) String() string {
// can we use this storage name directly? // can we use this storage name directly?
func (m matchedStorName) IsUsable() bool { func (m matchedStorName) IsUsable() bool {
return m != "" && m != consts.RuleStorNameChosen return m != "" && m != rule.RuleStorNameChosen
} }
type MatchedDirPath string type MatchedDirPath string
@@ -47,17 +45,17 @@ func (m MatchedDirPath) String() string {
} }
func (m MatchedDirPath) NeedNewForAlbum() bool { func (m MatchedDirPath) NeedNewForAlbum() bool {
return m != "" && m == consts.RuleDirPathNewForAlbum return m != "" && m == rule.RuleDirPathNewForAlbum
} }
func ApplyRule(ctx context.Context, rules []database.Rule, inputs *ruleInput) (matchedStorageName matchedStorName, dirPath MatchedDirPath) { func ApplyRule(ctx context.Context, rules []database.Rule, inputs *ruleInput) (matched bool, matchedStorageName matchedStorName, dirPath MatchedDirPath) {
if inputs == nil || len(rules) == 0 { 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 {
switch ur.Type { switch ur.Type {
case ruleenum.FileNameRegex.String(): case rule.FileNameRegex.String():
ru, err := rule.NewRuleFileNameRegex(ur.StorageName, ur.DirPath, ur.Data) ru, err := rule.NewRuleFileNameRegex(ur.StorageName, ur.DirPath, ur.Data)
if err != nil { if err != nil {
logger.Errorf("Failed to create rule: %s", err) logger.Errorf("Failed to create rule: %s", err)
@@ -72,7 +70,7 @@ func ApplyRule(ctx context.Context, rules []database.Rule, inputs *ruleInput) (m
dirPath = MatchedDirPath(ru.StoragePath()) dirPath = MatchedDirPath(ru.StoragePath())
matchedStorageName = matchedStorName(ru.StorageName()) matchedStorageName = matchedStorName(ru.StorageName())
} }
case ruleenum.MessageRegex.String(): case rule.MessageRegex.String():
ru, err := rule.NewRuleMessageRegex(ur.StorageName, ur.DirPath, ur.Data) ru, err := rule.NewRuleMessageRegex(ur.StorageName, ur.DirPath, ur.Data)
if err != nil { if err != nil {
logger.Errorf("Failed to create rule: %s", err) logger.Errorf("Failed to create rule: %s", err)
@@ -87,7 +85,7 @@ func ApplyRule(ctx context.Context, rules []database.Rule, inputs *ruleInput) (m
dirPath = MatchedDirPath(ru.StoragePath()) dirPath = MatchedDirPath(ru.StoragePath())
matchedStorageName = matchedStorName(ru.StorageName()) matchedStorageName = matchedStorName(ru.StorageName())
} }
case ruleenum.IsAlbum.String(): case rule.IsAlbum.String():
matchAlbum, err := convertor.ToBool(ur.Data) matchAlbum, err := convertor.ToBool(ur.Data)
if err != nil { if err != nil {
matchAlbum = false matchAlbum = false
@@ -108,5 +106,8 @@ func ApplyRule(ctx context.Context, rules []database.Rule, inputs *ruleInput) (m
} }
} }
} }
return if matchedStorageName != "" || dirPath != "" {
return true, matchedStorageName, dirPath
}
return false, "", ""
} }

View File

@@ -20,12 +20,14 @@ import (
"github.com/krau/SaveAny-Bot/common/utils/tgutil" "github.com/krau/SaveAny-Bot/common/utils/tgutil"
"github.com/krau/SaveAny-Bot/common/utils/tphutil" "github.com/krau/SaveAny-Bot/common/utils/tphutil"
"github.com/krau/SaveAny-Bot/config" "github.com/krau/SaveAny-Bot/config"
"github.com/krau/SaveAny-Bot/database"
"github.com/krau/SaveAny-Bot/pkg/enums/fnamest"
"github.com/krau/SaveAny-Bot/pkg/telegraph" "github.com/krau/SaveAny-Bot/pkg/telegraph"
"github.com/krau/SaveAny-Bot/pkg/tfile" "github.com/krau/SaveAny-Bot/pkg/tfile"
) )
// 获取消息中的文件并回复等待消息, 返回等待消息, 获取到的文件 // 获取消息中的文件并回复等待消息, 返回等待消息, 获取到的文件
func GetFileFromMessageWithReply(ctx *ext.Context, update *ext.Update, message *tg.Message, tfileopts ...tfile.TGFileOptions) (replied *types.Message, func GetFileFromMessageWithReply(ctx *ext.Context, update *ext.Update, message *tg.Message, tfileopts ...tfile.TGFileOption) (replied *types.Message,
file tfile.TGFileMessage, err error, file tfile.TGFileMessage, err error,
) { ) {
logger := log.FromContext(ctx) logger := log.FromContext(ctx)
@@ -40,7 +42,7 @@ func GetFileFromMessageWithReply(ctx *ext.Context, update *ext.Update, message *
logger.Errorf("Failed to reply: %s", err) logger.Errorf("Failed to reply: %s", err)
return nil, nil, dispatcher.EndGroups return nil, nil, dispatcher.EndGroups
} }
options := []tfile.TGFileOptions{ options := []tfile.TGFileOption{
tfile.WithMessage(message), tfile.WithMessage(message),
} }
if len(tfileopts) > 0 { if len(tfileopts) > 0 {
@@ -62,7 +64,7 @@ type EditMessageFunc func(text string, markup tg.ReplyMarkupClass)
// 获取链接中的文件并回复等待消息 // 获取链接中的文件并回复等待消息
func GetFilesFromUpdateLinkMessageWithReplyEdit(ctx *ext.Context, update *ext.Update) (replied *types.Message, files []tfile.TGFileMessage, editReplied EditMessageFunc, err error) { func GetFilesFromUpdateLinkMessageWithReplyEdit(ctx *ext.Context, update *ext.Update) (replied *types.Message, files []tfile.TGFileMessage, editReplied EditMessageFunc, err error) {
logger := log.FromContext(ctx) logger := log.FromContext(ctx)
msgLinks := re.TgMessageLinkRegexp.FindAllString(update.EffectiveMessage.GetMessage(), -1) msgLinks := re.TgMessageLinkRegexp.FindAllString(tgutil.ExtractMessageEntityUrlsText(update.EffectiveMessage.Message), -1)
if len(msgLinks) == 0 { if len(msgLinks) == 0 {
logger.Warn("no matched message links but called handleMessageLink") logger.Warn("no matched message links but called handleMessageLink")
return nil, nil, nil, dispatcher.EndGroups return nil, nil, nil, dispatcher.EndGroups
@@ -81,7 +83,12 @@ func GetFilesFromUpdateLinkMessageWithReplyEdit(ctx *ext.Context, update *ext.Up
logger.Errorf("failed to edit message: %s", err) logger.Errorf("failed to edit message: %s", err)
} }
} }
user, err := database.GetUserByChatID(ctx, update.GetUserChat().GetID())
if err != nil {
logger.Errorf("failed to get user from db: %s", err)
editReplied("获取用户信息失败: "+err.Error(), nil)
return nil, nil, nil, dispatcher.EndGroups
}
files = make([]tfile.TGFileMessage, 0, len(msgLinks)) files = make([]tfile.TGFileMessage, 0, len(msgLinks))
addFile := func(client downloader.Client, msg *tg.Message) { addFile := func(client downloader.Client, msg *tg.Message) {
if msg == nil || msg.Media == nil { if msg == nil || msg.Media == nil {
@@ -93,7 +100,14 @@ func GetFilesFromUpdateLinkMessageWithReplyEdit(ctx *ext.Context, update *ext.Up
logger.Debugf("message %d has no media", msg.GetID()) logger.Debugf("message %d has no media", msg.GetID())
return return
} }
file, err := tfile.FromMediaMessage(media, client, msg, tfile.WithNameIfEmpty(tgutil.GenFileNameFromMessage(*msg))) var opt tfile.TGFileOption
switch user.FilenameStrategy {
case fnamest.Message.String():
opt = tfile.WithName(tgutil.GenFileNameFromMessage(*msg))
default:
opt = tfile.WithNameIfEmpty(tgutil.GenFileNameFromMessage(*msg))
}
file, err := tfile.FromMediaMessage(media, client, msg, opt)
if err != nil { if err != nil {
logger.Errorf("failed to create file from media: %s", err) logger.Errorf("failed to create file from media: %s", err)
return return
@@ -102,7 +116,7 @@ func GetFilesFromUpdateLinkMessageWithReplyEdit(ctx *ext.Context, update *ext.Up
} }
tctx := ctx tctx := ctx
if config.Cfg.Telegram.Userbot.Enable { if config.C().Telegram.Userbot.Enable {
tctx = uc.GetCtx() tctx = uc.GetCtx()
} }
@@ -164,7 +178,7 @@ type TelegraphResult struct {
// return replied message, image urls, telegraph path(unescaped), error // return replied message, image urls, telegraph path(unescaped), error
func GetTphPicsFromMessageWithReply(ctx *ext.Context, update *ext.Update) (*types.Message, *TelegraphResult, error) { func GetTphPicsFromMessageWithReply(ctx *ext.Context, update *ext.Update) (*types.Message, *TelegraphResult, error) {
logger := log.FromContext(ctx) logger := log.FromContext(ctx)
tphurl := re.TelegraphUrlRegexp.FindString(update.EffectiveMessage.GetMessage()) // TODO: batch urls tphurl := re.TelegraphUrlRegexp.FindString(tgutil.ExtractMessageEntityUrlsText(update.EffectiveMessage.Message))
if tphurl == "" { if tphurl == "" {
logger.Warnf("No telegraph url found but called handleTelegraph") logger.Warnf("No telegraph url found but called handleTelegraph")
return nil, nil, dispatcher.ContinueGroups return nil, nil, dispatcher.ContinueGroups

View File

@@ -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()

View File

@@ -7,16 +7,17 @@ import (
"github.com/celestix/gotgproto/dispatcher" "github.com/celestix/gotgproto/dispatcher"
"github.com/celestix/gotgproto/ext" "github.com/celestix/gotgproto/ext"
"github.com/charmbracelet/log" "github.com/charmbracelet/log"
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/msgelem" "github.com/krau/SaveAny-Bot/common/i18n"
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
"github.com/krau/SaveAny-Bot/common/utils/tgutil" "github.com/krau/SaveAny-Bot/common/utils/tgutil"
"github.com/krau/SaveAny-Bot/database" "github.com/krau/SaveAny-Bot/database"
) )
func handleWatchCmd(ctx *ext.Context, update *ext.Update) error { func handleWatchCmd(ctx *ext.Context, update *ext.Update) error {
logger := log.FromContext(ctx) logger := log.FromContext(ctx)
args := strings.Split(string(update.EffectiveMessage.Text), " ") args := strings.Split(update.EffectiveMessage.Text, " ")
if len(args) < 2 { if len(args) < 2 {
ctx.Reply(update, ext.ReplyTextString(msgelem.WatchHelpText), nil) ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgWatchHelpText)), nil)
return dispatcher.EndGroups return dispatcher.EndGroups
} }
userChatID := update.GetUserChat().GetID() userChatID := update.GetUserChat().GetID()
@@ -82,7 +83,7 @@ func handleWatchCmd(ctx *ext.Context, update *ext.Update) error {
func handleUnwatchCmd(ctx *ext.Context, update *ext.Update) error { func handleUnwatchCmd(ctx *ext.Context, update *ext.Update) error {
logger := log.FromContext(ctx) logger := log.FromContext(ctx)
args := strings.Split(string(update.EffectiveMessage.Text), " ") args := strings.Split(update.EffectiveMessage.Text, " ")
if len(args) < 2 { if len(args) < 2 {
ctx.Reply(update, ext.ReplyTextString("请提供要取消监听的聊天ID或用户名"), nil) ctx.Reply(update, ext.ReplyTextString("请提供要取消监听的聊天ID或用户名"), nil)
return dispatcher.EndGroups return dispatcher.EndGroups

View File

@@ -16,7 +16,7 @@ import (
func NewDefaultMiddlewares(ctx context.Context, timeout time.Duration) []telegram.Middleware { func NewDefaultMiddlewares(ctx context.Context, timeout time.Duration) []telegram.Middleware {
return []telegram.Middleware{ return []telegram.Middleware{
recovery.New(ctx, newBackoff(timeout)), recovery.New(ctx, newBackoff(timeout)),
retry.New(config.Cfg.Telegram.RpcRetry), retry.New(config.C().Telegram.RpcRetry),
floodwait.NewSimpleWaiter(), floodwait.NewSimpleWaiter(),
} }
} }

View File

@@ -54,8 +54,8 @@ func Login(ctx context.Context) (*gotgproto.Client, error) {
}) })
go func() { go func() {
var resolver dcs.Resolver var resolver dcs.Resolver
if config.Cfg.Telegram.Proxy.Enable && config.Cfg.Telegram.Proxy.URL != "" { if config.C().Telegram.Proxy.Enable && config.C().Telegram.Proxy.URL != "" {
dialer, err := netutil.NewProxyDialer(config.Cfg.Telegram.Proxy.URL) dialer, err := netutil.NewProxyDialer(config.C().Telegram.Proxy.URL)
if err != nil { if err != nil {
res <- struct { res <- struct {
client *gotgproto.Client client *gotgproto.Client
@@ -70,16 +70,16 @@ func Login(ctx context.Context) (*gotgproto.Client, error) {
resolver = dcs.DefaultResolver() resolver = dcs.DefaultResolver()
} }
tclient, err := gotgproto.NewClient( tclient, err := gotgproto.NewClient(
config.Cfg.Telegram.AppID, config.C().Telegram.AppID,
config.Cfg.Telegram.AppHash, config.C().Telegram.AppHash,
gotgproto.ClientTypePhone(""), gotgproto.ClientTypePhone(""),
&gotgproto.ClientOpts{ &gotgproto.ClientOpts{
Session: sessionMaker.SqlSession(gormlite.Open(config.Cfg.Telegram.Userbot.Session)), Session: sessionMaker.SqlSession(gormlite.Open(config.C().Telegram.Userbot.Session)),
AuthConversator: &terminalAuthConversator{}, AuthConversator: &terminalAuthConversator{},
Context: ctx, Context: ctx,
DisableCopyright: true, DisableCopyright: true,
Resolver: resolver, Resolver: resolver,
MaxRetries: config.Cfg.Telegram.RpcRetry, MaxRetries: config.C().Telegram.RpcRetry,
AutoFetchReply: true, AutoFetchReply: true,
Middlewares: middleware.NewDefaultMiddlewares(ctx, 5*time.Minute), Middlewares: middleware.NewDefaultMiddlewares(ctx, 5*time.Minute),
ErrorHandler: func(ctx *ext.Context, u *ext.Update, s string) error { ErrorHandler: func(ctx *ext.Context, u *ext.Update, s string) error {

View File

@@ -8,9 +8,10 @@ import (
"io/fs" "io/fs"
"os" "os"
"path/filepath" "path/filepath"
"regexp"
"sort" "sort"
"strings" "strings"
"github.com/goccy/go-yaml"
) )
func main() { func main() {
@@ -20,28 +21,27 @@ func main() {
flag.Parse() flag.Parse()
keys := make(map[string]struct{}) keys := make(map[string]struct{})
re := regexp.MustCompile(`^\s*\[+\s*([^\]\[]+)\s*\]+`)
err := filepath.WalkDir(*dir, func(path string, d fs.DirEntry, err error) error { err := filepath.WalkDir(*dir, func(path string, d fs.DirEntry, err error) error {
if err != nil { if err != nil {
return err return err
} }
if d.IsDir() || !strings.HasSuffix(d.Name(), ".toml") { if d.IsDir() || !(strings.HasSuffix(d.Name(), ".yaml") || strings.HasSuffix(d.Name(), ".yml")) {
return nil return nil
} }
f, err := os.Open(path)
data, err := os.ReadFile(path)
if err != nil { if err != nil {
return err return err
} }
defer f.Close()
s := bufio.NewScanner(f) var content map[string]interface{}
for s.Scan() { if err := yaml.Unmarshal(data, &content); err != nil {
if m := re.FindStringSubmatch(s.Text()); m != nil { return fmt.Errorf("failed to parse yaml %s: %w", path, err)
keys[m[1]] = struct{}{}
}
} }
return s.Err()
collectKeys(content, "", keys)
return nil
}) })
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "Error walking directory: %v\n", err) fmt.Fprintf(os.Stderr, "Error walking directory: %v\n", err)
@@ -62,23 +62,44 @@ func main() {
defer f.Close() defer f.Close()
w := bufio.NewWriter(f) w := bufio.NewWriter(f)
fmt.Fprintf(w, "// Code generated by cmd/gen_i18n. DO NOT EDIT.\n") fmt.Fprintf(w, "// Code generated by cmd/geni18n. DO NOT EDIT.\n")
fmt.Fprintf(w, "package %s\n\n", *pkg) fmt.Fprintf(w, "package %s\n\n", *pkg)
fmt.Fprintf(w, "type Key string\n\n")
fmt.Fprintf(w, "const (\n") fmt.Fprintf(w, "const (\n")
for _, key := range list { for _, key := range list {
name := toPascal(key) name := toPascal(key)
fmt.Fprintf(w, "\t%s = %q\n", name, key) fmt.Fprintf(w, "\t%s Key = %q\n", name, key)
} }
fmt.Fprintf(w, ")\n") fmt.Fprintf(w, ")\n")
w.Flush() w.Flush()
} }
func collectKeys(node map[string]interface{}, prefix string, keys map[string]struct{}) {
for k, v := range node {
fullKey := k
if prefix != "" {
fullKey = prefix + "." + k
}
switch val := v.(type) {
case map[string]interface{}:
collectKeys(val, fullKey, keys)
default:
keys[fullKey] = struct{}{}
}
}
}
// 转 PascalCase
func toPascal(key string) string { func toPascal(key string) string {
parts := strings.Split(key, ".") parts := strings.Split(key, ".")
for i, p := range parts { for i, p := range parts {
if len(p) > 0 { subs := strings.Split(p, "_")
parts[i] = strings.ToUpper(string(p[0])) + p[1:] for j, s := range subs {
if len(s) > 0 {
subs[j] = strings.ToUpper(s[:1]) + s[1:]
}
} }
parts[i] = strings.Join(subs, "")
} }
return strings.Join(parts, "") return strings.Join(parts, "")
} }

View File

@@ -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,75 +34,84 @@ 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(i18n.T(i18nk.LifetimeInitfailed), "error", err)
}
go func() {
<-exitChan
cancel()
}()
core.Run(ctx) core.Run(ctx)
<-ctx.Done() <-ctx.Done()
logger.Info(i18n.T(i18nk.Exiting)) logger.Info(i18n.T(i18nk.LifetimeExiting))
defer logger.Info(i18n.T(i18nk.Bye)) defer logger.Info(i18n.T(i18nk.LifetimeBye))
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) return nil, fmt.Errorf("failed to load config: %w", err)
os.Exit(1)
} }
cache.Init() cache.Init()
logger := log.FromContext(ctx) logger := log.FromContext(ctx)
i18n.Init(config.Cfg.Lang) i18n.Init(config.C().Lang)
logger.Info(i18n.T(i18nk.Initing)) logger.Info(i18n.T(i18nk.LifetimeIniting))
database.Init(ctx) database.Init(ctx)
storage.LoadStorages(ctx) storage.LoadStorages(ctx)
if config.Cfg.Parser.PluginEnable { if config.C().Parser.PluginEnable {
for _, dir := range config.Cfg.Parser.PluginDirs { for _, dir := range config.C().Parser.PluginDirs {
if err := parsers.LoadPlugins(ctx, dir); err != nil { if err := parsers.LoadPlugins(ctx, dir); err != nil {
logger.Error("Failed to load parser plugins", "dir", dir, "error", err) logger.Error(i18n.T(i18nk.ParserPluginLoadFailed), "dir", dir, "error", err)
} else { } else {
logger.Debug("Loaded parser plugins", "dir", dir) logger.Debug(i18n.T(i18nk.ParserPluginLoadedDir), "dir", dir)
} }
} }
} }
if config.Cfg.Telegram.Userbot.Enable { if config.C().Telegram.Userbot.Enable {
_, err := userclient.Login(ctx) _, err := userclient.Login(ctx)
if err != nil { if err != nil {
logger.Fatalf("User client login failed: %s", err) logger.Fatal(i18n.T(i18nk.LifetimeUserLoginFailed, map[string]any{
"Error": err,
}))
} }
} }
bot.Init(ctx) return bot.Init(ctx), nil
} }
func cleanCache() { func cleanCache() {
if config.Cfg.NoCleanCache { if config.C().NoCleanCache {
return return
} }
if config.Cfg.Temp.BasePath != "" && !config.Cfg.Stream { if config.C().Temp.BasePath != "" && !config.C().Stream {
if slices.Contains([]string{"/", ".", "\\", ".."}, filepath.Clean(config.Cfg.Temp.BasePath)) { if slices.Contains([]string{"/", ".", "\\", ".."}, filepath.Clean(config.C().Temp.BasePath)) {
log.Error(i18n.T(i18nk.InvalidCacheDir, map[string]any{ log.Error(i18n.T(i18nk.ConfigErrInvalidCacheDir, map[string]any{
"Path": config.Cfg.Temp.BasePath, "Path": config.C().Temp.BasePath,
})) }))
return return
} }
currentDir, err := os.Getwd() currentDir, err := os.Getwd()
if err != nil { if err != nil {
log.Error(i18n.T(i18nk.GetWorkdirFailed, map[string]any{ log.Error(i18n.T(i18nk.ErrGetWorkdirFailed, map[string]any{
"Error": err, "Error": err,
})) }))
return return
} }
cachePath := filepath.Join(currentDir, config.Cfg.Temp.BasePath) cachePath := filepath.Join(currentDir, config.C().Temp.BasePath)
cachePath, err = filepath.Abs(cachePath) cachePath, err = filepath.Abs(cachePath)
if err != nil { if err != nil {
log.Error(i18n.T(i18nk.GetCacheAbsPathFailed, map[string]any{ log.Error(i18n.T(i18nk.ErrGetCacheAbsPathFailed, map[string]any{
"Error": err, "Error": err,
})) }))
return return
} }
log.Info(i18n.T(i18nk.CleaningCache, map[string]any{ log.Info(i18n.T(i18nk.LifetimeCleaningCache, map[string]any{
"Path": cachePath, "Path": cachePath,
})) }))
if err := fsutil.RemoveAllInDir(cachePath); err != nil { if err := fsutil.RemoveAllInDir(cachePath); err != nil {
log.Error(i18n.T(i18nk.CleanCacheFailed, map[string]any{ log.Error(i18n.T(i18nk.ErrCleanCacheFailed, map[string]any{
"Error": err, "Error": err,
})) }))
} }

View File

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

View File

@@ -16,8 +16,8 @@ func Init() {
panic("cache already initialized") panic("cache already initialized")
} }
c, err := ristretto.NewCache(&ristretto.Config[string, any]{ c, err := ristretto.NewCache(&ristretto.Config[string, any]{
NumCounters: config.Cfg.Cache.NumCounters, NumCounters: config.C().Cache.NumCounters,
MaxCost: config.Cfg.Cache.MaxCost, MaxCost: config.C().Cache.MaxCost,
BufferItems: 64, BufferItems: 64,
OnReject: func(item *ristretto.Item[any]) { OnReject: func(item *ristretto.Item[any]) {
log.Warnf("Cache item rejected: key=%d, value=%v", item.Key, item.Value) 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 { 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 { if !ok {
return fmt.Errorf("failed to set value in cache") return fmt.Errorf("failed to set value in cache")
} }

View File

@@ -5,12 +5,13 @@ import (
"maps" "maps"
"github.com/goccy/go-yaml"
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
"github.com/nicksnyder/go-i18n/v2/i18n" "github.com/nicksnyder/go-i18n/v2/i18n"
"github.com/pelletier/go-toml/v2"
"golang.org/x/text/language" "golang.org/x/text/language"
) )
//go:embed locale/*.toml //go:embed locale/*
var localesFS embed.FS var localesFS embed.FS
var ( var (
@@ -20,7 +21,7 @@ var (
func Init(lang string) { func Init(lang string) {
bundle = i18n.NewBundle(language.SimplifiedChinese) bundle = i18n.NewBundle(language.SimplifiedChinese)
bundle.RegisterUnmarshalFunc("toml", toml.Unmarshal) bundle.RegisterUnmarshalFunc("yaml", yaml.Unmarshal)
files, err := localesFS.ReadDir("locale") files, err := localesFS.ReadDir("locale")
if err != nil { if err != nil {
panic("failed to read locale directory: " + err.Error()) panic("failed to read locale directory: " + err.Error())
@@ -39,7 +40,7 @@ func Init(lang string) {
} }
} }
func T(key string, templateData ...map[string]any) string { func T(key i18nk.Key, templateData ...map[string]any) string {
if localizer == nil || bundle == nil { if localizer == nil || bundle == nil {
panic("localizer or bundle is not initialized, call Init() first") panic("localizer or bundle is not initialized, call Init() first")
} }
@@ -48,11 +49,11 @@ func T(key string, templateData ...map[string]any) string {
maps.Copy(templateDataMap, data) maps.Copy(templateDataMap, data)
} }
msg, err := localizer.Localize(&i18n.LocalizeConfig{ msg, err := localizer.Localize(&i18n.LocalizeConfig{
MessageID: key, MessageID: string(key),
TemplateData: templateDataMap, TemplateData: templateDataMap,
}) })
if err != nil { if err != nil {
return key return string(key)
} }
return msg return msg
} }
@@ -77,32 +78,32 @@ func TWithLang(lang, key string, templateData ...map[string]any) string {
} }
// Only use in tests or packages that load before i18n // Only use in tests or packages that load before i18n
func TWithoutInit(lang, key string, templateData ...map[string]any) string { func TWithoutInit(lang string, key i18nk.Key, templateData ...map[string]any) string {
bundle := i18n.NewBundle(language.SimplifiedChinese) bundle := i18n.NewBundle(language.SimplifiedChinese)
bundle.RegisterUnmarshalFunc("toml", toml.Unmarshal) bundle.RegisterUnmarshalFunc("yaml", yaml.Unmarshal)
files, err := localesFS.ReadDir("locale") files, err := localesFS.ReadDir("locale")
if err != nil { if err != nil {
return key return string(key)
} }
for _, file := range files { for _, file := range files {
if _, err := bundle.LoadMessageFileFS(localesFS, "locale/"+file.Name()); err != nil { if _, err := bundle.LoadMessageFileFS(localesFS, "locale/"+file.Name()); err != nil {
return key return string(key)
} }
} }
localizer := i18n.NewLocalizer(bundle, lang) localizer := i18n.NewLocalizer(bundle, lang)
if localizer == nil { if localizer == nil {
return key return string(key)
} }
templateDataMap := make(map[string]any) templateDataMap := make(map[string]any)
for _, data := range templateData { for _, data := range templateData {
maps.Copy(templateDataMap, data) maps.Copy(templateDataMap, data)
} }
msg, err := localizer.Localize(&i18n.LocalizeConfig{ msg, err := localizer.Localize(&i18n.LocalizeConfig{
MessageID: key, MessageID: string(key),
TemplateData: templateDataMap, TemplateData: templateDataMap,
}) })
if err != nil { if err != nil {
return key return string(key)
} }
return msg return msg
} }

View File

@@ -1,19 +1,24 @@
// Code generated by cmd/gen_i18n. DO NOT EDIT. // Code generated by cmd/geni18n. DO NOT EDIT.
package i18nk package i18nk
type Key string
const ( const (
CleanCacheFailed = "CleanCacheFailed" BotMsgHelpTextFmt Key = "bot.msg.help_text_fmt"
CleaningCache = "CleaningCache" BotMsgSaveHelpText Key = "bot.msg.save_help_text"
ConfigInvalidDuplicateStorageName = "ConfigInvalid.DuplicateStorageName" BotMsgWatchHelpText Key = "bot.msg.watch_help_text"
ConfigInvalidWorkersOrRetry = "ConfigInvalid.WorkersOrRetry" ConfigErrDuplicateStorageName Key = "config.err.duplicate_storage_name"
CreateRmTimerFailed = "CreateRmTimerFailed" ConfigErrInvalidCacheDir Key = "config.err.invalid_cache_dir"
GetCacheAbsPathFailed = "GetCacheAbsPathFailed" ConfigLoadedStorages Key = "config.loaded_storages"
GetWorkdirFailed = "GetWorkdirFailed" ErrCleanCacheFailed Key = "err.clean_cache_failed"
InvalidCacheDir = "InvalidCacheDir" ErrGetCacheAbsPathFailed Key = "err.get_cache_abs_path_failed"
LoadedStorages = "LoadedStorages" ErrGetWorkdirFailed Key = "err.get_workdir_failed"
RemoveFileAfter = "RemoveFileAfter" LifetimeBye Key = "lifetime.bye"
RemoveFileFailed = "RemoveFileFailed" LifetimeCleaningCache Key = "lifetime.cleaning_cache"
Bye = "bye" LifetimeExiting Key = "lifetime.exiting"
Exiting = "exiting" LifetimeInitfailed Key = "lifetime.initfailed"
Initing = "initing" LifetimeIniting Key = "lifetime.initing"
LifetimeUserLoginFailed Key = "lifetime.user_login_failed"
ParserPluginLoadFailed Key = "parser.plugin.load_failed"
ParserPluginLoadedDir Key = "parser.plugin.loaded_dir"
) )

View File

@@ -1,28 +0,0 @@
[initing]
other = "正在启动..."
[exiting]
other = "正在退出..."
[bye]
other = "已退出"
[InvalidCacheDir]
other = "无效的缓存文件夹: {{.Path}}"
[GetWorkdirFailed]
other = "获取工作目录失败: {{.Error}}"
[GetCacheAbsPathFailed]
other = "获取缓存绝对路径失败: {{.Error}}"
[CleaningCache]
other = "正在清理缓存文件夹: {{.Path}}"
[CleanCacheFailed]
other = "清理缓存失败: {{.Error}}"
[CreateRmTimerFailed]
other = "创建清理定时器失败, 路径: {{.Path}}, 错误: {{.Error}}"
[RemoveFileAfter]
other = "将在 {{.Duration}} 后删除文件: {{.Path}}"
[RemoveFileFailed]
other = "删除文件失败: {{.Path}}, 错误: {{.Error}}"
[LoadedStorages]
other = "已加载 {{.Count}} 个存储"
[ConfigInvalid.WorkersOrRetry]
other = "配置无效: workers 或 retry 必须大于 0, 但当前值为: workers={{.Workers}}, retry={{.Retry}}"
[ConfigInvalid.DuplicateStorageName]
other = "存储名称重复: {{.Name}}"

View File

@@ -0,0 +1,62 @@
lifetime:
initing: 正在启动
initfailed: 初始化失败
exiting: 正在退出
user_login_failed: "用户登录失败: {{.Error}}"
cleaning_cache: "正在清理缓存 {{.Path}}"
bye: 已退出
config:
loaded_storages: "已加载 {{.Count}} 个存储后端"
err:
invalid_cache_dir: "无效的缓存目录: {{.Path}},请检查配置文件"
duplicate_storage_name: "存储名称 '{{.Name}}' 重复,请检查配置文件"
err:
get_workdir_failed: "获取工作目录失败: {{.Error}}"
get_cache_abs_path_failed: "获取缓存绝对路径失败: {{.Error}}"
clean_cache_failed: "清理缓存失败: {{.Error}}"
parser:
plugin:
load_failed: 加载解析器插件失败
loaded_dir: 解析器插件已加载
bot:
msg:
help_text_fmt: |
Save Any Bot - 转存你的 Telegram 文件
版本: %s , 提交: %s
命令:
/start - 开始使用
/help - 显示帮助
/silent - 开关静默模式
/storage - 设置默认存储位置
/save [自定义文件名] - 保存文件
/dir - 管理存储目录
/rule - 管理规则
/update - 检查更新并升级
使用帮助: https://sabot.unv.app/usage
反馈群组: https://t.me/ProjectSaveAny
save_help_text: |
使用方法:
1. 使用该命令回复要保存的文件, 可选文件名参数.
示例:
/save custom_file_name.mp4
2. 设置默认存储后, 发送 /save <频道ID/用户名> <消息ID范围> 来批量保存文件. 遵从存储规则, 若未匹配到任何规则则使用默认存储.
示例:
/save @acherkrau 114-514
watch_help_text: |
使用 /watch 命令监听一个聊天的消息, 并自动保存到默认存储中, 遵从存储规则.
命令语法:
/watch <chat_id> [filter]
参数:
- <chat_id>: 聊天的 ID 或用户名
- [filter]: 可选, 格式为 过滤器类型:表达式 , 所有支持类型的过滤器请查看文档
命令示例:
/watch 2229835658 msgre:.*plana.*
这将监听 ID 为 2229835658 的聊天, 并转存所有包含 "plana" 的媒体消息

View File

@@ -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
}

View 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)
}
}
}

View File

@@ -1,8 +1,15 @@
package netutil package netutil
import ( import (
"context"
"fmt"
"net"
"net/http"
"net/url" "net/url"
"sync"
"github.com/charmbracelet/log"
"github.com/krau/SaveAny-Bot/config"
"golang.org/x/net/proxy" "golang.org/x/net/proxy"
) )
@@ -13,3 +20,60 @@ func NewProxyDialer(proxyUrl string) (proxy.Dialer, error) {
} }
return proxy.FromURL(url, proxy.Direct) return proxy.FromURL(url, proxy.Direct)
} }
func NewProxyHTTPClient(proxyUrl string) (*http.Client, error) {
if proxyUrl == "" {
return &http.Client{
Transport: &http.Transport{
Proxy: http.ProxyFromEnvironment,
},
}, nil
}
u, err := url.Parse(proxyUrl)
if err != nil {
return nil, err
}
switch u.Scheme {
case "http", "https":
return &http.Client{
Transport: &http.Transport{
Proxy: http.ProxyURL(u),
},
}, nil
case "socks5":
dialer, err := proxy.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)
}
}
var (
defaultProxyHttpClient *http.Client
onceLoadDefaultProxyHttpClient sync.Once
)
func DefaultParserHTTPClient() *http.Client {
onceLoadDefaultProxyHttpClient.Do(func() {
client, err := NewProxyHTTPClient(config.C().Parser.Proxy)
if err != nil {
log.Warn("Failed to create default proxy HTTP client, using http.DefaultClient", "error", err)
defaultProxyHttpClient = http.DefaultClient
} else {
defaultProxyHttpClient = client
}
})
return defaultProxyHttpClient
}

View File

@@ -4,6 +4,8 @@ import (
"fmt" "fmt"
"strconv" "strconv"
"strings" "strings"
"unicode"
"unicode/utf16"
"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 +63,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)) {
@@ -309,3 +307,49 @@ func GetGroupedMessages(ctx *ext.Context, chatID int64, msg *tg.Message) ([]*tg.
} }
return groupedMessages, nil return groupedMessages, nil
} }
func ExtractMessageEntityUrls(msg *tg.Message) []string {
if len(msg.Entities) == 0 {
return nil
}
msgText := msg.GetMessage()
if msgText == "" {
return nil
}
runes := []rune(msgText)
utf16Codes := utf16.Encode(runes)
var urls []string
for _, entity := range msg.Entities {
switch ent := entity.(type) {
case *tg.MessageEntityTextURL:
urls = append(urls, ent.GetURL())
case *tg.MessageEntityURL:
start := ent.Offset
end := ent.Offset + ent.Length
if start < 0 || end > len(utf16Codes) {
continue
}
subRunes := utf16.Decode(utf16Codes[start:end])
urls = append(urls, string(subRunes))
}
}
return urls
}
func ExtractMessageEntityUrlsText(msg *tg.Message) string {
if msg == nil {
return ""
}
urls := ExtractMessageEntityUrls(msg)
if len(urls) == 0 {
return msg.GetMessage()
}
var sb strings.Builder
for _, url := range urls {
sb.WriteString(url)
sb.WriteString(" ")
}
return sb.String()
}

View File

@@ -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]

View File

@@ -13,8 +13,8 @@ func DefaultClient() *telegraph.Client {
if tphClient != nil { if tphClient != nil {
return tphClient return tphClient
} }
if config.Cfg.Telegram.Proxy.Enable && config.Cfg.Telegram.Proxy.URL != "" { if config.C().Telegram.Proxy.Enable && config.C().Telegram.Proxy.URL != "" {
proxyUrl := config.Cfg.Telegram.Proxy.URL proxyUrl := config.C().Telegram.Proxy.URL
var err error var err error
tphClient, err = telegraph.NewClientWithProxy(proxyUrl) tphClient, err = telegraph.NewClientWithProxy(proxyUrl)
if err != nil { if err != nil {

View File

@@ -1,6 +1,15 @@
package config package config
type parserConfig struct { type parserConfig struct {
PluginEnable bool `toml:"plugin_enable" mapstructure:"plugin_enable" json:"plugin_enable"` PluginEnable bool `toml:"plugin_enable" mapstructure:"plugin_enable" json:"plugin_enable"`
PluginDirs []string `toml:"plugin_dirs" mapstructure:"plugin_dirs" json:"plugin_dirs"` PluginDirs []string `toml:"plugin_dirs" mapstructure:"plugin_dirs" json:"plugin_dirs"`
Proxy string `toml:"proxy" mapstructure:"proxy" json:"proxy"`
ParserCfgs map[string]map[string]any `mapstructure:",remain"`
}
func (c Config) GetParserConfigByName(name string) map[string]any {
if c.Parser.ParserCfgs == nil {
return nil
}
return c.Parser.ParserCfgs[name]
} }

View File

@@ -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"`
} }

View File

@@ -14,7 +14,7 @@ var userIDs []int64
var storages []string var storages []string
var userStorages = make(map[int64][]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] us, ok := userStorages[userID]
if ok { if ok {
return us return us
@@ -22,11 +22,11 @@ func (c *Config) GetStorageNamesByUserID(userID int64) []string {
return nil return nil
} }
func (c *Config) GetUsersID() []int64 { func (c Config) GetUsersID() []int64 {
return userIDs return userIDs
} }
func (c *Config) HasStorage(userID int64, storageName string) bool { func (c Config) HasStorage(userID int64, storageName string) bool {
us, ok := userStorages[userID] us, ok := userStorages[userID]
if !ok { if !ok {
return false return false

13
config/version.go Normal file
View File

@@ -0,0 +1,13 @@
package config
// inject version by '-X' flag
// go build -ldflags "-X github.com/krau/SaveAny-Bot/config.Version=${{ env.VERSION }}"
var (
Version string = "dev"
BuildTime string = "unknown"
GitCommit string = "unknown"
)
const (
GitRepo = "krau/SaveAny-Bot"
)

View File

@@ -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"
@@ -32,7 +31,11 @@ type Config struct {
Hook hookConfig `toml:"hook" mapstructure:"hook" json:"hook"` 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 { func (c Config) GetStorageByName(name string) storage.StorageConfig {
for _, storage := range c.Storages { for _, storage := range c.Storages {
@@ -92,48 +95,51 @@ 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())
if err != nil { if err != nil {
return fmt.Errorf("error loading storage configs: %w", err) return fmt.Errorf("error loading storage configs: %w", err)
} }
Cfg.Storages = storagesConfig cfg.Storages = storagesConfig
storageNames := make(map[string]struct{}) storageNames := make(map[string]struct{})
for _, storage := range Cfg.Storages { for _, storage := range cfg.Storages {
if _, ok := storageNames[storage.GetName()]; ok { if _, ok := storageNames[storage.GetName()]; ok {
return errors.New(i18n.TWithoutInit(Cfg.Lang, i18nk.ConfigInvalidDuplicateStorageName, map[string]any{ return errors.New(i18n.TWithoutInit(cfg.Lang, i18nk.ConfigErrDuplicateStorageName, map[string]any{
"Name": storage.GetName(), "Name": storage.GetName(),
})) }))
} }
storageNames[storage.GetName()] = struct{}{} storageNames[storage.GetName()] = struct{}{}
} }
fmt.Println(i18n.TWithoutInit(Cfg.Lang, i18nk.LoadedStorages, map[string]any{ fmt.Println(i18n.TWithoutInit(cfg.Lang, i18nk.ConfigLoadedStorages, map[string]any{
"Count": len(Cfg.Storages), "Count": len(cfg.Storages),
})) }))
for _, storage := range Cfg.Storages { for _, storage := range cfg.Storages {
fmt.Printf(" - %s (%s)\n", storage.GetName(), storage.GetType()) fmt.Printf(" - %s (%s)\n", storage.GetName(), storage.GetType())
} }
if Cfg.Workers < 1 || Cfg.Retry < 1 { if cfg.Workers < 1 {
return errors.New(i18n.TWithoutInit(Cfg.Lang, i18nk.ConfigInvalidWorkersOrRetry, map[string]any{ cfg.Workers = 1
"Workers": Cfg.Workers, }
"Retry": Cfg.Retry, if cfg.Threads < 1 {
})) cfg.Threads = 1
}
if cfg.Retry < 1 {
cfg.Retry = 1
} }
for _, storage := range Cfg.Storages { for _, storage := range cfg.Storages {
storages = append(storages, storage.GetName()) storages = append(storages, storage.GetName())
} }
for _, user := range Cfg.Users { for _, user := range cfg.Users {
userIDs = append(userIDs, user.ID) userIDs = append(userIDs, user.ID)
if user.Blacklist { if user.Blacklist {
userStorages[user.ID] = slice.Compact(slice.Difference(storages, user.Storages)) userStorages[user.ID] = slice.Compact(slice.Difference(storages, user.Storages))
@@ -143,20 +149,3 @@ func Init(ctx context.Context) error {
} }
return nil 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
}

View File

@@ -20,7 +20,7 @@ type Exectable interface {
func worker(ctx context.Context, qe *queue.TaskQueue[Exectable], semaphore chan struct{}) { func worker(ctx context.Context, qe *queue.TaskQueue[Exectable], semaphore chan struct{}) {
logger := log.FromContext(ctx) logger := log.FromContext(ctx)
execHooks := config.Cfg.Hook.Exec execHooks := config.C().Hook.Exec
for { for {
semaphore <- struct{}{} semaphore <- struct{}{}
qtask, err := qe.Get() qtask, err := qe.Get()
@@ -58,11 +58,11 @@ func worker(ctx context.Context, qe *queue.TaskQueue[Exectable], semaphore chan
func Run(ctx context.Context) { func Run(ctx context.Context) {
log.FromContext(ctx).Info("Start processing tasks...") log.FromContext(ctx).Info("Start processing tasks...")
semaphore := make(chan struct{}, config.Cfg.Workers) semaphore := make(chan struct{}, config.C().Workers)
if queueInstance == nil { if queueInstance == nil {
queueInstance = queue.NewTaskQueue[Exectable]() queueInstance = queue.NewTaskQueue[Exectable]()
} }
for range config.Cfg.Workers { for range config.C().Workers {
go worker(ctx, queueInstance, semaphore) go worker(ctx, queueInstance, semaphore)
} }

View File

@@ -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 := log.FromContext(ctx).WithPrefix(fmt.Sprintf("batch_file[%s]", t.ID))
logger.Info("Starting batch file task") logger.Info("Starting batch file task")
t.Progress.OnStart(ctx, t) t.Progress.OnStart(ctx, t)
workers := config.Cfg.Workers workers := config.C().Workers
eg, gctx := errgroup.WithContext(ctx) eg, gctx := errgroup.WithContext(ctx)
eg.SetLimit(workers) eg.SetLimit(workers)
for _, elem := range t.Elems { for _, elem := range t.Elems {
@@ -124,6 +124,6 @@ func (t *Task) processElement(ctx context.Context, elem TaskElement) error {
return err return err
} }
return nil return nil
}, retry.Context(vctx), retry.RetryTimes(uint(config.Cfg.Retry))) }, retry.Context(vctx), retry.RetryTimes(uint(config.C().Retry)))
return err return err
} }

View File

@@ -47,8 +47,8 @@ func NewTaskElement(
) (*TaskElement, error) { ) (*TaskElement, error) {
id := xid.New().String() id := xid.New().String()
_, ok := stor.(storage.StorageCannotStream) _, ok := stor.(storage.StorageCannotStream)
if !config.Cfg.Stream || ok { if !config.C().Stream || ok {
cachePath, err := filepath.Abs(filepath.Join(config.Cfg.Temp.BasePath, fmt.Sprintf("%s_%s", id, file.Name()))) cachePath, err := filepath.Abs(filepath.Join(config.C().Temp.BasePath, fmt.Sprintf("%s_%s", id, file.Name())))
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to get absolute path for cache: %w", err) return nil, fmt.Errorf("failed to get absolute path for cache: %w", err)
} }

View File

@@ -26,7 +26,7 @@ func (t *Task) Execute(ctx context.Context) error {
t.progress.OnStart(ctx, t) t.progress.OnStart(ctx, t)
} }
eg, gctx := errgroup.WithContext(ctx) eg, gctx := errgroup.WithContext(ctx)
eg.SetLimit(config.Cfg.Workers) eg.SetLimit(config.C().Workers)
for _, resource := range t.item.Resources { for _, resource := range t.item.Resources {
eg.Go(func() error { eg.Go(func() error {
t.processingMu.RLock() t.processingMu.RLock()
@@ -96,7 +96,7 @@ func (t *Task) processResource(ctx context.Context, resource parser.Resource) er
if t.stream { if t.stream {
return t.Stor.Save(ctx, resp.Body, path.Join(t.StorPath, resource.Filename)) 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))) fmt.Sprintf("resource_%s_%s", t.ID, resource.Filename)))
if err != nil { if err != nil {
return fmt.Errorf("failed to create cache file for resource %s: %w", resource.URL, err) 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 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)) 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 { if ctx.Err() != nil {
return ctx.Err() return ctx.Err()
} }

View File

@@ -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,14 +48,9 @@ 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.Cfg.Stream && !ok stream := config.C().Stream && !ok
return &Task{ return &Task{
ID: id, ID: id,
Ctx: ctx, Ctx: ctx,

View File

@@ -20,7 +20,7 @@ func (t *Task) Execute(ctx context.Context) error {
logger.Infof("Starting Telegraph task %s", t.PhPath) logger.Infof("Starting Telegraph task %s", t.PhPath)
t.progress.OnStart(ctx, t) t.progress.OnStart(ctx, t)
eg, gctx := errgroup.WithContext(ctx) eg, gctx := errgroup.WithContext(ctx)
eg.SetLimit(config.Cfg.Workers) eg.SetLimit(config.C().Workers)
for i, pic := range t.Pics { for i, pic := range t.Pics {
eg.Go(func() error { eg.Go(func() error {
err := t.processPic(gctx, pic, i) 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 { func (t *Task) processPic(ctx context.Context, picUrl string, index int) error {
retryOpts := []retry.Option{ retryOpts := []retry.Option{
retry.Context(ctx), retry.Context(ctx),
retry.RetryTimes(uint(config.Cfg.Retry)), retry.RetryTimes(uint(config.C().Retry)),
} }
var lastErr error var lastErr error
err := retry.Retry(func() 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() defer body.Close()
filename := fmt.Sprintf("%d%s", index+1, path.Ext(picUrl)) filename := fmt.Sprintf("%d%s", index+1, path.Ext(picUrl))
if t.cannotStream { 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), fmt.Sprintf("tph_%s_%s", t.TaskID(), filename),
)) ))
if err != nil { if err != nil {

View File

@@ -57,7 +57,7 @@ func (t *Task) Execute(ctx context.Context) error {
return fmt.Errorf("failed to get file stat: %w", err) return fmt.Errorf("failed to get file stat: %w", err)
} }
vctx := context.WithValue(ctx, ctxkey.ContentLength, fileStat.Size()) 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 { if err = vctx.Err(); err != nil {
return fmt.Errorf("context canceled while saving file: %w", err) 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() defer file.Close()
if err = t.Storage.Save(vctx, file, t.Path); err != nil { 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) return fmt.Errorf("failed to save file: %w", err)
} }
logger.Errorf("Failed to save file: %s, retrying...", err) logger.Errorf("Failed to save file: %s, retrying...", err)

View File

@@ -35,8 +35,8 @@ func NewTGFileTask(
progress ProgressTracker, progress ProgressTracker,
) (*Task, error) { ) (*Task, error) {
_, ok := stor.(storage.StorageCannotStream) _, ok := stor.(storage.StorageCannotStream)
if !config.Cfg.Stream || ok { if !config.C().Stream || ok {
cachePath, err := filepath.Abs(filepath.Join(config.Cfg.Temp.BasePath, fmt.Sprintf("%s_%s", id, file.Name()))) cachePath, err := filepath.Abs(filepath.Join(config.C().Temp.BasePath, fmt.Sprintf("%s_%s", id, file.Name())))
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to get absolute path for cache: %w", err) return nil, fmt.Errorf("failed to get absolute path for cache: %w", err)
} }

View File

@@ -19,11 +19,11 @@ var db *gorm.DB
func Init(ctx context.Context) { func Init(ctx context.Context) {
logger := log.FromContext(ctx) 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) logger.Fatal("Failed to create data directory: ", err)
} }
var err error 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{ Logger: glogger.New(logger, glogger.Config{
Colorful: true, Colorful: true,
SlowThreshold: time.Second * 5, SlowThreshold: time.Second * 5,
@@ -60,7 +60,7 @@ func syncUsers(ctx context.Context) error {
} }
cfgUserMap := make(map[int64]struct{}) cfgUserMap := make(map[int64]struct{})
for _, u := range config.Cfg.Users { for _, u := range config.C().Users {
cfgUserMap[u.ID] = struct{}{} cfgUserMap[u.ID] = struct{}{}
} }

View File

@@ -6,13 +6,14 @@ import (
type User struct { type User struct {
gorm.Model gorm.Model
ChatID int64 `gorm:"uniqueIndex;not null"` ChatID int64 `gorm:"uniqueIndex;not null"`
Silent bool Silent bool
DefaultStorage string DefaultStorage string
Dirs []Dir Dirs []Dir
ApplyRule bool ApplyRule bool
Rules []Rule Rules []Rule
WatchChats []WatchChat WatchChats []WatchChat
FilenameStrategy string
} }
type WatchChat struct { type WatchChat struct {

View File

@@ -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

View File

@@ -11,20 +11,22 @@ title: 介绍
把 Telegram 上的文件转存到多种存储端. 把 Telegram 上的文件转存到多种存储端.
## 特性 ## 🎯 特性
- 支持文档/视频/图片/贴纸... 甚至还有 Telegraph - 支持文档/视频/图片/贴纸甚至还有 [Telegraph](https://telegra.ph/)
- 破解禁止保存的文件 - 破解禁止保存的文件
- 批量下载 - 批量下载
- 流式传输 - 流式传输
- 多用户 - 多用户使用
- 基于存储规则的自动整理 - 基于存储规则的自动整理
- 支持多种存储端: - 监听并自动转存指定聊天的消息, 支持过滤
- Alist - 使用 js 编写解析器插件以转存任意网站的文件
- Minio (S3 兼容) - 存储端支持:
- WebDAV - Alist
- Telegram (重传回指定聊天) - S3 (MinioSDK)
- 本地磁盘 - WebDAV
- 本地磁盘
- Telegram (重传回指定聊天)
## [贡献者](https://github.com/krau/SaveAny-Bot/graphs/contributors) ## [贡献者](https://github.com/krau/SaveAny-Bot/graphs/contributors)

View File

@@ -5,10 +5,30 @@ weight: 20
# 参与开发 # 参与开发
在开始之前, 请 Fork 本项目, 并克隆到本地, 并确保 Go 版本 >= 1.23.
以下是一些贡献代码的指南或建议, 你不必完全遵守, 但将有助于快速 review 并合并你的提交:
- **新功能请先提交 Issue**, 以便讨论设计和实现细节, 并避免因与项目设计不符而被拒绝.
- **使用现代开发工具**, 确保提交前格式化代码, 并保持风格一致.
- **使用[语义化提交](https://www.conventionalcommits.org/zh-hans/v1.0.0/)**, 避免提交消息模糊或过于简单.
## 贡献新存储端 ## 贡献新存储端
1. Fork 本项目, 克隆到本地 1. `pkg/enums/storage/storages.go` 中添加新的存储端类型, 并运行代码生成
2.`pkg/enums/storage/storages.go` 中添加新的存储端类型, 并运行代码生成 2.`config/storage` 目录下定义存储端配置, 并添加到 `config/storage/factory.go`
3.`config/storage` 目录下定义存储端配置, 并添加到 `config/storage/factory.go` 3.`storage` 目录下新建一个包, 编写存储端实现, 然后在 `storage/storage.go`导入并添加它
4. `storage` 目录下新建一个包, 编写存储端实现, 然后在 `storage/storage.go` 中导入并添加它 4. 更新文档, 添加配置说明
5. 更新文档, 添加配置说明
## 贡献新解析器
你可以选择使用 Go 编写原生的解析器实现(推荐), 或是使用 JavaScript 以插件的方式实现.
如果使用 Go 编写, 请:
1.`parsers` 目录下新建一个包, 编写解析器实现
2.`parsers/parser.go``init` 中注册解析器
如果使用 JavaScript 编写, 请参考 `plugins/example_parser_basic.js` 的实现, 并在该文件夹下新建一个 js 文件, 实现你的解析逻辑.
需要注意, `plugins` 目录下解析器默认不会被编译到二进制文件中, 用户需要手动下载它们并放到本地指定目录下以启用它们.

View File

@@ -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
@@ -164,6 +166,18 @@ task_fail = "curl -X POST https://example.com/api/notify -d '任务失败'"
task_cancel = "bash /path/to/cancel_script.sh" task_cancel = "bash /path/to/cancel_script.sh"
``` ```
### 解析器
解析器为 Bot 提供了处理非 Telegram 文件的能力, 例如从其他网站下载文件. 使用 `[parsers]` 配置.
```toml
[parsers]
plugin_enable = true # 是否启用解析器插件
plugin_dirs = ["./plugins"] # 插件目录, 可以是多个目录
```
上述两个配置项只用于控制以 JavaScript 编写的解析器插件, Bot 还有内置的使用 Go 实现的解析器, 目前默认开启.
### 杂项 ### 杂项
```toml ```toml

View File

@@ -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

View File

@@ -9,12 +9,11 @@ weight: 10
## 转存文件 ## 转存文件
Bot 接受两种消息: 文件和链接. 要使用 Bot 的转存 Telegram 文件功能, 需要向 Bot 发送或转发以下类型的消息.
对于链接, 目前支持以下类型的链接: 1. 文件或媒体消息, 如图片, 视频, 文档等
2. Telegram 消息链接, 例如: `https://t.me/acherkrau/1097`. **即使频道禁止了转发和保存, Bot 依然可以下载其文件.**
1. Telegram 消息链接, 例如: `https://t.me/acherkrau/1097`. **即使频道禁止了转发和保存, Bot 依然可以下载其文件.** 3. Telegra.ph 的文章链接, Bot 将下载其中的所有图片
2. Telegra.ph 的文章链接, Bot 将下载其中的所有图片
## 静默模式 (silent) ## 静默模式 (silent)
@@ -112,3 +111,14 @@ IS-ALBUM true MyWebdav NEW-FOR-ALBUM
``` ```
这将会监听 ID 为 12345678 的聊天, 并且只保存消息文本中包含 "hello" 的消息. 这将会监听 ID 为 12345678 的聊天, 并且只保存消息文本中包含 "hello" 的消息.
## 转存 Telegram 之外的文件
除了 Telegram 上的文件, Bot 还可通过 JavaScript 插件或内置解析器来支持转存其他网站的文件.
> 查看[贡献解析器](../contribute)文档了解详情
只需向 Bot 发送符合解析器要求的链接即可使用, 当前内置的解析器:
- Twitter
- Kemono

5
go.mod
View File

@@ -10,6 +10,7 @@ require (
github.com/charmbracelet/log v0.4.2 github.com/charmbracelet/log v0.4.2
github.com/fatih/color v1.18.0 github.com/fatih/color v1.18.0
github.com/gabriel-vasile/mimetype v1.4.9 github.com/gabriel-vasile/mimetype v1.4.9
github.com/goccy/go-yaml v1.18.0
github.com/gotd/contrib v0.21.0 github.com/gotd/contrib v0.21.0
github.com/gotd/td v0.129.0 github.com/gotd/td v0.129.0
github.com/minio/minio-go/v7 v7.0.95 github.com/minio/minio-go/v7 v7.0.95
@@ -83,7 +84,7 @@ require (
github.com/tcnksm/go-gitconfig v0.1.2 // indirect github.com/tcnksm/go-gitconfig v0.1.2 // indirect
github.com/tetratelabs/wazero v1.9.0 // indirect github.com/tetratelabs/wazero v1.9.0 // indirect
github.com/tinylib/msgp v1.3.0 // indirect github.com/tinylib/msgp v1.3.0 // indirect
github.com/ulikunitz/xz v0.5.12 // indirect github.com/ulikunitz/xz v0.5.14 // indirect
go.opentelemetry.io/otel v1.37.0 // indirect go.opentelemetry.io/otel v1.37.0 // indirect
go.opentelemetry.io/otel/metric v1.37.0 // indirect go.opentelemetry.io/otel/metric v1.37.0 // indirect
go.opentelemetry.io/otel/trace v1.37.0 // indirect go.opentelemetry.io/otel/trace v1.37.0 // indirect
@@ -114,7 +115,7 @@ require (
github.com/ncruces/go-sqlite3 v0.27.1 github.com/ncruces/go-sqlite3 v0.27.1
github.com/ncruces/go-sqlite3/gormlite v0.24.0 github.com/ncruces/go-sqlite3/gormlite v0.24.0
github.com/nicksnyder/go-i18n/v2 v2.6.0 github.com/nicksnyder/go-i18n/v2 v2.6.0
github.com/pelletier/go-toml/v2 v2.2.4 github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/sagikazarmark/locafero v0.10.0 // indirect github.com/sagikazarmark/locafero v0.10.0 // indirect
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
github.com/spf13/afero v1.14.0 // indirect github.com/spf13/afero v1.14.0 // indirect

6
go.sum
View File

@@ -109,6 +109,8 @@ github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9L
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
@@ -245,8 +247,8 @@ github.com/tetratelabs/wazero v1.9.0/go.mod h1:TSbcXCfFP0L2FGkRPxHphadXPjo1T6W+C
github.com/tinylib/msgp v1.3.0 h1:ULuf7GPooDaIlbyvgAxBV/FI7ynli6LZ1/nVUNu+0ww= github.com/tinylib/msgp v1.3.0 h1:ULuf7GPooDaIlbyvgAxBV/FI7ynli6LZ1/nVUNu+0ww=
github.com/tinylib/msgp v1.3.0/go.mod h1:ykjzy2wzgrlvpDCRc4LA8UXy6D8bzMSuAF3WD57Gok0= github.com/tinylib/msgp v1.3.0/go.mod h1:ykjzy2wzgrlvpDCRc4LA8UXy6D8bzMSuAF3WD57Gok0=
github.com/ulikunitz/xz v0.5.9/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= github.com/ulikunitz/xz v0.5.9/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
github.com/ulikunitz/xz v0.5.12 h1:37Nm15o69RwBkXM0J6A5OlE67RZTfzUxTj8fB3dfcsc= github.com/ulikunitz/xz v0.5.14 h1:uv/0Bq533iFdnMHZdRBTOlaNMdb1+ZxXIlHDZHIHcvg=
github.com/ulikunitz/xz v0.5.12/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= github.com/ulikunitz/xz v0.5.14/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=

View File

@@ -8,6 +8,8 @@ import (
"github.com/krau/SaveAny-Bot/cmd" "github.com/krau/SaveAny-Bot/cmd"
) )
//go:generate go run cmd/geni18n/main.go -dir ./common/i18n/locale -out common/i18n/i18nk/keys.go -pkg i18nk
func main() { func main() {
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt) ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
defer cancel() defer cancel()

View File

@@ -7,24 +7,11 @@ import (
"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"
) )
var (
LatestParserVersion = semver.MustParse("1.0.0")
MinimumParserVersion = semver.MustParse("1.0.0")
)
type PluginMeta struct {
Name string `json:"name"`
Version string `json:"version"` // [TODO] 分版本解析, 但是我们现在只有 v1 所以先不写
Description string `json:"description"`
Author string `json:"author"`
}
type jsParser struct { type jsParser struct {
meta PluginMeta meta PluginMeta
vm *goja.Runtime vm *goja.Runtime
@@ -32,7 +19,7 @@ type jsParser struct {
} }
type jsParserReq struct { type jsParserReq struct {
method string method ParserMethod
url string url string
respCh chan jsParserResp respCh chan jsParserResp
} }
@@ -45,16 +32,20 @@ type jsParserResp struct {
func (p *jsParser) CanHandle(url string) bool { func (p *jsParser) CanHandle(url string) bool {
respCh := make(chan jsParserResp, 1) respCh := make(chan jsParserResp, 1)
p.reqCh <- jsParserReq{method: "canHandle", url: url, respCh: respCh} p.reqCh <- jsParserReq{method: ParserMethodCanHandle, url: url, respCh: respCh}
resp := <-respCh resp := <-respCh
return resp.ok && resp.err == nil 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) respCh := make(chan jsParserResp, 1)
p.reqCh <- jsParserReq{method: "parse", url: url, respCh: respCh} p.reqCh <- jsParserReq{method: ParserMethodParse, url: url, respCh: respCh}
resp := <-respCh select {
return resp.item, resp.err 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 { func newJSParser(vm *goja.Runtime, canHandleFunc, parseFunc goja.Value, metadata PluginMeta) *jsParser {
@@ -67,7 +58,7 @@ func newJSParser(vm *goja.Runtime, canHandleFunc, parseFunc goja.Value, metadata
go func() { go func() {
for req := range p.reqCh { for req := range p.reqCh {
switch req.method { switch req.method {
case "canHandle": case ParserMethodCanHandle:
fn, _ := goja.AssertFunction(canHandleFunc) fn, _ := goja.AssertFunction(canHandleFunc)
res, err := fn(goja.Undefined(), p.vm.ToValue(req.url)) res, err := fn(goja.Undefined(), p.vm.ToValue(req.url))
if err != nil { if err != nil {
@@ -75,7 +66,7 @@ func newJSParser(vm *goja.Runtime, canHandleFunc, parseFunc goja.Value, metadata
continue continue
} }
req.respCh <- jsParserResp{ok: res.ToBoolean()} req.respCh <- jsParserResp{ok: res.ToBoolean()}
case "parse": case ParserMethodParse:
fn, _ := goja.AssertFunction(parseFunc) fn, _ := goja.AssertFunction(parseFunc)
result, err := fn(goja.Undefined(), p.vm.ToValue(req.url)) result, err := fn(goja.Undefined(), p.vm.ToValue(req.url))
if err != nil { if err != nil {
@@ -107,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 {
@@ -168,14 +115,13 @@ func LoadPlugins(ctx context.Context, dir string) error {
} }
vm := goja.New() vm := goja.New()
vm.Set("registerParser", jsRegisterParser(vm))
// Inject some utils to vm
logger := log.FromContext(ctx).WithPrefix(fmt.Sprintf("[plugin|parser]/%s", e.Name())) logger := log.FromContext(ctx).WithPrefix(fmt.Sprintf("[plugin|parser]/%s", e.Name()))
vm.Set("registerParser", registerParser(vm)) vm.Set("console", jsConsole(logger))
vm.Set("console", map[string]any{ // http fetch funcs
"log": func(args ...any) { vm.Set("ghttp", jsGhttp(vm))
logger.Info(fmt.Sprint(args...))
},
})
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
View 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
}

View File

@@ -0,0 +1,36 @@
package kemono
import (
"net/url"
"strings"
)
type DownloadInfo struct {
ServiceName string
UserID string
PostID string
}
func extractDownloadInfoFromURL(u string) *DownloadInfo {
if !strings.HasPrefix(u, "http://") && !strings.HasPrefix(u, "https://") {
u = "https://" + u
}
url, err := url.Parse(u)
if err != nil {
return nil
}
parts := strings.Split(strings.Trim(url.Path, "/"), "/")
if len(parts) == 3 {
return &DownloadInfo{
ServiceName: parts[0],
UserID: parts[2],
}
} else if len(parts) == 5 && parts[3] == "post" {
return &DownloadInfo{
ServiceName: parts[0],
UserID: parts[2],
PostID: parts[4],
}
}
return nil
}

173
parsers/kemono/kemono.go Normal file
View File

@@ -0,0 +1,173 @@
package kemono
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"path"
"strings"
"github.com/duke-git/lancet/v2/strutil"
"github.com/krau/SaveAny-Bot/common/utils/netutil"
"github.com/krau/SaveAny-Bot/pkg/parser"
)
type KemonoParser struct{}
var (
kemonoDomains = []string{
"kemono.su",
"kemono.cr",
}
ErrFailedToExtractInfo = errors.New("failed to extract download info from URL")
)
const (
kemonoApiBase = "https://kemono.cr/api/v1"
)
func (k *KemonoParser) CanHandle(text string) bool {
text = strings.TrimPrefix(text, "https://")
text = strings.TrimPrefix(text, "http://")
var matchesDomain bool
for _, domain := range kemonoDomains {
if strings.Contains(text, domain) {
matchesDomain = true
break
}
}
if !matchesDomain {
return false
}
var path string
for _, domain := range kemonoDomains {
if idx := strings.Index(text, domain); idx != -1 {
remaining := text[idx+len(domain):]
if len(remaining) > 0 && remaining[0] == '/' {
path = remaining[1:]
}
break
}
}
if path == "" {
return false
}
parts := strings.Split(path, "/")
// servicename/user/id (user profile page)
// servicename/user/id/post/id (post page)
return len(parts) == 3 || (len(parts) == 5 && parts[3] == "post")
}
func (k *KemonoParser) Parse(ctx context.Context, u string) (*parser.Item, error) {
info := extractDownloadInfoFromURL(u)
if info == nil {
return nil, ErrFailedToExtractInfo
}
if info.PostID != "" {
return k.parseOne(ctx, info)
}
return k.parseUserPage(ctx, info)
}
func (k *KemonoParser) parseOne(ctx context.Context, info *DownloadInfo) (*parser.Item, error) {
client := netutil.DefaultParserHTTPClient()
endpoint := fmt.Sprintf("%s/%s/user/%s/post/%s", kemonoApiBase, info.ServiceName, info.UserID, info.PostID)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request to Kemono API: %w", err)
}
req.Header.Set("Accept", "text/css")
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to fetch Kemono API: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("failed to fetch Kemono API, status code: %d", resp.StatusCode)
}
var postInfo PostInfo
if err := json.NewDecoder(resp.Body).Decode(&postInfo); err != nil {
return nil, fmt.Errorf("failed to decode Kemono API response: %w", err)
}
item := &parser.Item{
Site: "kemono",
Title: postInfo.Post.Title,
URL: fmt.Sprintf("https://kemono.cr/%s/user/%s/post/%s", info.ServiceName, info.UserID, info.PostID),
Author: postInfo.Post.User, // [TODO] request user profile
Description: postInfo.Post.Content,
Tags: func() []string {
if postInfo.Post.Tags != nil {
return *postInfo.Post.Tags
}
return nil
}(),
}
resources := make([]parser.Resource, 0)
for _, attachment := range postInfo.Attachments {
if attachment.Server == nil || attachment.Path == nil || attachment.Name == nil {
continue
}
var size int64
fileUrl := fmt.Sprintf("%s/data%s", *attachment.Server, *attachment.Path)
headReq, err := http.NewRequestWithContext(ctx, http.MethodHead, fileUrl, nil)
if err == nil {
resp, err := client.Do(headReq)
if err == nil {
size = resp.ContentLength
resp.Body.Close()
}
}
resources = append(resources, parser.Resource{
URL: fmt.Sprintf("%s/data%s", *attachment.Server, *attachment.Path),
Filename: *attachment.Name,
Size: size,
})
}
picCdnMap := make(map[string]string)
for _, preview := range postInfo.Previews {
if preview.Type == nil || *preview.Type != "thumbnail" {
continue
}
picCdnMap[*preview.Path] = *preview.Server
}
for _, attachment := range postInfo.Post.Attachments {
if !isImageExt(*attachment.Path) {
continue
}
picUrl, err := url.JoinPath(picCdnMap[*attachment.Path], "data", *attachment.Path)
if err != nil {
continue
}
var size int64
headReq, err := http.NewRequestWithContext(ctx, http.MethodHead, picUrl, nil)
if err == nil {
resp, err := client.Do(headReq)
if err == nil {
size = resp.ContentLength
resp.Body.Close()
}
}
resources = append(resources, parser.Resource{
URL: picUrl,
Filename: *attachment.Name,
Size: size,
})
}
item.Resources = resources
return item, nil
}
func (k *KemonoParser) parseUserPage(_ context.Context, _ *DownloadInfo) (*parser.Item, error) {
return nil, errors.New("kemono user page not implemented")
}
func isImageExt(attachmentPath string) bool {
return strutil.HasSuffixAny(path.Ext(strings.Split(attachmentPath, "?")[0]), []string{".jpg", ".jpeg", ".png", ".webp"})
}

View File

@@ -0,0 +1,62 @@
// https://github.com/kemono-rs/kemono
package kemono
type PostInfo struct {
Post Post `json:"post"`
Attachments []AttachmentLike `json:"attachments"`
Previews []AttachmentLike `json:"previews"`
}
type AttachmentLike struct {
Type *string `json:"type,omitempty"`
Server *string `json:"server,omitempty"`
Name *string `json:"name,omitempty"`
Path *string `json:"path,omitempty"`
}
type Post struct {
ID string `json:"id"`
User string `json:"user"`
Service string `json:"service"`
Title string `json:"title"`
Content string `json:"content"`
Embed Embed `json:"embed"`
SharedFile bool `json:"shared_file"`
Added *string `json:"added,omitempty"`
Published string `json:"published"`
Edited *string `json:"edited,omitempty"`
File File `json:"file"`
Attachments []AttachmentLike `json:"attachments"`
Poll *Poll `json:"poll,omitempty"`
Captions *string `json:"captions,omitempty"`
Tags *[]string `json:"tags,omitempty"`
Next *string `json:"next,omitempty"`
Prev *string `json:"prev,omitempty"`
}
type File struct {
Name *string `json:"name,omitempty"`
Path *string `json:"path,omitempty"`
}
type Embed struct {
URL *string `json:"url,omitempty"`
Subject *string `json:"subject,omitempty"`
Description *string `json:"description,omitempty"`
}
type Poll struct {
Title string `json:"title"`
Choices []Choice `json:"choices"`
ClosesAt *string `json:"closes_at,omitempty"`
CreatedAt string `json:"created_at"`
Description *string `json:"description,omitempty"`
AllowsMultiple bool `json:"allows_multiple"`
TotalVotes int64 `json:"total_votes"`
}
type Choice struct {
Text string `json:"text"`
Votes int64 `json:"votes"`
}

View File

@@ -0,0 +1,16 @@
package kemono
type PostLegacy struct {
Props Props `json:"props"`
Results []Result `json:"results"`
}
type Props struct {
Count uint `json:"count"`
Limit uint `json:"limit"`
}
type Result struct {
ID string `json:"id"`
Title string `json:"title"`
}

View File

@@ -0,0 +1,8 @@
package kemono
type UserProfile struct {
ID string `json:"id"`
Name string `json:"name"`
Service string `json:"service"`
PublicID *string `json:"public_id,omitempty"`
}

View File

@@ -5,29 +5,39 @@ import (
"fmt" "fmt"
"sync" "sync"
"github.com/krau/SaveAny-Bot/config"
"github.com/krau/SaveAny-Bot/parsers/kemono"
"github.com/krau/SaveAny-Bot/parsers/twitter" "github.com/krau/SaveAny-Bot/parsers/twitter"
"github.com/krau/SaveAny-Bot/pkg/parser" "github.com/krau/SaveAny-Bot/pkg/parser"
) )
var ( var (
parsers []parser.Parser parsers []parser.Parser
parsersMu sync.Mutex parsersMu sync.Mutex
doConfig sync.Once
configParsers = func() {
if len(parsers) == 0 {
return
}
for _, pser := range parsers {
if configurable, ok := pser.(parser.ConfigurableParser); ok {
cfg := config.C().GetParserConfigByName(configurable.Name())
if err := configurable.Configure(cfg); err != nil {
fmt.Printf("Error configuring parser %s: %v\n", configurable.Name(), err)
}
}
}
}
) )
func GetParsers() []parser.Parser { func AddParser(p ...parser.Parser) {
parsersMu.Lock() parsersMu.Lock()
defer parsersMu.Unlock() defer parsersMu.Unlock()
return parsers parsers = append(parsers, p...)
}
func AddParser(p parser.Parser) {
parsersMu.Lock()
defer parsersMu.Unlock()
parsers = append(parsers, p)
} }
func init() { func init() {
AddParser(new(twitter.TwitterParser)) AddParser(new(twitter.TwitterParser), new(kemono.KemonoParser))
} }
var ( var (
@@ -35,6 +45,7 @@ var (
) )
func ParseWithContext(ctx context.Context, url string) (*parser.Item, error) { func ParseWithContext(ctx context.Context, url string) (*parser.Item, error) {
doConfig.Do(configParsers)
ch := make(chan *parser.Item, 1) ch := make(chan *parser.Item, 1)
errCh := make(chan error, 1) errCh := make(chan error, 1)
@@ -43,7 +54,7 @@ func ParseWithContext(ctx context.Context, url string) (*parser.Item, error) {
if !pser.CanHandle(url) { if !pser.CanHandle(url) {
continue continue
} }
item, err := pser.Parse(url) item, err := pser.Parse(ctx, url)
if err != nil { if err != nil {
errCh <- err errCh <- err
return return
@@ -63,3 +74,13 @@ func ParseWithContext(ctx context.Context, url string) (*parser.Item, error) {
return nil, ctx.Err() return nil, ctx.Err()
} }
} }
func CanHandle(url string) (bool, parser.Parser) {
doConfig.Do(configParsers)
for _, pser := range parsers {
if pser.CanHandle(url) {
return true, pser
}
}
return false, nil
}

23
parsers/plugin.go Normal file
View File

@@ -0,0 +1,23 @@
package parsers
import "github.com/blang/semver"
var (
LatestParserVersion = semver.MustParse("1.0.0")
MinimumParserVersion = semver.MustParse("1.0.0")
)
type PluginMeta struct {
Name string `json:"name"`
Version string `json:"version"` // [TODO] 分版本解析, 但是我们现在只有 v1 所以先不写
Description string `json:"description"`
Author string `json:"author"`
}
type ParserMethod uint
const (
_ ParserMethod = iota
ParserMethodCanHandle
ParserMethodParse
)

View File

@@ -1,6 +1,7 @@
package twitter package twitter
import ( import (
"context"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
@@ -9,18 +10,20 @@ import (
"regexp" "regexp"
"strings" "strings"
"github.com/krau/SaveAny-Bot/common/utils/netutil"
"github.com/krau/SaveAny-Bot/pkg/parser" "github.com/krau/SaveAny-Bot/pkg/parser"
) )
type TwitterParser struct { type TwitterParser struct {
client http.Client client http.Client
apiDomain string
} }
const ( const (
FxTwitterApi = "api.fxtwitter.com" fxTwitterApi = "api.fxtwitter.com"
) )
var _ parser.Parser = (*TwitterParser)(nil) var _ parser.ConfigurableParser = (*TwitterParser)(nil)
var ( var (
twitterSourceURLRegexp *regexp.Regexp = regexp.MustCompile(`(?:twitter|x)\.com/([^/]+)/status/(\d+)`) twitterSourceURLRegexp *regexp.Regexp = regexp.MustCompile(`(?:twitter|x)\.com/([^/]+)/status/(\d+)`)
@@ -34,13 +37,17 @@ func getTweetID(sourceURL string) string {
return matches[2] 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) id := getTweetID(u)
if id == "" { if id == "" {
return nil, errors.New("invalid Twitter URL") return nil, errors.New("invalid Twitter URL")
} }
apiUrl := fmt.Sprintf("https://%s/_/status/%s", FxTwitterApi, id) apiUrl := fmt.Sprintf("https://%s/_/status/%s", p.apiDomain, id)
resp, err := p.client.Get(apiUrl) 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 { if err != nil {
return nil, fmt.Errorf("failed to fetch Twitter API: %w", err) 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)) resources := make([]parser.Resource, 0, len(fxResp.Tweet.Media.All))
for _, media := range fxResp.Tweet.Media.All { for _, media := range fxResp.Tweet.Media.All {
var size int64
resp, err := p.client.Head(media.URL)
if err == nil {
size = resp.ContentLength
resp.Body.Close()
}
resources = append(resources, parser.Resource{ resources = append(resources, parser.Resource{
URL: media.URL, URL: media.URL,
Filename: path.Base(strings.Split(media.URL, "?")[0]), Filename: path.Base(strings.Split(media.URL, "?")[0]),
Size: size,
}) })
} }
item := &parser.Item{ item := &parser.Item{
@@ -81,3 +95,28 @@ func (p *TwitterParser) Parse(u string) (*parser.Item, error) {
func (p *TwitterParser) CanHandle(u string) bool { func (p *TwitterParser) CanHandle(u string) bool {
return twitterSourceURLRegexp.MatchString(u) return twitterSourceURLRegexp.MatchString(u)
} }
func (p *TwitterParser) Name() string {
return "twitter"
}
func (p *TwitterParser) Configure(config map[string]any) error {
if config == nil {
p.apiDomain = fxTwitterApi
p.client = *netutil.DefaultParserHTTPClient()
return nil
}
if domain, ok := config["api_domain"].(string); ok && domain != "" {
p.apiDomain = domain
} else {
p.apiDomain = fxTwitterApi
}
if proxyUrl, ok := config["proxy"].(string); ok && proxyUrl != "" {
proxyClient, err := netutil.NewProxyHTTPClient(proxyUrl)
if err != nil {
return fmt.Errorf("failed to create proxy client: %w", err)
}
p.client = *proxyClient
}
return nil
}

View File

@@ -7,4 +7,5 @@ import (
const ( const (
MaxPartSize = 1024 * 1024 MaxPartSize = 1024 * 1024
MaxUploadPartSize = uploader.MaximumPartSize MaxUploadPartSize = uploader.MaximumPartSize
MaxPhotoSize = 10 * 1024 * 1024
) )

View File

@@ -1,9 +0,0 @@
package consts
// inject version by '-X' flag
// go build -ldflags "-X github.com/krau/SaveAny-Bot/pkg/consts.Version=${{ env.VERSION }}"
var (
Version string = "dev"
BuildTime string = "unknown"
GitCommit string = "unknown"
)

View File

@@ -0,0 +1,14 @@
package fnamest
//go:generate go-enum --values --names --noprefix --flag --nocase
// FnameST
/* ENUM(
default, message
) */
type FnameST string
var FnameSTDisplay = map[FnameST]string{
Default: "默认",
Message: "优先从消息生成",
}

View File

@@ -0,0 +1,87 @@
// Code generated by go-enum DO NOT EDIT.
// Version: 0.6.1
// Revision: a6f63bddde05aca4221df9c8e9e6d7d9674b1cb4
// Build Date: 2025-03-18T23:42:14Z
// Built By: goreleaser
package fnamest
import (
"fmt"
"strings"
)
const (
// Default is a FnameST of type default.
Default FnameST = "default"
// Message is a FnameST of type message.
Message FnameST = "message"
)
var ErrInvalidFnameST = fmt.Errorf("not a valid FnameST, try [%s]", strings.Join(_FnameSTNames, ", "))
var _FnameSTNames = []string{
string(Default),
string(Message),
}
// FnameSTNames returns a list of possible string values of FnameST.
func FnameSTNames() []string {
tmp := make([]string, len(_FnameSTNames))
copy(tmp, _FnameSTNames)
return tmp
}
// FnameSTValues returns a list of the values for FnameST
func FnameSTValues() []FnameST {
return []FnameST{
Default,
Message,
}
}
// String implements the Stringer interface.
func (x FnameST) String() string {
return string(x)
}
// IsValid provides a quick way to determine if the typed value is
// part of the allowed enumerated values
func (x FnameST) IsValid() bool {
_, err := ParseFnameST(string(x))
return err == nil
}
var _FnameSTValue = map[string]FnameST{
"default": Default,
"message": Message,
}
// ParseFnameST attempts to convert a string to a FnameST.
func ParseFnameST(name string) (FnameST, error) {
if x, ok := _FnameSTValue[name]; ok {
return x, nil
}
// Case insensitive parse, do a separate lookup to prevent unnecessary cost of lowercasing a string if we don't need to.
if x, ok := _FnameSTValue[strings.ToLower(name)]; ok {
return x, nil
}
return FnameST(""), fmt.Errorf("%s is %w", name, ErrInvalidFnameST)
}
// Set implements the Golang flag.Value interface func.
func (x *FnameST) Set(val string) error {
v, err := ParseFnameST(val)
*x = v
return err
}
// Get implements the Golang flag.Getter interface func.
func (x *FnameST) Get() interface{} {
return *x
}
// Type implements the github.com/spf13/pFlag Value interface.
func (x *FnameST) Type() string {
return "FnameST"
}

View File

@@ -1,13 +1,20 @@
package parser package parser
import ( import (
"context"
"crypto/md5" "crypto/md5"
"fmt" "fmt"
) )
type Parser interface { type Parser interface {
CanHandle(url string) bool 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. // Resource is a single downloadable resource with metadata.
@@ -15,7 +22,7 @@ type Resource struct {
URL string `json:"url"` URL string `json:"url"`
Filename string `json:"filename"` // with ext Filename string `json:"filename"` // with ext
MimeType string `json:"mime_type"` MimeType string `json:"mime_type"`
Extension string `json:"extension"` Extension string `json:"extension"` // e.g. "mp4"
Size int64 `json:"size"` // 0 when unknown Size int64 `json:"size"` // 0 when unknown
Hash map[string]string `json:"hash"` // {"md5": "...", "sha256": "..."} Hash map[string]string `json:"hash"` // {"md5": "...", "sha256": "..."}
Headers map[string]string `json:"headers"` // HTTP headers when downloading Headers map[string]string `json:"headers"` // HTTP headers when downloading

View File

@@ -1,4 +1,4 @@
package consts package rule
const ( const (
RuleStorNameChosen = "CHOSEN" RuleStorNameChosen = "CHOSEN"

View File

@@ -3,7 +3,6 @@ package rule
import ( import (
"regexp" "regexp"
ruleenum "github.com/krau/SaveAny-Bot/pkg/enums/rule"
"github.com/krau/SaveAny-Bot/pkg/tfile" "github.com/krau/SaveAny-Bot/pkg/tfile"
) )
@@ -14,8 +13,8 @@ type RuleFileNameRegex struct {
var _ RuleClass[tfile.TGFile] = (*RuleFileNameRegex)(nil) var _ RuleClass[tfile.TGFile] = (*RuleFileNameRegex)(nil)
func (r RuleFileNameRegex) Type() ruleenum.RuleType { func (r RuleFileNameRegex) Type() RuleType {
return ruleenum.FileNameRegex return FileNameRegex
} }
func (r RuleFileNameRegex) Match(input tfile.TGFile) (bool, error) { func (r RuleFileNameRegex) Match(input tfile.TGFile) (bool, error) {

View File

@@ -1,9 +1,5 @@
package rule package rule
import (
ruleenum "github.com/krau/SaveAny-Bot/pkg/enums/rule"
)
var _ RuleClass[bool] = (*RuleMediaType)(nil) var _ RuleClass[bool] = (*RuleMediaType)(nil)
type RuleMediaType struct { type RuleMediaType struct {
@@ -11,8 +7,8 @@ type RuleMediaType struct {
matchAlbum bool matchAlbum bool
} }
func (r RuleMediaType) Type() ruleenum.RuleType { func (r RuleMediaType) Type() RuleType {
return ruleenum.IsAlbum return IsAlbum
} }
func (r RuleMediaType) Match(input bool) (bool, error) { func (r RuleMediaType) Match(input bool) (bool, error) {

View File

@@ -2,8 +2,6 @@ package rule
import ( import (
"regexp" "regexp"
ruleenum "github.com/krau/SaveAny-Bot/pkg/enums/rule"
) )
var _ RuleClass[string] = (*RuleMessageRegex)(nil) var _ RuleClass[string] = (*RuleMessageRegex)(nil)
@@ -13,8 +11,8 @@ type RuleMessageRegex struct {
regex *regexp.Regexp regex *regexp.Regexp
} }
func (r RuleMessageRegex) Type() ruleenum.RuleType { func (r RuleMessageRegex) Type() RuleType {
return ruleenum.MessageRegex return MessageRegex
} }
func (r RuleMessageRegex) Match(input string) (bool, error) { func (r RuleMessageRegex) Match(input string) (bool, error) {

View File

@@ -1,11 +1,7 @@
package rule package rule
import (
ruleenum "github.com/krau/SaveAny-Bot/pkg/enums/rule"
)
type RuleClass[InputType any] interface { type RuleClass[InputType any] interface {
Type() ruleenum.RuleType Type() RuleType
Match(input InputType) (bool, error) Match(input InputType) (bool, error)
StorageName() string StorageName() string
StoragePath() string StoragePath() string

View File

@@ -10,6 +10,8 @@ import (
const ( const (
TypeAdd = "add" TypeAdd = "add"
TypeSetDefault = "setdefault" TypeSetDefault = "setdefault"
TypeConfig = "config"
TypeCancel = "cancel"
) )
// type TaskDataTGFiles struct { // type TaskDataTGFiles struct {

View File

@@ -9,5 +9,5 @@ import (
func NewDownloader(file TGFile) *downloader.Builder { func NewDownloader(file TGFile) *downloader.Builder {
return downloader.NewDownloader().WithPartSize(tglimit.MaxPartSize). 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))
} }

View File

@@ -2,20 +2,21 @@ package tfile
import "github.com/gotd/td/tg" import "github.com/gotd/td/tg"
type TGFileOptions func(*tgFile) type TGFileOption func(*tgFile)
func WithMessage(msg *tg.Message) TGFileOptions { func WithMessage(msg *tg.Message) TGFileOption {
return func(f *tgFile) { return func(f *tgFile) {
f.message = msg f.message = msg
} }
} }
func WithName(name string) TGFileOptions {
func WithName(name string) TGFileOption {
return func(f *tgFile) { return func(f *tgFile) {
f.name = name f.name = name
} }
} }
func WithNameIfEmpty(name string) TGFileOptions { func WithNameIfEmpty(name string) TGFileOption {
return func(f *tgFile) { return func(f *tgFile) {
if f.name == "" { if f.name == "" {
f.name = name f.name = name
@@ -23,13 +24,13 @@ func WithNameIfEmpty(name string) TGFileOptions {
} }
} }
func WithSize(size int64) TGFileOptions { func WithSize(size int64) TGFileOption {
return func(f *tgFile) { return func(f *tgFile) {
f.size = size f.size = size
} }
} }
func WithSizeIfZero(size int64) TGFileOptions { func WithSizeIfZero(size int64) TGFileOption {
return func(f *tgFile) { return func(f *tgFile) {
if f.size == 0 { if f.size == 0 {
f.size = size f.size = size

View File

@@ -54,7 +54,7 @@ func NewTGFile(
dler downloader.Client, dler downloader.Client,
size int64, size int64,
name string, name string,
opts ...TGFileOptions, opts ...TGFileOption,
) TGFile { ) TGFile {
f := &tgFile{ f := &tgFile{
location: location, location: location,
@@ -68,7 +68,7 @@ func NewTGFile(
return f return f
} }
func FromMedia(media tg.MessageMediaClass, client downloader.Client, opts ...TGFileOptions) (TGFile, error) { func FromMedia(media tg.MessageMediaClass, client downloader.Client, opts ...TGFileOption) (TGFile, error) {
switch m := media.(type) { switch m := media.(type) {
case *tg.MessageMediaDocument: case *tg.MessageMediaDocument:
document, ok := m.Document.AsNotEmpty() document, ok := m.Document.AsNotEmpty()
@@ -125,7 +125,7 @@ func FromMedia(media tg.MessageMediaClass, client downloader.Client, opts ...TGF
return nil, fmt.Errorf("unsupported media type: %T", media) return nil, fmt.Errorf("unsupported media type: %T", media)
} }
func FromMediaMessage(media tg.MessageMediaClass, client downloader.Client, msg *tg.Message, opts ...TGFileOptions) (TGFileMessage, error) { func FromMediaMessage(media tg.MessageMediaClass, client downloader.Client, msg *tg.Message, opts ...TGFileOption) (TGFileMessage, error) {
file, err := FromMedia(media, client, opts...) file, err := FromMedia(media, client, opts...)
if err != nil { if err != nil {
return nil, err return nil, err

161
plugins/README.md Normal file
View 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 图片页面并提取图片资源.

View File

@@ -1,7 +1,5 @@
// 这是一个示例解析器插件, 模拟处理 YouTube 的视频链接 // 这是一个最简示例解析器插件, 用于展示插件所需实现的基本功能
// 此插件将会模拟处理 YouTube 的视频链接
// 你可以使用 console.log 来在终端中使用 go 的 logger 打印信息
console.log("Example parser loaded");
/** /**
* 插件元数据 * 插件元数据
@@ -14,6 +12,9 @@ const metadata = {
author: "Krau", // 插件作者 author: "Krau", // 插件作者
} }
// 你可以使用 console.log 来在终端中使用 go 的 logger 打印信息
console.log("Parser loaded", "name", metadata.name);
/** /**
* canHandle 函数用于判断当前解析器能否解析给定的 URL * canHandle 函数用于判断当前解析器能否解析给定的 URL
*/ */
@@ -22,7 +23,6 @@ const canHandle = function (url) {
return url.includes("youtube.com/watch?v"); return url.includes("youtube.com/watch?v");
} }
/** /**
* 解析 url 并返回一个 Item 对象, 类型定义在 pkg/parser.go * 解析 url 并返回一个 Item 对象, 类型定义在 pkg/parser.go
*/ */
@@ -63,8 +63,11 @@ const parse = function (url) {
return result; return result;
} }
// 最后需要调用 registerParser 来注册这个解析器
registerParser({ registerParser({
metadata, metadata,
canHandle, canHandle,
parse parse
}); });
// 更进一步的插件编写信息, 请查看 plugins/example_parser_danbooru.js

View 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,
});

View File

@@ -20,7 +20,7 @@ func getStorageByName(ctx context.Context, name string) (Storage, error) {
if ok { if ok {
return storage, nil return storage, nil
} }
cfg := config.Cfg.GetStorageByName(name) cfg := config.C().GetStorageByName(name)
if cfg == nil { if cfg == nil {
return nil, fmt.Errorf("未找到存储 %s", name) return nil, fmt.Errorf("未找到存储 %s", name)
} }
@@ -39,7 +39,7 @@ func GetStorageByUserIDAndName(ctx context.Context, chatID int64, name string) (
return nil, ErrStorageNameEmpty return nil, ErrStorageNameEmpty
} }
if !config.Cfg.HasStorage(chatID, name) { if !config.C().HasStorage(chatID, name) {
return nil, fmt.Errorf("没有找到用户 %d 的存储 %s", chatID, name) return nil, fmt.Errorf("没有找到用户 %d 的存储 %s", chatID, name)
} }
@@ -54,7 +54,7 @@ func GetUserStorages(ctx context.Context, chatID int64) []Storage {
return storages return storages
} }
var storages []Storage var storages []Storage
for _, name := range config.Cfg.GetStorageNamesByUserID(chatID) { for _, name := range config.C().GetStorageNamesByUserID(chatID) {
storage, err := getStorageByName(ctx, name) storage, err := getStorageByName(ctx, name)
if err != nil { if err != nil {
continue continue
@@ -67,14 +67,14 @@ func GetUserStorages(ctx context.Context, chatID int64) []Storage {
func LoadStorages(ctx context.Context) { func LoadStorages(ctx context.Context) {
logger := log.FromContext(ctx) logger := log.FromContext(ctx)
logger.Info("加载存储...") logger.Info("加载存储...")
for _, storage := range config.Cfg.Storages { for _, storage := range config.C().Storages {
_, err := getStorageByName(ctx, storage.GetName()) _, err := getStorageByName(ctx, storage.GetName())
if err != nil { if err != nil {
logger.Errorf("加载存储 %s 失败: %v", storage.GetName(), err) logger.Errorf("加载存储 %s 失败: %v", storage.GetName(), err)
} }
} }
logger.Infof("成功加载 %d 个存储", len(Storages)) logger.Infof("成功加载 %d 个存储", len(Storages))
for user := range config.Cfg.GetUsersID() { for user := range config.C().GetUsersID() {
UserStorages[int64(user)] = GetUserStorages(ctx, int64(user)) UserStorages[int64(user)] = GetUserStorages(ctx, int64(user))
} }
} }

View File

@@ -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,32 +77,52 @@ 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)
} }
upler := uploader.NewUploader(tctx.Raw). upler := uploader.NewUploader(tctx.Raw).
WithPartSize(tglimit.MaxUploadPartSize). WithPartSize(tglimit.MaxUploadPartSize).
WithThreads(config.Cfg.Threads) WithThreads(config.C().Threads)
var file tg.InputFileClass var file tg.InputFileClass
size := func() int64 { size := func() int64 {
@@ -120,9 +142,13 @@ func (t *Telegram) Save(ctx context.Context, r io.Reader, storagePath string) er
return fmt.Errorf("failed to upload file to telegram: %w", err) 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