mirror of
https://github.com/krau/SaveAny-Bot.git
synced 2026-05-12 03:39:41 +08:00
Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
37c32a23d4 | ||
|
|
3aa1e2eaed | ||
|
|
b87dd68880 | ||
|
|
68e5a51300 | ||
|
|
7300e54c40 | ||
|
|
94f796d0e8 | ||
|
|
c023fd869d | ||
|
|
e5d1e143e0 | ||
|
|
03eb4f8a18 | ||
|
|
231eb61d25 | ||
|
|
fd1b586b8d | ||
|
|
d035a3409e | ||
|
|
6112f6c240 | ||
|
|
18eedf2edb | ||
|
|
5f9bba9ff7 | ||
|
|
0d3d2209be | ||
|
|
302db2fe75 | ||
|
|
79386bdd7d | ||
|
|
f0607de2cc |
6
.github/workflows/build-release.yml
vendored
6
.github/workflows/build-release.yml
vendored
@@ -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 }}
|
||||||
|
|||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -7,4 +7,4 @@ session.*
|
|||||||
cache.db
|
cache.db
|
||||||
.vscode/
|
.vscode/
|
||||||
temp/
|
temp/
|
||||||
.hugo_build.lock
|
.hugo_build.lock
|
||||||
|
|||||||
@@ -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 .
|
||||||
|
|
||||||
|
|||||||
70
README.md
70
README.md
@@ -4,28 +4,70 @@
|
|||||||
|
|
||||||
**简体中文** | [English](https://sabot.unv.app/en/)
|
**简体中文** | [English](https://sabot.unv.app/en/)
|
||||||
|
|
||||||
把 Telegram 上的文件转存到多种存储端.
|
> **把 Telegram 上的文件转存到多种存储端.**
|
||||||
|
|
||||||
|
[](https://github.com/krau/saveany-bot/releases)
|
||||||
|
[](https://github.com/krau/saveany-bot/releases)
|
||||||
|
[](https://github.com/krau/saveany-bot/actions/workflows/build-release.yml)
|
||||||
|
[](https://github.com/krau/saveany-bot/stargazers)
|
||||||
|
[](https://github.com/krau/saveany-bot/releases)
|
||||||
|
[](https://github.com/krau/saveany-bot/issues)
|
||||||
|
[](https://github.com/krau/saveany-bot/pulls)
|
||||||
|
[](./LICENSE)
|
||||||
|
|
||||||
</div>
|
</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
|
||||||
|
|
||||||
|
- [](https://t.me/ProjectSaveAny)
|
||||||
|
- [](https://github.com/krau/saveany-bot/discussions)
|
||||||
|
- [](https://t.me/acherkrau)
|
||||||
@@ -27,8 +27,8 @@ func Init(ctx context.Context) {
|
|||||||
})
|
})
|
||||||
go func() {
|
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,16 +43,16 @@ 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)
|
log.FromContext(ctx).Errorf("Unhandled error: %s", s)
|
||||||
@@ -79,7 +79,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: "取消监听聊天"})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,7 +72,9 @@ func handleAddCallback(ctx *ext.Context, update *ext.Update) error {
|
|||||||
}
|
}
|
||||||
return shortcut.CreateAndAddTGFileTaskWithEdit(ctx, userID, selectedStorage, dirPath, data.Files[0], msgID)
|
return shortcut.CreateAndAddTGFileTaskWithEdit(ctx, userID, selectedStorage, dirPath, data.Files[0], msgID)
|
||||||
case tasktype.TaskTypeTphpics:
|
case tasktype.TaskTypeTphpics:
|
||||||
return shortcut.CreateAndAddTphTaskWithEdit(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:
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
|
|||||||
103
client/bot/handlers/config.go
Normal file
103
client/bot/handlers/config.go
Normal 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
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ 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/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
func handleHelpCmd(ctx *ext.Context, update *ext.Update) error {
|
func handleHelpCmd(ctx *ext.Context, update *ext.Update) error {
|
||||||
@@ -24,10 +24,10 @@ Save Any Bot - 转存你的 Telegram 文件
|
|||||||
|
|
||||||
使用帮助: https://sabot.unv.app/usage/
|
使用帮助: https://sabot.unv.app/usage/
|
||||||
`
|
`
|
||||||
shortHash := consts.GitCommit
|
shortHash := config.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(helpText, config.Version, shortHash)), nil)
|
||||||
return dispatcher.EndGroups
|
return dispatcher.EndGroups
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
110
client/bot/handlers/parse.go
Normal file
110
client/bot/handlers/parse.go
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
// 处理任意文本消息, 用于通用地从外部源下载文件
|
||||||
|
|
||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"github.com/celestix/gotgproto/dispatcher"
|
||||||
|
"github.com/celestix/gotgproto/ext"
|
||||||
|
"github.com/charmbracelet/log"
|
||||||
|
"github.com/gotd/td/tg"
|
||||||
|
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/msgelem"
|
||||||
|
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/shortcut"
|
||||||
|
"github.com/krau/SaveAny-Bot/parsers"
|
||||||
|
"github.com/krau/SaveAny-Bot/pkg/enums/tasktype"
|
||||||
|
"github.com/krau/SaveAny-Bot/pkg/tcbdata"
|
||||||
|
"github.com/krau/SaveAny-Bot/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
func handleTextMessage(ctx *ext.Context, u *ext.Update) error {
|
||||||
|
logger := log.FromContext(ctx)
|
||||||
|
text := u.EffectiveMessage.Text
|
||||||
|
ok, pser := parsers.CanHandle(text)
|
||||||
|
if !ok {
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
msg, err := ctx.Reply(u, ext.ReplyTextString("正在解析..."), nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
item, err := pser.Parse(ctx, text)
|
||||||
|
if errors.Is(err, parsers.ErrNoParserFound) {
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
logger.Error("Failed to parse text", "error", err)
|
||||||
|
ctx.Reply(u, ext.ReplyTextString("Failed to parse text: "+err.Error()), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
logger.Debug("Parsed item from text message", "title", item.Title, "url", item.URL)
|
||||||
|
userID := u.GetUserChat().GetID()
|
||||||
|
markup, err := msgelem.BuildAddSelectStorageKeyboard(storage.GetUserStorages(ctx, userID), tcbdata.Add{
|
||||||
|
TaskType: tasktype.TaskTypeParseditem,
|
||||||
|
ParsedItem: item,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("Failed to build storage selection keyboard: %s", err)
|
||||||
|
ctx.Reply(u, ext.ReplyTextString("Failed to build storage selection keyboard: "+err.Error()), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
text, entities, err := msgelem.BuildParsedTextEntity(*item)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("Failed to build parsed text entity: %s", err)
|
||||||
|
ctx.Reply(u, ext.ReplyTextString("Failed to build parsed text entity: "+err.Error()), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
|
||||||
|
Message: text,
|
||||||
|
ReplyMarkup: markup,
|
||||||
|
Entities: entities,
|
||||||
|
ID: msg.ID,
|
||||||
|
})
|
||||||
|
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleSilentSaveText(ctx *ext.Context, u *ext.Update) error {
|
||||||
|
logger := log.FromContext(ctx)
|
||||||
|
stor := storage.FromContext(ctx)
|
||||||
|
if stor == nil {
|
||||||
|
logger.Warn("Context storage is nil")
|
||||||
|
ctx.Reply(u, ext.ReplyTextString("未找到存储"), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
text := u.EffectiveMessage.Text
|
||||||
|
if text == "" {
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
item, err := parsers.ParseWithContext(ctx, text)
|
||||||
|
if errors.Is(err, parsers.ErrNoParserFound) {
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
logger.Error("Failed to parse text", "error", err)
|
||||||
|
ctx.Reply(u, ext.ReplyTextString("Failed to parse text: "+err.Error()), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
logger.Debug("Parsed item from text message", "title", item.Title, "url", item.URL)
|
||||||
|
userID := u.GetUserChat().GetID()
|
||||||
|
text, entities, err := msgelem.BuildParsedTextEntity(*item)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("Failed to build parsed text entity: %s", err)
|
||||||
|
ctx.Reply(u, ext.ReplyTextString("Failed to build parsed text entity: "+err.Error()), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
msg, err := ctx.SendMessage(userID, &tg.MessagesSendMessageRequest{
|
||||||
|
Message: text,
|
||||||
|
Entities: entities,
|
||||||
|
ReplyTo: &tg.InputReplyToMessage{
|
||||||
|
ReplyToMsgID: u.EffectiveMessage.ID,
|
||||||
|
ReplyToPeerID: u.GetUserChat().AsInputPeer(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("Failed to send message: %s", err)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
return shortcut.CreateAndAddParsedTaskWithEdit(ctx, stor, "", item, msg.ID, userID)
|
||||||
|
}
|
||||||
@@ -16,7 +16,7 @@ import (
|
|||||||
"github.com/krau/SaveAny-Bot/common/utils/tgutil"
|
"github.com/krau/SaveAny-Bot/common/utils/tgutil"
|
||||||
"github.com/krau/SaveAny-Bot/config"
|
"github.com/krau/SaveAny-Bot/config"
|
||||||
"github.com/krau/SaveAny-Bot/core"
|
"github.com/krau/SaveAny-Bot/core"
|
||||||
"github.com/krau/SaveAny-Bot/core/tftask"
|
"github.com/krau/SaveAny-Bot/core/tasks/tfile"
|
||||||
"github.com/krau/SaveAny-Bot/database"
|
"github.com/krau/SaveAny-Bot/database"
|
||||||
"github.com/krau/SaveAny-Bot/pkg/tcbdata"
|
"github.com/krau/SaveAny-Bot/pkg/tcbdata"
|
||||||
"github.com/krau/SaveAny-Bot/storage"
|
"github.com/krau/SaveAny-Bot/storage"
|
||||||
@@ -40,9 +40,11 @@ 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.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))
|
||||||
|
disp.AddHandler(handlers.NewCallbackQuery(filters.CallbackQuery.Prefix(tcbdata.TypeConfig), handleConfigCallback))
|
||||||
linkRegexFilter, err := filters.Message.Regex(re.TgMessageLinkRegexString)
|
linkRegexFilter, err := filters.Message.Regex(re.TgMessageLinkRegexString)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic("failed to create regex filter: " + err.Error())
|
panic("failed to create regex filter: " + err.Error())
|
||||||
@@ -54,8 +56,9 @@ func Register(disp dispatcher.Dispatcher) {
|
|||||||
}
|
}
|
||||||
disp.AddHandler(handlers.NewMessage(telegraphUrlRegexFilter, handleSilentMode(handleTelegraphUrlMessage, handleSilentSaveTelegraph)))
|
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)))
|
||||||
|
|
||||||
if config.Cfg.Telegram.Userbot.Enable {
|
if config.C().Telegram.Userbot.Enable {
|
||||||
go listenMediaMessageEvent(userclient.GetMediaMessageCh())
|
go listenMediaMessageEvent(userclient.GetMediaMessageCh())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -122,7 +125,7 @@ func listenMediaMessageEvent(ch chan userclient.MediaMessageEvent) {
|
|||||||
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()
|
||||||
task, err := tftask.NewTGFileTask(taskid, injectCtx, file, stor, storagePath, nil)
|
task, err := tfile.NewTGFileTask(taskid, injectCtx, file, stor, storagePath, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Errorf("create task failed: %s", err)
|
logger.Errorf("create task failed: %s", err)
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -71,6 +71,6 @@ func handleSilentSaveTelegraph(ctx *ext.Context, update *ext.Update) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
userID := update.GetUserChat().GetID()
|
userID := update.GetUserChat().GetID()
|
||||||
return shortcut.CreateAndAddTphTaskWithEdit(ctx, userID, result.Page, result.TphDir, result.Pics, stor, msg.ID)
|
return shortcut.CreateAndAddtelegraphWithEdit(ctx, userID, result.Page, result.TphDir, result.Pics, stor, msg.ID)
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
39
client/bot/handlers/utils/msgelem/parse.go
Normal file
39
client/bot/handlers/utils/msgelem/parse.go
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
package msgelem
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/duke-git/lancet/v2/strutil"
|
||||||
|
"github.com/gotd/td/telegram/message/entity"
|
||||||
|
"github.com/gotd/td/telegram/message/styling"
|
||||||
|
"github.com/gotd/td/tg"
|
||||||
|
"github.com/krau/SaveAny-Bot/pkg/parser"
|
||||||
|
)
|
||||||
|
|
||||||
|
func BuildParsedTextEntity(item parser.Item) (string, []tg.MessageEntityClass, error) {
|
||||||
|
eb := entity.Builder{}
|
||||||
|
if err := styling.Perform(&eb,
|
||||||
|
styling.Bold(fmt.Sprintf("[%s]%s", item.Site, item.Title)),
|
||||||
|
styling.Plain("\n链接: "),
|
||||||
|
styling.Code(item.URL),
|
||||||
|
styling.Plain("\n作者: "),
|
||||||
|
styling.Code(item.Author),
|
||||||
|
styling.Plain("\n描述: "),
|
||||||
|
styling.Code(strutil.Ellipsis(item.Description, 233)),
|
||||||
|
styling.Plain("\n文件数量: "),
|
||||||
|
styling.Code(fmt.Sprintf("%d", len(item.Resources))),
|
||||||
|
styling.Plain("\n预计总大小: "),
|
||||||
|
styling.Code(fmt.Sprintf("%.2f MB", func() float64 {
|
||||||
|
var totalSize int64
|
||||||
|
for _, res := range item.Resources {
|
||||||
|
totalSize += res.Size
|
||||||
|
}
|
||||||
|
return float64(totalSize) / 1024 / 1024
|
||||||
|
}())),
|
||||||
|
styling.Plain("\n请选择存储位置"),
|
||||||
|
); err != nil {
|
||||||
|
return "", nil, fmt.Errorf("构建消息失败: %w", err)
|
||||||
|
}
|
||||||
|
text, entities := eb.Complete()
|
||||||
|
return text, entities, nil
|
||||||
|
}
|
||||||
@@ -24,6 +24,8 @@ func BuildAddSelectStorageKeyboard(stors []storage.Storage, adddata tcbdata.Add)
|
|||||||
taskType = tasktype.TaskTypeTgfiles
|
taskType = tasktype.TaskTypeTgfiles
|
||||||
} else if adddata.TphPageNode != nil {
|
} else if adddata.TphPageNode != nil {
|
||||||
taskType = tasktype.TaskTypeTphpics
|
taskType = tasktype.TaskTypeTphpics
|
||||||
|
} else if adddata.ParsedItem != nil {
|
||||||
|
taskType = tasktype.TaskTypeParseditem
|
||||||
} else {
|
} else {
|
||||||
return nil, fmt.Errorf("unknown task type: %s", taskType)
|
return nil, fmt.Errorf("unknown task type: %s", taskType)
|
||||||
}
|
}
|
||||||
@@ -41,6 +43,8 @@ func BuildAddSelectStorageKeyboard(stors []storage.Storage, adddata tcbdata.Add)
|
|||||||
TphPageNode: adddata.TphPageNode,
|
TphPageNode: adddata.TphPageNode,
|
||||||
TphPics: adddata.TphPics,
|
TphPics: adddata.TphPics,
|
||||||
TphDirPath: adddata.TphDirPath,
|
TphDirPath: adddata.TphDirPath,
|
||||||
|
|
||||||
|
ParsedItem: adddata.ParsedItem,
|
||||||
}
|
}
|
||||||
dataid := xid.New().String()
|
dataid := xid.New().String()
|
||||||
err := cache.Set(dataid, data)
|
err := cache.Set(dataid, data)
|
||||||
|
|||||||
@@ -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,7 +45,7 @@ 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) (matchedStorageName matchedStorName, dirPath MatchedDirPath) {
|
||||||
@@ -57,7 +55,7 @@ func ApplyRule(ctx context.Context, rules []database.Rule, inputs *ruleInput) (m
|
|||||||
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
|
||||||
|
|||||||
@@ -20,20 +20,21 @@ 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)
|
||||||
media := message.Media
|
media := message.Media
|
||||||
supported := mediautil.IsSupported(media)
|
supported := mediautil.IsSupported(media)
|
||||||
if !supported {
|
if !supported {
|
||||||
ctx.Reply(update, ext.ReplyTextString("不支持的消息类型"), nil)
|
return nil, nil, dispatcher.ContinueGroups
|
||||||
return nil, nil, dispatcher.EndGroups
|
|
||||||
}
|
}
|
||||||
|
|
||||||
replied, err = ctx.Reply(update, ext.ReplyTextString("正在获取文件信息..."), nil)
|
replied, err = ctx.Reply(update, ext.ReplyTextString("正在获取文件信息..."), nil)
|
||||||
@@ -41,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 {
|
||||||
@@ -82,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 {
|
||||||
@@ -94,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
|
||||||
@@ -103,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()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
35
client/bot/handlers/utils/shortcut/parsed.go
Normal file
35
client/bot/handlers/utils/shortcut/parsed.go
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
package shortcut
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/celestix/gotgproto/dispatcher"
|
||||||
|
"github.com/celestix/gotgproto/ext"
|
||||||
|
"github.com/charmbracelet/log"
|
||||||
|
"github.com/gotd/td/tg"
|
||||||
|
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/msgelem"
|
||||||
|
"github.com/krau/SaveAny-Bot/common/utils/tgutil"
|
||||||
|
"github.com/krau/SaveAny-Bot/core"
|
||||||
|
"github.com/krau/SaveAny-Bot/core/tasks/parsed"
|
||||||
|
"github.com/krau/SaveAny-Bot/pkg/parser"
|
||||||
|
"github.com/krau/SaveAny-Bot/storage"
|
||||||
|
"github.com/rs/xid"
|
||||||
|
)
|
||||||
|
|
||||||
|
func CreateAndAddParsedTaskWithEdit(ctx *ext.Context, stor storage.Storage, dirPath string, item *parser.Item, msgID int, userID int64) error {
|
||||||
|
injectCtx := tgutil.ExtWithContext(ctx.Context, ctx)
|
||||||
|
task := parsed.NewTask(xid.New().String(), injectCtx, stor, stor.JoinStoragePath(dirPath), item, parsed.NewProgress(msgID, userID))
|
||||||
|
if err := core.AddTask(injectCtx, task); err != nil {
|
||||||
|
log.FromContext(ctx).Errorf("Failed to add task: %s", err)
|
||||||
|
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
|
||||||
|
ID: msgID,
|
||||||
|
Message: "任务添加失败: " + err.Error(),
|
||||||
|
})
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
text, entities := msgelem.BuildTaskAddedEntities(ctx, item.Title, core.GetLength(ctx))
|
||||||
|
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
|
||||||
|
ID: msgID,
|
||||||
|
Message: text,
|
||||||
|
Entities: entities,
|
||||||
|
})
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
@@ -13,15 +13,15 @@ import (
|
|||||||
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/ruleutil"
|
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/ruleutil"
|
||||||
"github.com/krau/SaveAny-Bot/common/utils/tgutil"
|
"github.com/krau/SaveAny-Bot/common/utils/tgutil"
|
||||||
"github.com/krau/SaveAny-Bot/core"
|
"github.com/krau/SaveAny-Bot/core"
|
||||||
"github.com/krau/SaveAny-Bot/core/batchtftask"
|
"github.com/krau/SaveAny-Bot/core/tasks/batchtfile"
|
||||||
"github.com/krau/SaveAny-Bot/core/tftask"
|
tftask "github.com/krau/SaveAny-Bot/core/tasks/tfile"
|
||||||
"github.com/krau/SaveAny-Bot/database"
|
"github.com/krau/SaveAny-Bot/database"
|
||||||
"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"
|
||||||
"github.com/rs/xid"
|
"github.com/rs/xid"
|
||||||
)
|
)
|
||||||
|
|
||||||
// 创建一个 tftask.TGFileTask 并添加到任务队列中, 以编辑消息的方式反馈结果
|
// 创建一个 tfile.TGFileTask 并添加到任务队列中, 以编辑消息的方式反馈结果
|
||||||
func CreateAndAddTGFileTaskWithEdit(ctx *ext.Context, userID int64, stor storage.Storage, dirPath string, file tfile.TGFileMessage, trackMsgID int) error {
|
func CreateAndAddTGFileTaskWithEdit(ctx *ext.Context, userID int64, stor storage.Storage, dirPath string, file tfile.TGFileMessage, trackMsgID int) error {
|
||||||
logger := log.FromContext(ctx)
|
logger := log.FromContext(ctx)
|
||||||
user, err := database.GetUserByChatID(ctx, userID)
|
user, err := database.GetUserByChatID(ctx, userID)
|
||||||
@@ -82,7 +82,7 @@ func CreateAndAddTGFileTaskWithEdit(ctx *ext.Context, userID int64, stor storage
|
|||||||
return dispatcher.EndGroups
|
return dispatcher.EndGroups
|
||||||
}
|
}
|
||||||
|
|
||||||
// 创建一个 batchtftask.BatchTGFileTask 并添加到任务队列中, 以编辑消息的方式反馈结果
|
// 创建一个 batchtfile.BatchTGFileTask 并添加到任务队列中, 以编辑消息的方式反馈结果
|
||||||
func CreateAndAddBatchTGFileTaskWithEdit(ctx *ext.Context, userID int64, stor storage.Storage, dirPath string, files []tfile.TGFileMessage, trackMsgID int) error {
|
func CreateAndAddBatchTGFileTaskWithEdit(ctx *ext.Context, userID int64, stor storage.Storage, dirPath string, files []tfile.TGFileMessage, trackMsgID int) error {
|
||||||
logger := log.FromContext(ctx)
|
logger := log.FromContext(ctx)
|
||||||
user, err := database.GetUserByChatID(ctx, userID)
|
user, err := database.GetUserByChatID(ctx, userID)
|
||||||
@@ -110,7 +110,7 @@ func CreateAndAddBatchTGFileTaskWithEdit(ctx *ext.Context, userID int64, stor st
|
|||||||
return storname, dirP
|
return storname, dirP
|
||||||
}
|
}
|
||||||
|
|
||||||
elems := make([]batchtftask.TaskElement, 0, len(files))
|
elems := make([]batchtfile.TaskElement, 0, len(files))
|
||||||
type albumFile struct {
|
type albumFile struct {
|
||||||
file tfile.TGFileMessage
|
file tfile.TGFileMessage
|
||||||
storage storage.Storage
|
storage storage.Storage
|
||||||
@@ -132,7 +132,7 @@ func CreateAndAddBatchTGFileTaskWithEdit(ctx *ext.Context, userID int64, stor st
|
|||||||
}
|
}
|
||||||
if !dirPath.NeedNewForAlbum() {
|
if !dirPath.NeedNewForAlbum() {
|
||||||
storPath := fileStor.JoinStoragePath(path.Join(dirPath.String(), file.Name()))
|
storPath := fileStor.JoinStoragePath(path.Join(dirPath.String(), file.Name()))
|
||||||
elem, err := batchtftask.NewTaskElement(fileStor, storPath, file)
|
elem, err := batchtfile.NewTaskElement(fileStor, storPath, file)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Errorf("Failed to create task element: %s", err)
|
logger.Errorf("Failed to create task element: %s", err)
|
||||||
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
|
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
|
||||||
@@ -167,7 +167,7 @@ func CreateAndAddBatchTGFileTaskWithEdit(ctx *ext.Context, userID int64, stor st
|
|||||||
albumStor := afiles[0].storage
|
albumStor := afiles[0].storage
|
||||||
for _, af := range afiles {
|
for _, af := range afiles {
|
||||||
afstorPath := af.storage.JoinStoragePath(path.Join(dirPath, albumDir, af.file.Name()))
|
afstorPath := af.storage.JoinStoragePath(path.Join(dirPath, albumDir, af.file.Name()))
|
||||||
elem, err := batchtftask.NewTaskElement(albumStor, afstorPath, af.file)
|
elem, err := batchtfile.NewTaskElement(albumStor, afstorPath, af.file)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Errorf("Failed to create task element for album file: %s", err)
|
logger.Errorf("Failed to create task element for album file: %s", err)
|
||||||
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
|
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
|
||||||
@@ -182,7 +182,7 @@ func CreateAndAddBatchTGFileTaskWithEdit(ctx *ext.Context, userID int64, stor st
|
|||||||
|
|
||||||
injectCtx := tgutil.ExtWithContext(ctx.Context, ctx)
|
injectCtx := tgutil.ExtWithContext(ctx.Context, ctx)
|
||||||
taskid := xid.New().String()
|
taskid := xid.New().String()
|
||||||
task := batchtftask.NewBatchTGFileTask(taskid, injectCtx, elems, batchtftask.NewProgressTracker(trackMsgID, userID), true)
|
task := batchtfile.NewBatchTGFileTask(taskid, injectCtx, elems, batchtfile.NewProgressTracker(trackMsgID, userID), true)
|
||||||
if err := core.AddTask(injectCtx, task); err != nil {
|
if err := core.AddTask(injectCtx, task); err != nil {
|
||||||
logger.Errorf("Failed to add batch task: %s", err)
|
logger.Errorf("Failed to add batch task: %s", err)
|
||||||
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
|
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
|
||||||
|
|||||||
@@ -9,19 +9,21 @@ 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/core"
|
"github.com/krau/SaveAny-Bot/core"
|
||||||
"github.com/krau/SaveAny-Bot/core/tphtask"
|
tphtask "github.com/krau/SaveAny-Bot/core/tasks/telegraph"
|
||||||
"github.com/krau/SaveAny-Bot/pkg/telegraph"
|
"github.com/krau/SaveAny-Bot/pkg/telegraph"
|
||||||
"github.com/krau/SaveAny-Bot/storage"
|
"github.com/krau/SaveAny-Bot/storage"
|
||||||
"github.com/rs/xid"
|
"github.com/rs/xid"
|
||||||
)
|
)
|
||||||
|
|
||||||
func CreateAndAddTphTaskWithEdit(ctx *ext.Context,
|
func CreateAndAddtelegraphWithEdit(
|
||||||
|
ctx *ext.Context,
|
||||||
userID int64,
|
userID int64,
|
||||||
tphpage *telegraph.Page,
|
tphpage *telegraph.Page,
|
||||||
dirPath string, // unescaped ph path for file storage
|
dirPath string, // unescaped ph path for file storage
|
||||||
pics []string,
|
pics []string,
|
||||||
stor storage.Storage,
|
stor storage.Storage,
|
||||||
trackMsgID int) error {
|
trackMsgID int) error {
|
||||||
|
|
||||||
injectCtx := tgutil.ExtWithContext(ctx.Context, ctx)
|
injectCtx := tgutil.ExtWithContext(ctx.Context, ctx)
|
||||||
task := tphtask.NewTask(xid.New().String(),
|
task := tphtask.NewTask(xid.New().String(),
|
||||||
injectCtx,
|
injectCtx,
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import (
|
|||||||
|
|
||||||
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(msgelem.WatchHelpText), nil)
|
||||||
return dispatcher.EndGroups
|
return dispatcher.EndGroups
|
||||||
@@ -82,7 +82,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
|
||||||
|
|||||||
@@ -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(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,11 +2,11 @@ package recovery
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/cenkalti/backoff/v4"
|
"github.com/cenkalti/backoff/v4"
|
||||||
"github.com/charmbracelet/log"
|
"github.com/charmbracelet/log"
|
||||||
"github.com/go-faster/errors"
|
|
||||||
"github.com/gotd/td/bin"
|
"github.com/gotd/td/bin"
|
||||||
"github.com/gotd/td/telegram"
|
"github.com/gotd/td/telegram"
|
||||||
"github.com/gotd/td/tg"
|
"github.com/gotd/td/tg"
|
||||||
@@ -31,7 +31,7 @@ func (r *recovery) Handle(next tg.Invoker) telegram.InvokeFunc {
|
|||||||
return backoff.RetryNotify(func() error {
|
return backoff.RetryNotify(func() error {
|
||||||
if err := next.Invoke(ctx, input, output); err != nil {
|
if err := next.Invoke(ctx, input, output); err != nil {
|
||||||
if r.shouldRecover(ctx, err) {
|
if r.shouldRecover(ctx, err) {
|
||||||
return errors.Wrap(err, "recover")
|
return fmt.Errorf("recovery: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return backoff.Permanent(err)
|
return backoff.Permanent(err)
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
24
cmd/run.go
24
cmd/run.go
@@ -19,6 +19,7 @@ import (
|
|||||||
"github.com/krau/SaveAny-Bot/config"
|
"github.com/krau/SaveAny-Bot/config"
|
||||||
"github.com/krau/SaveAny-Bot/core"
|
"github.com/krau/SaveAny-Bot/core"
|
||||||
"github.com/krau/SaveAny-Bot/database"
|
"github.com/krau/SaveAny-Bot/database"
|
||||||
|
"github.com/krau/SaveAny-Bot/parsers"
|
||||||
"github.com/krau/SaveAny-Bot/storage"
|
"github.com/krau/SaveAny-Bot/storage"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
@@ -49,11 +50,20 @@ func initAll(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
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.Initing))
|
||||||
database.Init(ctx)
|
database.Init(ctx)
|
||||||
storage.LoadStorages(ctx)
|
storage.LoadStorages(ctx)
|
||||||
if config.Cfg.Telegram.Userbot.Enable {
|
if config.C().Parser.PluginEnable {
|
||||||
|
for _, dir := range config.C().Parser.PluginDirs {
|
||||||
|
if err := parsers.LoadPlugins(ctx, dir); err != nil {
|
||||||
|
logger.Error("Failed to load parser plugins", "dir", dir, "error", err)
|
||||||
|
} else {
|
||||||
|
logger.Debug("Loaded parser plugins", "dir", dir)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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.Fatalf("User client login failed: %s", err)
|
||||||
@@ -63,13 +73,13 @@ func initAll(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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.InvalidCacheDir, map[string]any{
|
||||||
"Path": config.Cfg.Temp.BasePath,
|
"Path": config.C().Temp.BasePath,
|
||||||
}))
|
}))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -80,7 +90,7 @@ func cleanCache() {
|
|||||||
}))
|
}))
|
||||||
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.GetCacheAbsPathFailed, map[string]any{
|
||||||
|
|||||||
@@ -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, "krau/SaveAny-Bot")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Println("Binary update failed:", err)
|
fmt.Println("Binary update failed:", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if latest.Version.Equals(v) {
|
if latest.Version.Equals(v) {
|
||||||
fmt.Println("Current binary is the latest version", 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)
|
||||||
|
|||||||
6
common/cache/ristretto.go
vendored
6
common/cache/ristretto.go
vendored
@@ -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")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
15
config/parser.go
Normal file
15
config/parser.go
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
type parserConfig struct {
|
||||||
|
PluginEnable bool `toml:"plugin_enable" mapstructure:"plugin_enable" json:"plugin_enable"`
|
||||||
|
PluginDirs []string `toml:"plugin_dirs" mapstructure:"plugin_dirs" json:"plugin_dirs"`
|
||||||
|
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]
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@ type telegramConfig struct {
|
|||||||
AppHash string `toml:"app_hash" mapstructure:"app_hash" json:"app_hash"`
|
AppHash string `toml:"app_hash" mapstructure:"app_hash" json:"app_hash"`
|
||||||
Proxy tgProxyConfig `toml:"proxy" mapstructure:"proxy"`
|
Proxy tgProxyConfig `toml:"proxy" mapstructure:"proxy"`
|
||||||
RpcRetry int `toml:"rpc_retry" mapstructure:"rpc_retry" json:"rpc_retry"`
|
RpcRetry int `toml:"rpc_retry" mapstructure:"rpc_retry" json:"rpc_retry"`
|
||||||
Userbot userbotConfig `toml:"userbot" mapstructure:"userbot" json:"userbot"` // [TODO]
|
Userbot userbotConfig `toml:"userbot" mapstructure:"userbot" json:"userbot"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type userbotConfig struct {
|
type userbotConfig struct {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package consts
|
package config
|
||||||
|
|
||||||
// inject version by '-X' flag
|
// inject version by '-X' flag
|
||||||
// go build -ldflags "-X github.com/krau/SaveAny-Bot/pkg/consts.Version=${{ env.VERSION }}"
|
// go build -ldflags "-X github.com/krau/SaveAny-Bot/config.Version=${{ env.VERSION }}"
|
||||||
var (
|
var (
|
||||||
Version string = "dev"
|
Version string = "dev"
|
||||||
BuildTime string = "unknown"
|
BuildTime string = "unknown"
|
||||||
@@ -28,10 +28,15 @@ type Config struct {
|
|||||||
DB dbConfig `toml:"db" mapstructure:"db"`
|
DB dbConfig `toml:"db" mapstructure:"db"`
|
||||||
Telegram telegramConfig `toml:"telegram" mapstructure:"telegram"`
|
Telegram telegramConfig `toml:"telegram" mapstructure:"telegram"`
|
||||||
Storages []storage.StorageConfig `toml:"-" mapstructure:"-" json:"storages"`
|
Storages []storage.StorageConfig `toml:"-" mapstructure:"-" json:"storages"`
|
||||||
|
Parser parserConfig `toml:"parser" mapstructure:"parser" json:"parser"`
|
||||||
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 {
|
||||||
@@ -94,7 +99,7 @@ func Init(ctx context.Context) error {
|
|||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
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)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
@@ -103,36 +108,36 @@ func Init(ctx context.Context) error {
|
|||||||
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.ConfigInvalidDuplicateStorageName, 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.LoadedStorages, 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 || cfg.Retry < 1 {
|
||||||
return errors.New(i18n.TWithoutInit(Cfg.Lang, i18nk.ConfigInvalidWorkersOrRetry, map[string]any{
|
return errors.New(i18n.TWithoutInit(cfg.Lang, i18nk.ConfigInvalidWorkersOrRetry, map[string]any{
|
||||||
"Workers": Cfg.Workers,
|
"Workers": cfg.Workers,
|
||||||
"Retry": Cfg.Retry,
|
"Retry": cfg.Retry,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
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))
|
||||||
@@ -142,20 +147,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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
package batchtftask
|
package batchtfile
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
@@ -21,18 +21,23 @@ 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 {
|
||||||
elem := elem
|
|
||||||
eg.Go(func() error {
|
eg.Go(func() error {
|
||||||
|
t.processingMu.RLock()
|
||||||
if t.processing[elem.ID] != nil {
|
if t.processing[elem.ID] != nil {
|
||||||
return fmt.Errorf("element with ID %s is already being processed", elem.ID)
|
return fmt.Errorf("element with ID %s is already being processed", elem.ID)
|
||||||
}
|
}
|
||||||
|
t.processingMu.RUnlock()
|
||||||
|
t.processingMu.Lock()
|
||||||
t.processing[elem.ID] = &elem
|
t.processing[elem.ID] = &elem
|
||||||
|
t.processingMu.Unlock()
|
||||||
defer func() {
|
defer func() {
|
||||||
|
t.processingMu.Lock()
|
||||||
delete(t.processing, elem.ID)
|
delete(t.processing, elem.ID)
|
||||||
|
t.processingMu.Unlock()
|
||||||
}()
|
}()
|
||||||
return t.processElement(gctx, elem)
|
return t.processElement(gctx, elem)
|
||||||
})
|
})
|
||||||
@@ -119,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
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package batchtftask
|
package batchtfile
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
package batchtftask
|
package batchtfile
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
|
|
||||||
"github.com/krau/SaveAny-Bot/config"
|
"github.com/krau/SaveAny-Bot/config"
|
||||||
@@ -31,7 +32,8 @@ type Task struct {
|
|||||||
downloaded atomic.Int64
|
downloaded atomic.Int64
|
||||||
totalSize int64
|
totalSize int64
|
||||||
processing map[string]TaskElementInfo
|
processing map[string]TaskElementInfo
|
||||||
failed map[string]error // errors for each element
|
processingMu sync.RWMutex
|
||||||
|
failed map[string]error // [TODO] errors for each element
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *Task) Type() tasktype.TaskType {
|
func (t *Task) Type() tasktype.TaskType {
|
||||||
@@ -45,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)
|
||||||
}
|
}
|
||||||
@@ -89,6 +91,7 @@ func NewBatchTGFileTask(
|
|||||||
}(),
|
}(),
|
||||||
processing: make(map[string]TaskElementInfo),
|
processing: make(map[string]TaskElementInfo),
|
||||||
IgnoreErrors: ignoreErrors,
|
IgnoreErrors: ignoreErrors,
|
||||||
|
processingMu: sync.RWMutex{},
|
||||||
failed: make(map[string]error),
|
failed: make(map[string]error),
|
||||||
}
|
}
|
||||||
return task
|
return task
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package batchtftask
|
package batchtfile
|
||||||
|
|
||||||
type TaskElementInfo interface {
|
type TaskElementInfo interface {
|
||||||
FileName() string
|
FileName() string
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package batchtftask
|
package batchtfile
|
||||||
|
|
||||||
var progressUpdatesLevels = []struct {
|
var progressUpdatesLevels = []struct {
|
||||||
size int64 // 文件大小阈值
|
size int64 // 文件大小阈值
|
||||||
139
core/tasks/parsed/execute.go
Normal file
139
core/tasks/parsed/execute.go
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
package parsed
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"path"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/log"
|
||||||
|
"github.com/duke-git/lancet/v2/retry"
|
||||||
|
"github.com/krau/SaveAny-Bot/common/utils/fsutil"
|
||||||
|
"github.com/krau/SaveAny-Bot/common/utils/ioutil"
|
||||||
|
"github.com/krau/SaveAny-Bot/config"
|
||||||
|
"github.com/krau/SaveAny-Bot/pkg/enums/ctxkey"
|
||||||
|
"github.com/krau/SaveAny-Bot/pkg/parser"
|
||||||
|
"golang.org/x/sync/errgroup"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (t *Task) Execute(ctx context.Context) error {
|
||||||
|
logger := log.FromContext(ctx)
|
||||||
|
logger.Infof("Starting Parsed item task %s", t.item.Title)
|
||||||
|
if t.progress != nil {
|
||||||
|
t.progress.OnStart(ctx, t)
|
||||||
|
}
|
||||||
|
eg, gctx := errgroup.WithContext(ctx)
|
||||||
|
eg.SetLimit(config.C().Workers)
|
||||||
|
for _, resource := range t.item.Resources {
|
||||||
|
eg.Go(func() error {
|
||||||
|
t.processingMu.RLock()
|
||||||
|
if t.processing[resource.ID()] != nil {
|
||||||
|
return fmt.Errorf("resource %s is already being processed", resource.ID())
|
||||||
|
}
|
||||||
|
t.processingMu.RUnlock()
|
||||||
|
t.processingMu.Lock()
|
||||||
|
t.processing[resource.ID()] = &resource
|
||||||
|
t.processingMu.Unlock()
|
||||||
|
defer func() {
|
||||||
|
t.processingMu.Lock()
|
||||||
|
delete(t.processing, resource.URL)
|
||||||
|
t.processingMu.Unlock()
|
||||||
|
}()
|
||||||
|
err := t.processResource(gctx, resource)
|
||||||
|
t.downloaded.Add(1)
|
||||||
|
if errors.Is(err, context.Canceled) {
|
||||||
|
logger.Debug("Resource processing canceled")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("Error processing resource %s: %v", resource.URL, err)
|
||||||
|
return fmt.Errorf("failed to process resource %s: %w", resource.URL, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
err := eg.Wait()
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("Error during Parsed item task execution: %v", err)
|
||||||
|
} else {
|
||||||
|
logger.Infof("Parsed item task %s completed successfully", t.item.Title)
|
||||||
|
}
|
||||||
|
if t.progress != nil {
|
||||||
|
t.progress.OnDone(ctx, t, err)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Task) processResource(ctx context.Context, resource parser.Resource) error {
|
||||||
|
logger := log.FromContext(ctx)
|
||||||
|
err := retry.Retry(func() error {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, resource.URL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if resource.Headers != nil {
|
||||||
|
for k, v := range resource.Headers {
|
||||||
|
req.Header.Set(k, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
resp, err := t.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to download resource %s: %w", resource.URL, err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return fmt.Errorf("failed to download resource %s: %s", resource.URL, resp.Status)
|
||||||
|
}
|
||||||
|
ctx = context.WithValue(ctx, ctxkey.ContentLength, func() int64 {
|
||||||
|
if resource.Size > 0 {
|
||||||
|
return resource.Size
|
||||||
|
}
|
||||||
|
return resp.ContentLength
|
||||||
|
}())
|
||||||
|
if t.stream {
|
||||||
|
return t.Stor.Save(ctx, resp.Body, path.Join(t.StorPath, resource.Filename))
|
||||||
|
}
|
||||||
|
cacheFile, err := fsutil.CreateFile(filepath.Join(config.C().Temp.BasePath,
|
||||||
|
fmt.Sprintf("resource_%s_%s", t.ID, resource.Filename)))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create cache file for resource %s: %w", resource.URL, err)
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if err := cacheFile.CloseAndRemove(); err != nil {
|
||||||
|
logger.Errorf("Failed to close and remove cache file: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
wr := ioutil.NewProgressWriter(cacheFile, func(n int) {
|
||||||
|
t.downloadedBytes.Add(int64(n))
|
||||||
|
if t.progress != nil {
|
||||||
|
t.progress.OnProgress(ctx, t)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
copyResultCh := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
_, err := io.Copy(wr, resp.Body)
|
||||||
|
copyResultCh <- err
|
||||||
|
}()
|
||||||
|
select {
|
||||||
|
case err := <-copyResultCh:
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to copy resource %s to cache file: %w", resource.URL, err)
|
||||||
|
}
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
}
|
||||||
|
_, err = cacheFile.Seek(0, 0)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to seek cache file for resource %s: %w", resource.URL, err)
|
||||||
|
}
|
||||||
|
return t.Stor.Save(ctx, cacheFile, path.Join(t.StorPath, resource.Filename))
|
||||||
|
}, retry.Context(ctx), retry.RetryTimes(uint(config.C().Retry)))
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return ctx.Err()
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
209
core/tasks/parsed/progress.go
Normal file
209
core/tasks/parsed/progress.go
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
package parsed
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/log"
|
||||||
|
"github.com/duke-git/lancet/v2/slice"
|
||||||
|
"github.com/gotd/td/telegram/message/entity"
|
||||||
|
"github.com/gotd/td/telegram/message/styling"
|
||||||
|
"github.com/gotd/td/tg"
|
||||||
|
"github.com/krau/SaveAny-Bot/common/utils/dlutil"
|
||||||
|
"github.com/krau/SaveAny-Bot/common/utils/tgutil"
|
||||||
|
)
|
||||||
|
|
||||||
|
var progressUpdatesLevels = []struct {
|
||||||
|
size int64 // 文件大小阈值
|
||||||
|
stepPercent int // 每多少 % 更新一次
|
||||||
|
}{
|
||||||
|
{10 << 20, 100},
|
||||||
|
{50 << 20, 50},
|
||||||
|
{200 << 20, 20},
|
||||||
|
{500 << 20, 10},
|
||||||
|
}
|
||||||
|
|
||||||
|
func shouldUpdateProgress(total, downloaded int64, lastUpdatePercent int) bool {
|
||||||
|
if total <= 0 || downloaded <= 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
percent := int((downloaded * 100) / total)
|
||||||
|
if percent <= lastUpdatePercent {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
step := progressUpdatesLevels[len(progressUpdatesLevels)-1].stepPercent
|
||||||
|
for _, lvl := range progressUpdatesLevels {
|
||||||
|
if total < lvl.size {
|
||||||
|
step = lvl.stepPercent
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return percent >= lastUpdatePercent+step
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProgressTracker interface {
|
||||||
|
OnStart(ctx context.Context, info TaskInfo)
|
||||||
|
OnProgress(ctx context.Context, info TaskInfo)
|
||||||
|
OnDone(ctx context.Context, info TaskInfo, err error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type Progress struct {
|
||||||
|
MessageID int
|
||||||
|
ChatID int64
|
||||||
|
start time.Time
|
||||||
|
lastUpdatePercent atomic.Int32
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Progress) OnStart(ctx context.Context, info TaskInfo) {
|
||||||
|
logger := log.FromContext(ctx)
|
||||||
|
p.start = time.Now()
|
||||||
|
p.lastUpdatePercent.Store(0)
|
||||||
|
logger.Debugf("Parsed task progress tracking started for message %d in chat %d", p.MessageID, p.ChatID)
|
||||||
|
entityBuilder := entity.Builder{}
|
||||||
|
var entities []tg.MessageEntityClass
|
||||||
|
if err := styling.Perform(&entityBuilder,
|
||||||
|
styling.Plain(fmt.Sprintf("开始下载 %s 的资源\n总大小: ", info.Site())),
|
||||||
|
styling.Code(fmt.Sprintf("%.2f MB (%d个资源)", float64(info.TotalBytes())/(1024*1024), info.TotalResources())),
|
||||||
|
); err != nil {
|
||||||
|
log.FromContext(ctx).Errorf("Failed to build entities: %s", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
text, entities := entityBuilder.Complete()
|
||||||
|
req := &tg.MessagesEditMessageRequest{
|
||||||
|
ID: p.MessageID,
|
||||||
|
}
|
||||||
|
req.SetMessage(text)
|
||||||
|
req.SetEntities(entities)
|
||||||
|
req.SetReplyMarkup(&tg.ReplyInlineMarkup{
|
||||||
|
Rows: []tg.KeyboardButtonRow{
|
||||||
|
{
|
||||||
|
Buttons: []tg.KeyboardButtonClass{
|
||||||
|
tgutil.BuildCancelButton(info.TaskID()),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}},
|
||||||
|
)
|
||||||
|
ext := tgutil.ExtFromContext(ctx)
|
||||||
|
if ext != nil {
|
||||||
|
ext.EditMessage(p.ChatID, req)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Progress) OnProgress(ctx context.Context, info TaskInfo) {
|
||||||
|
if !shouldUpdateProgress(info.TotalBytes(), info.DownloadedBytes(), int(p.lastUpdatePercent.Load())) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
percent := int((info.DownloadedBytes() * 100) / info.TotalBytes())
|
||||||
|
if p.lastUpdatePercent.Load() == int32(percent) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p.lastUpdatePercent.Store(int32(percent))
|
||||||
|
log.FromContext(ctx).Debugf("Progress update: %s, %d/%d", info.TaskID(), info.DownloadedBytes(), info.TotalBytes())
|
||||||
|
entityBuilder := entity.Builder{}
|
||||||
|
var entities []tg.MessageEntityClass
|
||||||
|
if err := styling.Perform(&entityBuilder,
|
||||||
|
styling.Plain("正在下载\n总大小: "),
|
||||||
|
styling.Code(fmt.Sprintf("%.2f MB (%d个文件)", float64(info.TotalBytes())/(1024*1024), info.TotalResources())),
|
||||||
|
styling.Plain("\n正在处理:\n"),
|
||||||
|
func() styling.StyledTextOption {
|
||||||
|
var lines []string
|
||||||
|
for _, elem := range info.Processing() {
|
||||||
|
lines = append(lines, fmt.Sprintf(" - %s (%.2f MB)", elem.FileName(), float64(elem.FileSize())/(1024*1024)))
|
||||||
|
}
|
||||||
|
if len(lines) == 0 {
|
||||||
|
lines = append(lines, " - 无")
|
||||||
|
}
|
||||||
|
return styling.Plain(slice.Join(lines, "\n"))
|
||||||
|
}(),
|
||||||
|
styling.Plain("\n平均速度: "),
|
||||||
|
styling.Bold(fmt.Sprintf("%.2f MB/s", dlutil.GetSpeed(info.DownloadedBytes(), p.start)/(1024*1024))),
|
||||||
|
styling.Plain("\n当前进度: "),
|
||||||
|
styling.Bold(fmt.Sprintf("%.2f%%", float64(info.DownloadedBytes())/float64(info.TotalBytes())*100)),
|
||||||
|
); err != nil {
|
||||||
|
log.FromContext(ctx).Errorf("Failed to build entities: %s", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
text, entities := entityBuilder.Complete()
|
||||||
|
req := &tg.MessagesEditMessageRequest{
|
||||||
|
ID: p.MessageID,
|
||||||
|
}
|
||||||
|
req.SetMessage(text)
|
||||||
|
req.SetEntities(entities)
|
||||||
|
req.SetReplyMarkup(&tg.ReplyInlineMarkup{
|
||||||
|
Rows: []tg.KeyboardButtonRow{
|
||||||
|
{
|
||||||
|
Buttons: []tg.KeyboardButtonClass{
|
||||||
|
tgutil.BuildCancelButton(info.TaskID()),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}},
|
||||||
|
)
|
||||||
|
ext := tgutil.ExtFromContext(ctx)
|
||||||
|
if ext != nil {
|
||||||
|
ext.EditMessage(p.ChatID, req)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Progress) OnDone(ctx context.Context, info TaskInfo, err error) {
|
||||||
|
logger := log.FromContext(ctx)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, context.Canceled) {
|
||||||
|
logger.Infof("Parsed task %s was canceled", info.TaskID())
|
||||||
|
ext := tgutil.ExtFromContext(ctx)
|
||||||
|
if ext != nil {
|
||||||
|
ext.EditMessage(p.ChatID, &tg.MessagesEditMessageRequest{
|
||||||
|
ID: p.MessageID,
|
||||||
|
Message: fmt.Sprintf("处理已取消: %s", info.TaskID()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.Errorf("Parsed task %s failed: %s", info.TaskID(), err)
|
||||||
|
ext := tgutil.ExtFromContext(ctx)
|
||||||
|
if ext != nil {
|
||||||
|
ext.EditMessage(p.ChatID, &tg.MessagesEditMessageRequest{
|
||||||
|
ID: p.MessageID,
|
||||||
|
Message: fmt.Sprintf("处理失败: %s", err.Error()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
logger.Infof("Parsed task %s completed successfully", info.TaskID())
|
||||||
|
|
||||||
|
entityBuilder := entity.Builder{}
|
||||||
|
if err := styling.Perform(&entityBuilder,
|
||||||
|
styling.Plain("处理完成, 资源数量: "),
|
||||||
|
styling.Code(fmt.Sprintf("%d", info.TotalResources())),
|
||||||
|
styling.Plain("\n保存路径: "),
|
||||||
|
styling.Code(fmt.Sprintf("[%s]:%s", info.StorageName(), info.StoragePath())),
|
||||||
|
); err != nil {
|
||||||
|
logger.Errorf("Failed to build entities: %s", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
text, entities := entityBuilder.Complete()
|
||||||
|
req := &tg.MessagesEditMessageRequest{
|
||||||
|
ID: p.MessageID,
|
||||||
|
}
|
||||||
|
req.SetMessage(text)
|
||||||
|
req.SetEntities(entities)
|
||||||
|
|
||||||
|
ext := tgutil.ExtFromContext(ctx)
|
||||||
|
if ext != nil {
|
||||||
|
ext.EditMessage(p.ChatID, req)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewProgress(messageID int, chatID int64) *Progress {
|
||||||
|
return &Progress{
|
||||||
|
MessageID: messageID,
|
||||||
|
ChatID: chatID,
|
||||||
|
}
|
||||||
|
}
|
||||||
84
core/tasks/parsed/task.go
Normal file
84
core/tasks/parsed/task.go
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
package parsed
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
|
||||||
|
"github.com/krau/SaveAny-Bot/config"
|
||||||
|
"github.com/krau/SaveAny-Bot/pkg/enums/tasktype"
|
||||||
|
"github.com/krau/SaveAny-Bot/pkg/parser"
|
||||||
|
"github.com/krau/SaveAny-Bot/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Task struct {
|
||||||
|
ID string
|
||||||
|
Ctx context.Context
|
||||||
|
Stor storage.Storage
|
||||||
|
StorPath string
|
||||||
|
item *parser.Item
|
||||||
|
httpClient *http.Client
|
||||||
|
progress ProgressTracker
|
||||||
|
stream bool
|
||||||
|
|
||||||
|
totalResources int64
|
||||||
|
downloaded atomic.Int64 // downloaded resources count
|
||||||
|
totalBytes int64 // total bytes to download
|
||||||
|
downloadedBytes atomic.Int64 // downloaded bytes count
|
||||||
|
processing map[string]ResourceInfo
|
||||||
|
processingMu sync.RWMutex
|
||||||
|
failed map[string]error // [TODO] errors for each resource
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Task) Type() tasktype.TaskType {
|
||||||
|
return tasktype.TaskTypeParseditem
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Task) TaskID() string {
|
||||||
|
return t.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewTask(
|
||||||
|
id string,
|
||||||
|
ctx context.Context,
|
||||||
|
stor storage.Storage,
|
||||||
|
storPath string,
|
||||||
|
item *parser.Item,
|
||||||
|
progressTracker ProgressTracker,
|
||||||
|
) *Task {
|
||||||
|
client := &http.Client{
|
||||||
|
Transport: &http.Transport{
|
||||||
|
// [TODO] configure it via config
|
||||||
|
Proxy: http.ProxyFromEnvironment,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
_, ok := stor.(storage.StorageCannotStream)
|
||||||
|
stream := config.C().Stream && !ok
|
||||||
|
return &Task{
|
||||||
|
ID: id,
|
||||||
|
Ctx: ctx,
|
||||||
|
Stor: stor,
|
||||||
|
StorPath: storPath,
|
||||||
|
item: item,
|
||||||
|
totalResources: int64(len(item.Resources)),
|
||||||
|
downloaded: atomic.Int64{},
|
||||||
|
totalBytes: func() int64 {
|
||||||
|
var total int64
|
||||||
|
for _, res := range item.Resources {
|
||||||
|
if res.Size < 0 {
|
||||||
|
continue // skip resources with unknown size
|
||||||
|
}
|
||||||
|
total += res.Size
|
||||||
|
}
|
||||||
|
return total
|
||||||
|
}(),
|
||||||
|
stream: stream,
|
||||||
|
downloadedBytes: atomic.Int64{},
|
||||||
|
httpClient: client,
|
||||||
|
progress: progressTracker,
|
||||||
|
processing: make(map[string]ResourceInfo),
|
||||||
|
processingMu: sync.RWMutex{},
|
||||||
|
failed: make(map[string]error),
|
||||||
|
}
|
||||||
|
}
|
||||||
51
core/tasks/parsed/taskinfo.go
Normal file
51
core/tasks/parsed/taskinfo.go
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
package parsed
|
||||||
|
|
||||||
|
type TaskInfo interface {
|
||||||
|
TaskID() string
|
||||||
|
Site() string
|
||||||
|
TotalResources() int64
|
||||||
|
Downloaded() int64
|
||||||
|
TotalBytes() int64
|
||||||
|
DownloadedBytes() int64
|
||||||
|
Processing() map[string]ResourceInfo
|
||||||
|
StorageName() string
|
||||||
|
StoragePath() string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Task) StoragePath() string {
|
||||||
|
return t.StorPath
|
||||||
|
}
|
||||||
|
func (t *Task) TotalResources() int64 {
|
||||||
|
return t.totalResources
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Task) Downloaded() int64 {
|
||||||
|
return t.downloaded.Load()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Task) StorageName() string {
|
||||||
|
return t.Stor.Name()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Task) Site() string {
|
||||||
|
return t.item.Site
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Task) TotalBytes() int64 {
|
||||||
|
return t.totalBytes
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Task) DownloadedBytes() int64 {
|
||||||
|
return t.downloadedBytes.Load()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Task) Processing() map[string]ResourceInfo {
|
||||||
|
t.processingMu.RLock()
|
||||||
|
defer t.processingMu.RUnlock()
|
||||||
|
return t.processing
|
||||||
|
}
|
||||||
|
|
||||||
|
type ResourceInfo interface {
|
||||||
|
FileName() string
|
||||||
|
FileSize() int64
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package tphtask
|
package telegraph
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
@@ -20,10 +20,8 @@ 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 {
|
||||||
pic := pic
|
|
||||||
i := i
|
|
||||||
eg.Go(func() error {
|
eg.Go(func() error {
|
||||||
err := t.processPic(gctx, pic, i)
|
err := t.processPic(gctx, pic, i)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -48,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 {
|
||||||
@@ -61,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 {
|
||||||
@@ -79,6 +77,11 @@ func (t *Task) processPic(ctx context.Context, picUrl string, index int) error {
|
|||||||
lastErr = fmt.Errorf("failed to copy picture %s to cache file: %w", filename, lastErr)
|
lastErr = fmt.Errorf("failed to copy picture %s to cache file: %w", filename, lastErr)
|
||||||
return lastErr
|
return lastErr
|
||||||
}
|
}
|
||||||
|
_, err = cacheFile.Seek(0, 0)
|
||||||
|
if err != nil {
|
||||||
|
lastErr = fmt.Errorf("failed to seek cache file for picture %s: %w", filename, err)
|
||||||
|
return lastErr
|
||||||
|
}
|
||||||
lastErr = t.Stor.Save(ctx, cacheFile, path.Join(t.StorPath, filename))
|
lastErr = t.Stor.Save(ctx, cacheFile, path.Join(t.StorPath, filename))
|
||||||
} else {
|
} else {
|
||||||
lastErr = t.Stor.Save(ctx, body, path.Join(t.StorPath, filename))
|
lastErr = t.Stor.Save(ctx, body, path.Join(t.StorPath, filename))
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package tphtask
|
package telegraph
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package tphtask
|
package telegraph
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
@@ -39,7 +39,7 @@ func NewTask(
|
|||||||
progress ProgressTracker,
|
progress ProgressTracker,
|
||||||
) *Task {
|
) *Task {
|
||||||
_, cannotStream := stor.(storage.StorageCannotStream)
|
_, cannotStream := stor.(storage.StorageCannotStream)
|
||||||
tphtask := &Task{
|
telegraph := &Task{
|
||||||
ID: id,
|
ID: id,
|
||||||
Ctx: ctx,
|
Ctx: ctx,
|
||||||
PhPath: phPath,
|
PhPath: phPath,
|
||||||
@@ -52,5 +52,5 @@ func NewTask(
|
|||||||
totalpics: len(pics),
|
totalpics: len(pics),
|
||||||
downloaded: atomic.Int64{},
|
downloaded: atomic.Int64{},
|
||||||
}
|
}
|
||||||
return tphtask
|
return telegraph
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package tphtask
|
package telegraph
|
||||||
|
|
||||||
type TaskInfo interface {
|
type TaskInfo interface {
|
||||||
TaskID() string
|
TaskID() string
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package tphtask
|
package telegraph
|
||||||
|
|
||||||
func shouldUpdateProgress(downloaded int64, total int64) bool {
|
func shouldUpdateProgress(downloaded int64, total int64) bool {
|
||||||
if total <= 0 || downloaded <= 0 {
|
if total <= 0 || downloaded <= 0 {
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package tftask
|
package tfile
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
@@ -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)
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package tftask
|
package tfile
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package tftask
|
package tfile
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package tftask
|
package tfile
|
||||||
|
|
||||||
type TaskInfo interface {
|
type TaskInfo interface {
|
||||||
TaskID() string
|
TaskID() string
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package tftask
|
package tfile
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
@@ -35,12 +35,12 @@ 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)
|
||||||
}
|
}
|
||||||
tftask := &Task{
|
tfile := &Task{
|
||||||
ID: id,
|
ID: id,
|
||||||
Ctx: ctx,
|
Ctx: ctx,
|
||||||
File: file,
|
File: file,
|
||||||
@@ -49,7 +49,7 @@ func NewTGFileTask(
|
|||||||
Progress: progress,
|
Progress: progress,
|
||||||
localPath: cachePath,
|
localPath: cachePath,
|
||||||
}
|
}
|
||||||
return tftask, nil
|
return tfile, nil
|
||||||
}
|
}
|
||||||
tfileTask := &Task{
|
tfileTask := &Task{
|
||||||
ID: id,
|
ID: id,
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package tftask
|
package tfile
|
||||||
|
|
||||||
var progressUpdatesLevels = []struct {
|
var progressUpdatesLevels = []struct {
|
||||||
size int64 // 文件大小阈值
|
size int64 // 文件大小阈值
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package tftask
|
package tfile
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
@@ -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{}{}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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.js` 的实现, 并在该文件夹下新建一个 js 文件, 实现你的解析逻辑.
|
||||||
|
|
||||||
|
需要注意, `plugins` 目录下解析器默认不会被编译到二进制文件中, 用户需要手动下载它们并放到本地指定目录下以启用它们.
|
||||||
@@ -164,6 +164,18 @@ task_fail = "curl -X POST https://example.com/api/notify -d '任务失败'"
|
|||||||
task_cancel = "bash /path/to/cancel_script.sh"
|
task_cancel = "bash /path/to/cancel_script.sh"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 解析器
|
||||||
|
|
||||||
|
解析器为 Bot 提供了处理非 Telegram 文件的能力, 例如从其他网站下载文件. 使用 `[parsers]` 配置.
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[parsers]
|
||||||
|
plugin_enable = true # 是否启用解析器插件
|
||||||
|
plugin_dirs = ["./plugins"] # 插件目录, 可以是多个目录
|
||||||
|
```
|
||||||
|
|
||||||
|
上述两个配置项只用于控制以 JavaScript 编写的解析器插件, Bot 还有内置的使用 Go 实现的解析器, 目前默认开启.
|
||||||
|
|
||||||
### 杂项
|
### 杂项
|
||||||
|
|
||||||
```toml
|
```toml
|
||||||
|
|||||||
@@ -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,13 @@ IS-ALBUM true MyWebdav NEW-FOR-ALBUM
|
|||||||
```
|
```
|
||||||
|
|
||||||
这将会监听 ID 为 12345678 的聊天, 并且只保存消息文本中包含 "hello" 的消息.
|
这将会监听 ID 为 12345678 的聊天, 并且只保存消息文本中包含 "hello" 的消息.
|
||||||
|
|
||||||
|
## 转存 Telegram 之外的文件
|
||||||
|
|
||||||
|
除了 Telegram 上的文件, Bot 还可通过 JavaScript 插件或内置解析器来支持转存其他网站的文件.
|
||||||
|
|
||||||
|
> 查看[贡献解析器](../contribute)文档了解详情
|
||||||
|
|
||||||
|
只需向 Bot 发送符合解析器要求的链接即可使用, 当前内置的解析器:
|
||||||
|
|
||||||
|
- Twitter
|
||||||
7
go.mod
7
go.mod
@@ -10,7 +10,6 @@ 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/go-faster/errors v0.7.1
|
|
||||||
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
|
||||||
@@ -42,15 +41,18 @@ require (
|
|||||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||||
github.com/ghodss/yaml v1.0.0 // indirect
|
github.com/ghodss/yaml v1.0.0 // indirect
|
||||||
github.com/glebarez/go-sqlite v1.22.0 // indirect
|
github.com/glebarez/go-sqlite v1.22.0 // indirect
|
||||||
|
github.com/go-faster/errors v0.7.1 // indirect
|
||||||
github.com/go-faster/jx v1.1.0 // indirect
|
github.com/go-faster/jx v1.1.0 // indirect
|
||||||
github.com/go-faster/xor v1.0.0 // indirect
|
github.com/go-faster/xor v1.0.0 // indirect
|
||||||
github.com/go-faster/yaml v0.4.6 // indirect
|
github.com/go-faster/yaml v0.4.6 // indirect
|
||||||
github.com/go-ini/ini v1.67.0 // indirect
|
github.com/go-ini/ini v1.67.0 // indirect
|
||||||
github.com/go-logfmt/logfmt v0.6.0 // indirect
|
github.com/go-logfmt/logfmt v0.6.0 // indirect
|
||||||
|
github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect
|
||||||
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
||||||
github.com/goccy/go-json v0.10.5 // indirect
|
github.com/goccy/go-json v0.10.5 // indirect
|
||||||
github.com/google/go-github/v30 v30.1.0 // indirect
|
github.com/google/go-github/v30 v30.1.0 // indirect
|
||||||
github.com/google/go-querystring v1.1.0 // indirect
|
github.com/google/go-querystring v1.1.0 // indirect
|
||||||
|
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e // indirect
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
github.com/gotd/ige v0.2.2 // indirect
|
github.com/gotd/ige v0.2.2 // indirect
|
||||||
github.com/gotd/neo v0.1.5 // indirect
|
github.com/gotd/neo v0.1.5 // indirect
|
||||||
@@ -102,6 +104,7 @@ require (
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/dgraph-io/ristretto/v2 v2.2.0
|
github.com/dgraph-io/ristretto/v2 v2.2.0
|
||||||
|
github.com/dop251/goja v0.0.0-20250630131328-58d95d85e994
|
||||||
github.com/duke-git/lancet/v2 v2.3.7
|
github.com/duke-git/lancet/v2 v2.3.7
|
||||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||||
github.com/glebarez/sqlite v1.11.0 // indirect
|
github.com/glebarez/sqlite v1.11.0 // indirect
|
||||||
@@ -128,4 +131,4 @@ require (
|
|||||||
gorm.io/gorm v1.30.1
|
gorm.io/gorm v1.30.1
|
||||||
)
|
)
|
||||||
|
|
||||||
replace github.com/celestix/gotgproto v1.0.0-beta21 => github.com/krau/gotgproto v0.0.0-20250730080659-caaadb4b1f35
|
replace github.com/celestix/gotgproto v1.0.0-beta21 => github.com/krau/gotgproto v0.0.0-20250815074212-7fbd56c33c00
|
||||||
|
|||||||
10
go.sum
10
go.sum
@@ -4,6 +4,8 @@ github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg
|
|||||||
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||||
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
|
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
|
||||||
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
|
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
|
||||||
|
github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0=
|
||||||
|
github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ=
|
||||||
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
||||||
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||||
@@ -61,6 +63,8 @@ github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da h1:aIftn67I1fkbMa5
|
|||||||
github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw=
|
github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw=
|
||||||
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
|
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
|
||||||
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||||
|
github.com/dop251/goja v0.0.0-20250630131328-58d95d85e994 h1:aQYWswi+hRL2zJqGacdCZx32XjKYV8ApXFGntw79XAM=
|
||||||
|
github.com/dop251/goja v0.0.0-20250630131328-58d95d85e994/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4=
|
||||||
github.com/duke-git/lancet/v2 v2.3.7 h1:nnNBA9KyoqwbPm4nFmEFVIbXeAmpqf6IDCH45+HHHNs=
|
github.com/duke-git/lancet/v2 v2.3.7 h1:nnNBA9KyoqwbPm4nFmEFVIbXeAmpqf6IDCH45+HHHNs=
|
||||||
github.com/duke-git/lancet/v2 v2.3.7/go.mod h1:zGa2R4xswg6EG9I6WnyubDbFO/+A/RROxIbXcwryTsc=
|
github.com/duke-git/lancet/v2 v2.3.7/go.mod h1:zGa2R4xswg6EG9I6WnyubDbFO/+A/RROxIbXcwryTsc=
|
||||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
@@ -99,6 +103,8 @@ github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
|||||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||||
|
github.com/go-sourcemap/sourcemap v2.1.3+incompatible h1:W1iEw64niKVGogNgBN3ePyLFfuisuzeidWPMPWmECqU=
|
||||||
|
github.com/go-sourcemap/sourcemap v2.1.3+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=
|
||||||
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
|
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
|
||||||
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=
|
||||||
@@ -147,8 +153,8 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
|||||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
github.com/krau/gotgproto v0.0.0-20250730080659-caaadb4b1f35 h1:CVzeQNKRgkXm8DQX5bszxI73RcOVGTOFbMynhgjA2lQ=
|
github.com/krau/gotgproto v0.0.0-20250815074212-7fbd56c33c00 h1:Evg8e3u5ZuqkqdwzrmiQZrTiFUas00Pw99hQK9PGX7A=
|
||||||
github.com/krau/gotgproto v0.0.0-20250730080659-caaadb4b1f35/go.mod h1:xjZlGA8ABRKkfGMmkHKyz520hK6pMfyE8yxpSTqohME=
|
github.com/krau/gotgproto v0.0.0-20250815074212-7fbd56c33c00/go.mod h1:xjZlGA8ABRKkfGMmkHKyz520hK6pMfyE8yxpSTqohME=
|
||||||
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
|
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
|
||||||
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||||
|
|||||||
244
parsers/js.go
Normal file
244
parsers/js.go
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
package parsers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/blang/semver"
|
||||||
|
"github.com/charmbracelet/log"
|
||||||
|
"github.com/dop251/goja"
|
||||||
|
"github.com/krau/SaveAny-Bot/pkg/parser"
|
||||||
|
)
|
||||||
|
|
||||||
|
type jsParser struct {
|
||||||
|
meta PluginMeta
|
||||||
|
vm *goja.Runtime
|
||||||
|
reqCh chan jsParserReq
|
||||||
|
}
|
||||||
|
|
||||||
|
type jsParserReq struct {
|
||||||
|
method ParserMethod
|
||||||
|
url string
|
||||||
|
respCh chan jsParserResp
|
||||||
|
}
|
||||||
|
|
||||||
|
type jsParserResp struct {
|
||||||
|
item *parser.Item
|
||||||
|
ok bool
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *jsParser) CanHandle(url string) bool {
|
||||||
|
respCh := make(chan jsParserResp, 1)
|
||||||
|
p.reqCh <- jsParserReq{method: ParserMethodCanHandle, url: url, respCh: respCh}
|
||||||
|
resp := <-respCh
|
||||||
|
return resp.ok && resp.err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *jsParser) Parse(ctx context.Context, url string) (*parser.Item, error) {
|
||||||
|
respCh := make(chan jsParserResp, 1)
|
||||||
|
p.reqCh <- jsParserReq{method: ParserMethodParse, url: url, respCh: respCh}
|
||||||
|
select {
|
||||||
|
case resp := <-respCh:
|
||||||
|
return resp.item, resp.err
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil, ctx.Err()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newJSParser(vm *goja.Runtime, canHandleFunc, parseFunc goja.Value, metadata PluginMeta) *jsParser {
|
||||||
|
p := &jsParser{
|
||||||
|
vm: vm,
|
||||||
|
reqCh: make(chan jsParserReq, 10),
|
||||||
|
meta: metadata,
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
for req := range p.reqCh {
|
||||||
|
switch req.method {
|
||||||
|
case ParserMethodCanHandle:
|
||||||
|
fn, _ := goja.AssertFunction(canHandleFunc)
|
||||||
|
res, err := fn(goja.Undefined(), p.vm.ToValue(req.url))
|
||||||
|
if err != nil {
|
||||||
|
req.respCh <- jsParserResp{ok: false, err: err}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
req.respCh <- jsParserResp{ok: res.ToBoolean()}
|
||||||
|
case ParserMethodParse:
|
||||||
|
fn, _ := goja.AssertFunction(parseFunc)
|
||||||
|
result, err := fn(goja.Undefined(), p.vm.ToValue(req.url))
|
||||||
|
if err != nil {
|
||||||
|
req.respCh <- jsParserResp{err: err}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var item parser.Item
|
||||||
|
if exported := result.Export(); exported != nil {
|
||||||
|
data, err := json.Marshal(exported)
|
||||||
|
if err != nil {
|
||||||
|
req.respCh <- jsParserResp{err: fmt.Errorf("failed to marshal result to JSON: %w", err)}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.Unmarshal(data, &item); err != nil {
|
||||||
|
req.respCh <- jsParserResp{err: fmt.Errorf("failed to unmarshal JSON to Item: %w", err)}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
req.respCh <- jsParserResp{err: fmt.Errorf("JS function returned null or undefined")}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
req.respCh <- jsParserResp{item: &item}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
func registerParser(vm *goja.Runtime) func(call goja.FunctionCall) goja.Value {
|
||||||
|
return func(call goja.FunctionCall) goja.Value {
|
||||||
|
jsObj := call.Argument(0)
|
||||||
|
if jsObj == nil || goja.IsUndefined(jsObj) || goja.IsNull(jsObj) {
|
||||||
|
panic("registerParser expects an object { canHandle, parse }")
|
||||||
|
}
|
||||||
|
|
||||||
|
obj := jsObj.ToObject(vm)
|
||||||
|
if obj == nil {
|
||||||
|
panic("registerParser: cannot convert argument to object")
|
||||||
|
}
|
||||||
|
metaValue := obj.Get("metadata")
|
||||||
|
if metaValue == nil || goja.IsUndefined(metaValue) {
|
||||||
|
panic("parser must provide metadata")
|
||||||
|
}
|
||||||
|
var metadata PluginMeta
|
||||||
|
if exported := metaValue.Export(); exported != nil {
|
||||||
|
data, err := json.Marshal(exported)
|
||||||
|
if err != nil {
|
||||||
|
panic(fmt.Sprintf("failed to marshal metadata to JSON: %v", err))
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(data, &metadata); err != nil {
|
||||||
|
panic(fmt.Sprintf("failed to unmarshal JSON to PluginMeta: %v", err))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
panic("metadata cannot be null or undefined")
|
||||||
|
}
|
||||||
|
|
||||||
|
pluginV := semver.MustParse(metadata.Version)
|
||||||
|
if pluginV.LT(MinimumParserVersion) || pluginV.GT(LatestParserVersion) {
|
||||||
|
panic(fmt.Sprintf("parser version %s is not supported, must be between %s and %s", metadata.Version, MinimumParserVersion, LatestParserVersion))
|
||||||
|
}
|
||||||
|
|
||||||
|
handleFn := obj.Get("canHandle")
|
||||||
|
parseFn := obj.Get("parse")
|
||||||
|
if parseFn == nil || goja.IsUndefined(parseFn) {
|
||||||
|
panic("parser must provide a parse function")
|
||||||
|
}
|
||||||
|
|
||||||
|
parsers = append(parsers, newJSParser(vm, handleFn, parseFn, metadata))
|
||||||
|
return goja.Undefined()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func LoadPlugins(ctx context.Context, dir string) error {
|
||||||
|
entries, err := os.ReadDir(dir)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, e := range entries {
|
||||||
|
if filepath.Ext(e.Name()) != ".js" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
scriptPath := filepath.Join(dir, e.Name())
|
||||||
|
code, err := os.ReadFile(scriptPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
vm := goja.New()
|
||||||
|
vm.Set("registerParser", registerParser(vm))
|
||||||
|
// Inject some utils to vm
|
||||||
|
logger := log.FromContext(ctx).WithPrefix(fmt.Sprintf("[plugin|parser]/%s", e.Name()))
|
||||||
|
vm.Set("console", map[string]any{
|
||||||
|
"log": func(args ...any) {
|
||||||
|
if len(args) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(args) > 1 {
|
||||||
|
logger.Info(args[0], args[1:]...)
|
||||||
|
} else {
|
||||||
|
logger.Info(args[0])
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
// http fetch funcs
|
||||||
|
ghttp := vm.NewObject()
|
||||||
|
ghttp.Set("get", func(call goja.FunctionCall) goja.Value {
|
||||||
|
url := call.Argument(0).String()
|
||||||
|
resp, err := http.Get(url)
|
||||||
|
if err != nil {
|
||||||
|
return vm.ToValue(map[string]any{
|
||||||
|
"error": fmt.Sprintf("failed to fetch %s: %v", url, err),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return vm.ToValue(map[string]any{
|
||||||
|
"error": fmt.Sprintf("failed to fetch %s: %s", url, resp.Status),
|
||||||
|
"status": resp.StatusCode,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return vm.ToValue(map[string]any{
|
||||||
|
"error": fmt.Errorf("failed to read response body: %w", err).Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return vm.ToValue(string(body))
|
||||||
|
})
|
||||||
|
ghttp.Set("getJSON", func(call goja.FunctionCall) goja.Value {
|
||||||
|
url := call.Argument(0).String()
|
||||||
|
|
||||||
|
resp, err := http.Get(url)
|
||||||
|
if err != nil {
|
||||||
|
return vm.ToValue(map[string]any{
|
||||||
|
"error": fmt.Sprintf("failed to fetch %s: %v", url, err),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return vm.ToValue(map[string]any{
|
||||||
|
"error": fmt.Sprintf("failed to fetch %s: %s", url, resp.Status),
|
||||||
|
"status": resp.StatusCode,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return vm.ToValue(map[string]any{
|
||||||
|
"error": fmt.Errorf("failed to read response body: %w", err).Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
var jsonData map[string]any
|
||||||
|
if err := json.Unmarshal(body, &jsonData); err != nil {
|
||||||
|
return vm.ToValue(map[string]any{
|
||||||
|
"error": fmt.Errorf("failed to unmarshal JSON: %w", err).Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return vm.ToValue(map[string]any{
|
||||||
|
"data": jsonData,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
vm.Set("ghttp", ghttp)
|
||||||
|
|
||||||
|
if _, err := vm.RunString(string(code)); err != nil {
|
||||||
|
return fmt.Errorf("error loading plugin %s: %w", e.Name(), err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
36
parsers/kemono/download.go
Normal file
36
parsers/kemono/download.go
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
package kemono
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type DownloadInfo struct {
|
||||||
|
ServiceName string
|
||||||
|
UserID string
|
||||||
|
PostID string
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractDownloadInfoFromURL(u string) *DownloadInfo {
|
||||||
|
if !strings.HasPrefix(u, "http://") && !strings.HasPrefix(u, "https://") {
|
||||||
|
u = "https://" + u
|
||||||
|
}
|
||||||
|
url, err := url.Parse(u)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
parts := strings.Split(strings.Trim(url.Path, "/"), "/")
|
||||||
|
if len(parts) == 3 {
|
||||||
|
return &DownloadInfo{
|
||||||
|
ServiceName: parts[0],
|
||||||
|
UserID: parts[2],
|
||||||
|
}
|
||||||
|
} else if len(parts) == 5 && parts[3] == "post" {
|
||||||
|
return &DownloadInfo{
|
||||||
|
ServiceName: parts[0],
|
||||||
|
UserID: parts[2],
|
||||||
|
PostID: parts[4],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
173
parsers/kemono/kemono.go
Normal file
173
parsers/kemono/kemono.go
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
package kemono
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"path"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/duke-git/lancet/v2/strutil"
|
||||||
|
"github.com/krau/SaveAny-Bot/common/utils/netutil"
|
||||||
|
"github.com/krau/SaveAny-Bot/pkg/parser"
|
||||||
|
)
|
||||||
|
|
||||||
|
type KemonoParser struct{}
|
||||||
|
|
||||||
|
var (
|
||||||
|
kemonoDomains = []string{
|
||||||
|
"kemono.su",
|
||||||
|
"kemono.cr",
|
||||||
|
}
|
||||||
|
ErrFailedToExtractInfo = errors.New("failed to extract download info from URL")
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
kemonoApiBase = "https://kemono.cr/api/v1"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (k *KemonoParser) CanHandle(text string) bool {
|
||||||
|
text = strings.TrimPrefix(text, "https://")
|
||||||
|
text = strings.TrimPrefix(text, "http://")
|
||||||
|
|
||||||
|
var matchesDomain bool
|
||||||
|
for _, domain := range kemonoDomains {
|
||||||
|
if strings.Contains(text, domain) {
|
||||||
|
matchesDomain = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !matchesDomain {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
var path string
|
||||||
|
for _, domain := range kemonoDomains {
|
||||||
|
if idx := strings.Index(text, domain); idx != -1 {
|
||||||
|
remaining := text[idx+len(domain):]
|
||||||
|
if len(remaining) > 0 && remaining[0] == '/' {
|
||||||
|
path = remaining[1:]
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if path == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := strings.Split(path, "/")
|
||||||
|
// servicename/user/id (user profile page)
|
||||||
|
// servicename/user/id/post/id (post page)
|
||||||
|
return len(parts) == 3 || (len(parts) == 5 && parts[3] == "post")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (k *KemonoParser) Parse(ctx context.Context, u string) (*parser.Item, error) {
|
||||||
|
info := extractDownloadInfoFromURL(u)
|
||||||
|
if info == nil {
|
||||||
|
return nil, ErrFailedToExtractInfo
|
||||||
|
}
|
||||||
|
if info.PostID != "" {
|
||||||
|
return k.parseOne(ctx, info)
|
||||||
|
}
|
||||||
|
return k.parseUserPage(ctx, info)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (k *KemonoParser) parseOne(ctx context.Context, info *DownloadInfo) (*parser.Item, error) {
|
||||||
|
client := netutil.DefaultParserHTTPClient()
|
||||||
|
endpoint := fmt.Sprintf("%s/%s/user/%s/post/%s", kemonoApiBase, info.ServiceName, info.UserID, info.PostID)
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create request to Kemono API: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Accept", "text/css")
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to fetch Kemono API: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("failed to fetch Kemono API, status code: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
var postInfo PostInfo
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&postInfo); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to decode Kemono API response: %w", err)
|
||||||
|
}
|
||||||
|
item := &parser.Item{
|
||||||
|
Site: "kemono",
|
||||||
|
Title: postInfo.Post.Title,
|
||||||
|
URL: fmt.Sprintf("https://kemono.cr/%s/user/%s/post/%s", info.ServiceName, info.UserID, info.PostID),
|
||||||
|
Author: postInfo.Post.User, // [TODO] request user profile
|
||||||
|
Description: postInfo.Post.Content,
|
||||||
|
Tags: func() []string {
|
||||||
|
if postInfo.Post.Tags != nil {
|
||||||
|
return *postInfo.Post.Tags
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}(),
|
||||||
|
}
|
||||||
|
resources := make([]parser.Resource, 0)
|
||||||
|
for _, attachment := range postInfo.Attachments {
|
||||||
|
if attachment.Server == nil || attachment.Path == nil || attachment.Name == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var size int64
|
||||||
|
fileUrl := fmt.Sprintf("%s/data%s", *attachment.Server, *attachment.Path)
|
||||||
|
headReq, err := http.NewRequestWithContext(ctx, http.MethodHead, fileUrl, nil)
|
||||||
|
if err == nil {
|
||||||
|
resp, err := client.Do(headReq)
|
||||||
|
if err == nil {
|
||||||
|
size = resp.ContentLength
|
||||||
|
resp.Body.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
resources = append(resources, parser.Resource{
|
||||||
|
URL: fmt.Sprintf("%s/data%s", *attachment.Server, *attachment.Path),
|
||||||
|
Filename: *attachment.Name,
|
||||||
|
Size: size,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
picCdnMap := make(map[string]string)
|
||||||
|
for _, preview := range postInfo.Previews {
|
||||||
|
if preview.Type == nil || *preview.Type != "thumbnail" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
picCdnMap[*preview.Path] = *preview.Server
|
||||||
|
}
|
||||||
|
for _, attachment := range postInfo.Post.Attachments {
|
||||||
|
if !isImageExt(*attachment.Path) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
picUrl, err := url.JoinPath(picCdnMap[*attachment.Path], "data", *attachment.Path)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var size int64
|
||||||
|
headReq, err := http.NewRequestWithContext(ctx, http.MethodHead, picUrl, nil)
|
||||||
|
if err == nil {
|
||||||
|
resp, err := client.Do(headReq)
|
||||||
|
if err == nil {
|
||||||
|
size = resp.ContentLength
|
||||||
|
resp.Body.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
resources = append(resources, parser.Resource{
|
||||||
|
URL: picUrl,
|
||||||
|
Filename: *attachment.Name,
|
||||||
|
Size: size,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
item.Resources = resources
|
||||||
|
return item, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (k *KemonoParser) parseUserPage(_ context.Context, _ *DownloadInfo) (*parser.Item, error) {
|
||||||
|
return nil, errors.New("kemono user page not implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
func isImageExt(attachmentPath string) bool {
|
||||||
|
return strutil.HasSuffixAny(path.Ext(strings.Split(attachmentPath, "?")[0]), []string{".jpg", ".jpeg", ".png", ".webp"})
|
||||||
|
}
|
||||||
62
parsers/kemono/post_info.go
Normal file
62
parsers/kemono/post_info.go
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
// https://github.com/kemono-rs/kemono
|
||||||
|
|
||||||
|
package kemono
|
||||||
|
|
||||||
|
type PostInfo struct {
|
||||||
|
Post Post `json:"post"`
|
||||||
|
Attachments []AttachmentLike `json:"attachments"`
|
||||||
|
Previews []AttachmentLike `json:"previews"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AttachmentLike struct {
|
||||||
|
Type *string `json:"type,omitempty"`
|
||||||
|
Server *string `json:"server,omitempty"`
|
||||||
|
Name *string `json:"name,omitempty"`
|
||||||
|
Path *string `json:"path,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Post struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
User string `json:"user"`
|
||||||
|
Service string `json:"service"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
Embed Embed `json:"embed"`
|
||||||
|
SharedFile bool `json:"shared_file"`
|
||||||
|
Added *string `json:"added,omitempty"`
|
||||||
|
Published string `json:"published"`
|
||||||
|
Edited *string `json:"edited,omitempty"`
|
||||||
|
File File `json:"file"`
|
||||||
|
Attachments []AttachmentLike `json:"attachments"`
|
||||||
|
Poll *Poll `json:"poll,omitempty"`
|
||||||
|
Captions *string `json:"captions,omitempty"`
|
||||||
|
Tags *[]string `json:"tags,omitempty"`
|
||||||
|
Next *string `json:"next,omitempty"`
|
||||||
|
Prev *string `json:"prev,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type File struct {
|
||||||
|
Name *string `json:"name,omitempty"`
|
||||||
|
Path *string `json:"path,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Embed struct {
|
||||||
|
URL *string `json:"url,omitempty"`
|
||||||
|
Subject *string `json:"subject,omitempty"`
|
||||||
|
Description *string `json:"description,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Poll struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
Choices []Choice `json:"choices"`
|
||||||
|
ClosesAt *string `json:"closes_at,omitempty"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
Description *string `json:"description,omitempty"`
|
||||||
|
AllowsMultiple bool `json:"allows_multiple"`
|
||||||
|
TotalVotes int64 `json:"total_votes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Choice struct {
|
||||||
|
Text string `json:"text"`
|
||||||
|
Votes int64 `json:"votes"`
|
||||||
|
}
|
||||||
16
parsers/kemono/post_legacy.go
Normal file
16
parsers/kemono/post_legacy.go
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
package kemono
|
||||||
|
|
||||||
|
type PostLegacy struct {
|
||||||
|
Props Props `json:"props"`
|
||||||
|
Results []Result `json:"results"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Props struct {
|
||||||
|
Count uint `json:"count"`
|
||||||
|
Limit uint `json:"limit"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Result struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
}
|
||||||
8
parsers/kemono/user_profile.go
Normal file
8
parsers/kemono/user_profile.go
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
package kemono
|
||||||
|
|
||||||
|
type UserProfile struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Service string `json:"service"`
|
||||||
|
PublicID *string `json:"public_id,omitempty"`
|
||||||
|
}
|
||||||
88
parsers/parser.go
Normal file
88
parsers/parser.go
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
package parsers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/krau/SaveAny-Bot/config"
|
||||||
|
"github.com/krau/SaveAny-Bot/parsers/kemono"
|
||||||
|
"github.com/krau/SaveAny-Bot/parsers/twitter"
|
||||||
|
"github.com/krau/SaveAny-Bot/pkg/parser"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
parsers []parser.Parser
|
||||||
|
parsersMu sync.Mutex
|
||||||
|
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 cfg != nil {
|
||||||
|
if err := configurable.Configure(cfg); err != nil {
|
||||||
|
fmt.Printf("Error configuring parser %s: %v\n", configurable.Name(), err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
func AddParser(p ...parser.Parser) {
|
||||||
|
parsersMu.Lock()
|
||||||
|
defer parsersMu.Unlock()
|
||||||
|
parsers = append(parsers, p...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
AddParser(new(twitter.TwitterParser), new(kemono.KemonoParser))
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrNoParserFound = fmt.Errorf("no parser found for the given URL")
|
||||||
|
)
|
||||||
|
|
||||||
|
func ParseWithContext(ctx context.Context, url string) (*parser.Item, error) {
|
||||||
|
doConfig.Do(configParsers)
|
||||||
|
ch := make(chan *parser.Item, 1)
|
||||||
|
errCh := make(chan error, 1)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
for _, pser := range parsers {
|
||||||
|
if !pser.CanHandle(url) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
item, err := pser.Parse(ctx, url)
|
||||||
|
if err != nil {
|
||||||
|
errCh <- err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ch <- item
|
||||||
|
return
|
||||||
|
}
|
||||||
|
errCh <- ErrNoParserFound
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case item := <-ch:
|
||||||
|
return item, nil
|
||||||
|
case err := <-errCh:
|
||||||
|
return nil, err
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil, ctx.Err()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func CanHandle(url string) (bool, parser.Parser) {
|
||||||
|
doConfig.Do(configParsers)
|
||||||
|
for _, pser := range parsers {
|
||||||
|
if pser.CanHandle(url) {
|
||||||
|
return true, pser
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
23
parsers/plugin.go
Normal file
23
parsers/plugin.go
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
package parsers
|
||||||
|
|
||||||
|
import "github.com/blang/semver"
|
||||||
|
|
||||||
|
var (
|
||||||
|
LatestParserVersion = semver.MustParse("1.0.0")
|
||||||
|
MinimumParserVersion = semver.MustParse("1.0.0")
|
||||||
|
)
|
||||||
|
|
||||||
|
type PluginMeta struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Version string `json:"version"` // [TODO] 分版本解析, 但是我们现在只有 v1 所以先不写
|
||||||
|
Description string `json:"description"`
|
||||||
|
Author string `json:"author"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ParserMethod uint
|
||||||
|
|
||||||
|
const (
|
||||||
|
_ ParserMethod = iota
|
||||||
|
ParserMethodCanHandle
|
||||||
|
ParserMethodParse
|
||||||
|
)
|
||||||
117
parsers/twitter/parser.go
Normal file
117
parsers/twitter/parser.go
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
package twitter
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"path"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/krau/SaveAny-Bot/common/utils/netutil"
|
||||||
|
"github.com/krau/SaveAny-Bot/pkg/parser"
|
||||||
|
)
|
||||||
|
|
||||||
|
type TwitterParser struct {
|
||||||
|
client http.Client
|
||||||
|
apiDomain string
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
fxTwitterApi = "api.fxtwitter.com"
|
||||||
|
)
|
||||||
|
|
||||||
|
var _ parser.ConfigurableParser = (*TwitterParser)(nil)
|
||||||
|
|
||||||
|
var (
|
||||||
|
twitterSourceURLRegexp *regexp.Regexp = regexp.MustCompile(`(?:twitter|x)\.com/([^/]+)/status/(\d+)`)
|
||||||
|
)
|
||||||
|
|
||||||
|
func getTweetID(sourceURL string) string {
|
||||||
|
matches := twitterSourceURLRegexp.FindStringSubmatch(sourceURL)
|
||||||
|
if len(matches) < 3 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return matches[2]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *TwitterParser) Parse(ctx context.Context, u string) (*parser.Item, error) {
|
||||||
|
id := getTweetID(u)
|
||||||
|
if id == "" {
|
||||||
|
return nil, errors.New("invalid Twitter URL")
|
||||||
|
}
|
||||||
|
apiUrl := fmt.Sprintf("https://%s/_/status/%s", p.apiDomain, id)
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiUrl, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create request to Twitter API: %w", err)
|
||||||
|
}
|
||||||
|
resp, err := p.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to fetch Twitter API: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("failed to fetch Twitter API, status code: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
var fxResp FxTwitterApiResp
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&fxResp); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to decode Twitter API response: %w", err)
|
||||||
|
}
|
||||||
|
if fxResp.Code != 200 {
|
||||||
|
return nil, fmt.Errorf("request twitter API error: %s", fxResp.Message)
|
||||||
|
}
|
||||||
|
if len(fxResp.Tweet.Media.All) == 0 {
|
||||||
|
return nil, errors.New("no media found in the tweet")
|
||||||
|
}
|
||||||
|
resources := make([]parser.Resource, 0, len(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{
|
||||||
|
URL: media.URL,
|
||||||
|
Filename: path.Base(strings.Split(media.URL, "?")[0]),
|
||||||
|
Size: size,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
item := &parser.Item{
|
||||||
|
Site: "Twitter",
|
||||||
|
Title: fmt.Sprintf("Tweet/%s", id),
|
||||||
|
URL: fxResp.Tweet.URL,
|
||||||
|
Description: fxResp.Tweet.Text,
|
||||||
|
Author: fxResp.Tweet.Author.Name,
|
||||||
|
Tags: make([]string, 0),
|
||||||
|
Extra: make(map[string]any),
|
||||||
|
Resources: resources,
|
||||||
|
}
|
||||||
|
return item, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *TwitterParser) CanHandle(u string) bool {
|
||||||
|
return twitterSourceURLRegexp.MatchString(u)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *TwitterParser) Name() string {
|
||||||
|
return "twitter"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *TwitterParser) Configure(config map[string]any) error {
|
||||||
|
if domain, ok := config["api_domain"].(string); ok && domain != "" {
|
||||||
|
p.apiDomain = domain
|
||||||
|
} else {
|
||||||
|
p.apiDomain = fxTwitterApi
|
||||||
|
}
|
||||||
|
if proxyUrl, ok := config["proxy"].(string); ok && proxyUrl != "" {
|
||||||
|
proxyClient, err := netutil.NewProxyHTTPClient(proxyUrl)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create proxy client: %w", err)
|
||||||
|
}
|
||||||
|
p.client = *proxyClient
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
122
parsers/twitter/types.go
Normal file
122
parsers/twitter/types.go
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
package twitter
|
||||||
|
|
||||||
|
// type AutoGenerated struct {
|
||||||
|
// Code int `json:"code"`
|
||||||
|
// Message string `json:"message"`
|
||||||
|
// Tweet struct {
|
||||||
|
// URL string `json:"url"`
|
||||||
|
// ID string `json:"id"`
|
||||||
|
// Text string `json:"text"`
|
||||||
|
// RawText struct {
|
||||||
|
// Text string `json:"text"`
|
||||||
|
// Facets []struct {
|
||||||
|
// Type string `json:"type"`
|
||||||
|
// Indices []int `json:"indices"`
|
||||||
|
// Original string `json:"original"`
|
||||||
|
// ID string `json:"id,omitempty"`
|
||||||
|
// Display string `json:"display,omitempty"`
|
||||||
|
// Replacement string `json:"replacement,omitempty"`
|
||||||
|
// } `json:"facets"`
|
||||||
|
// } `json:"raw_text"`
|
||||||
|
// Author struct {
|
||||||
|
// ID string `json:"id"`
|
||||||
|
// Name string `json:"name"`
|
||||||
|
// ScreenName string `json:"screen_name"`
|
||||||
|
// AvatarURL string `json:"avatar_url"`
|
||||||
|
// BannerURL interface{} `json:"banner_url"`
|
||||||
|
// Description string `json:"description"`
|
||||||
|
// Location string `json:"location"`
|
||||||
|
// URL string `json:"url"`
|
||||||
|
// Followers int `json:"followers"`
|
||||||
|
// Following int `json:"following"`
|
||||||
|
// Joined string `json:"joined"`
|
||||||
|
// Likes int `json:"likes"`
|
||||||
|
// MediaCount int `json:"media_count"`
|
||||||
|
// Protected bool `json:"protected"`
|
||||||
|
// Website struct {
|
||||||
|
// URL string `json:"url"`
|
||||||
|
// DisplayURL string `json:"display_url"`
|
||||||
|
// } `json:"website"`
|
||||||
|
// Tweets int `json:"tweets"`
|
||||||
|
// AvatarColor interface{} `json:"avatar_color"`
|
||||||
|
// } `json:"author"`
|
||||||
|
// Replies int `json:"replies"`
|
||||||
|
// Retweets int `json:"retweets"`
|
||||||
|
// Likes int `json:"likes"`
|
||||||
|
// Bookmarks int `json:"bookmarks"`
|
||||||
|
// CreatedAt string `json:"created_at"`
|
||||||
|
// CreatedTimestamp int `json:"created_timestamp"`
|
||||||
|
// PossiblySensitive bool `json:"possibly_sensitive"`
|
||||||
|
// Views int `json:"views"`
|
||||||
|
// IsNoteTweet bool `json:"is_note_tweet"`
|
||||||
|
// CommunityNote interface{} `json:"community_note"`
|
||||||
|
// Lang string `json:"lang"`
|
||||||
|
// ReplyingTo interface{} `json:"replying_to"`
|
||||||
|
// ReplyingToStatus interface{} `json:"replying_to_status"`
|
||||||
|
// Media struct {
|
||||||
|
// All []struct {
|
||||||
|
// URL string `json:"url"`
|
||||||
|
// ThumbnailURL string `json:"thumbnail_url"`
|
||||||
|
// Duration int `json:"duration"`
|
||||||
|
// Width int `json:"width"`
|
||||||
|
// Height int `json:"height"`
|
||||||
|
// Format string `json:"format"`
|
||||||
|
// Type string `json:"type"`
|
||||||
|
// Variants []struct {
|
||||||
|
// Bitrate int `json:"bitrate"`
|
||||||
|
// ContentType string `json:"content_type"`
|
||||||
|
// URL string `json:"url"`
|
||||||
|
// } `json:"variants"`
|
||||||
|
// } `json:"all"`
|
||||||
|
// Photos []struct {
|
||||||
|
// Type string `json:"type"`
|
||||||
|
// URL string `json:"url"`
|
||||||
|
// Width int `json:"width"`
|
||||||
|
// Height int `json:"height"`
|
||||||
|
// } `json:"photos"`
|
||||||
|
// Videos []struct {
|
||||||
|
// URL string `json:"url"`
|
||||||
|
// ThumbnailURL string `json:"thumbnail_url"`
|
||||||
|
// Duration int `json:"duration"`
|
||||||
|
// Width int `json:"width"`
|
||||||
|
// Height int `json:"height"`
|
||||||
|
// Format string `json:"format"`
|
||||||
|
// Type string `json:"type"`
|
||||||
|
// Variants []struct {
|
||||||
|
// Bitrate int `json:"bitrate"`
|
||||||
|
// ContentType string `json:"content_type"`
|
||||||
|
// URL string `json:"url"`
|
||||||
|
// } `json:"variants"`
|
||||||
|
// } `json:"videos"`
|
||||||
|
// } `json:"media"`
|
||||||
|
// Source string `json:"source"`
|
||||||
|
// TwitterCard string `json:"twitter_card"`
|
||||||
|
// Color interface{} `json:"color"`
|
||||||
|
// Provider string `json:"provider"`
|
||||||
|
// } `json:"tweet"`
|
||||||
|
// }
|
||||||
|
|
||||||
|
type FxTwitterApiResp struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Tweet struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
ID string `json:"id"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
Author struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
ScreenName string `json:"screen_name"`
|
||||||
|
Protected bool `json:"protected"`
|
||||||
|
} `json:"author"`
|
||||||
|
PossiblySensitive bool `json:"possibly_sensitive"`
|
||||||
|
IsNoteTweet bool `json:"is_note_tweet"`
|
||||||
|
Lang string `json:"lang"`
|
||||||
|
Media struct {
|
||||||
|
All []struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
} `json:"all"`
|
||||||
|
} `json:"media"`
|
||||||
|
} `json:"tweet"`
|
||||||
|
}
|
||||||
14
pkg/enums/fnamest/filename_srategy.go
Normal file
14
pkg/enums/fnamest/filename_srategy.go
Normal 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: "优先从消息生成",
|
||||||
|
}
|
||||||
87
pkg/enums/fnamest/filename_srategy_enum.go
Normal file
87
pkg/enums/fnamest/filename_srategy_enum.go
Normal 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"
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
package tasktype
|
package tasktype
|
||||||
|
|
||||||
//go:generate go-enum --values --names --flag --nocase
|
//go:generate go-enum --values --names --flag --nocase
|
||||||
// ENUM(tgfiles,tphpics)
|
// ENUM(tgfiles,tphpics,parseditem)
|
||||||
type TaskType string
|
type TaskType string
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ const (
|
|||||||
TaskTypeTgfiles TaskType = "tgfiles"
|
TaskTypeTgfiles TaskType = "tgfiles"
|
||||||
// TaskTypeTphpics is a TaskType of type tphpics.
|
// TaskTypeTphpics is a TaskType of type tphpics.
|
||||||
TaskTypeTphpics TaskType = "tphpics"
|
TaskTypeTphpics TaskType = "tphpics"
|
||||||
|
// TaskTypeParseditem is a TaskType of type parseditem.
|
||||||
|
TaskTypeParseditem TaskType = "parseditem"
|
||||||
)
|
)
|
||||||
|
|
||||||
var ErrInvalidTaskType = fmt.Errorf("not a valid TaskType, try [%s]", strings.Join(_TaskTypeNames, ", "))
|
var ErrInvalidTaskType = fmt.Errorf("not a valid TaskType, try [%s]", strings.Join(_TaskTypeNames, ", "))
|
||||||
@@ -23,6 +25,7 @@ var ErrInvalidTaskType = fmt.Errorf("not a valid TaskType, try [%s]", strings.Jo
|
|||||||
var _TaskTypeNames = []string{
|
var _TaskTypeNames = []string{
|
||||||
string(TaskTypeTgfiles),
|
string(TaskTypeTgfiles),
|
||||||
string(TaskTypeTphpics),
|
string(TaskTypeTphpics),
|
||||||
|
string(TaskTypeParseditem),
|
||||||
}
|
}
|
||||||
|
|
||||||
// TaskTypeNames returns a list of possible string values of TaskType.
|
// TaskTypeNames returns a list of possible string values of TaskType.
|
||||||
@@ -37,6 +40,7 @@ func TaskTypeValues() []TaskType {
|
|||||||
return []TaskType{
|
return []TaskType{
|
||||||
TaskTypeTgfiles,
|
TaskTypeTgfiles,
|
||||||
TaskTypeTphpics,
|
TaskTypeTphpics,
|
||||||
|
TaskTypeParseditem,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,8 +57,9 @@ func (x TaskType) IsValid() bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var _TaskTypeValue = map[string]TaskType{
|
var _TaskTypeValue = map[string]TaskType{
|
||||||
"tgfiles": TaskTypeTgfiles,
|
"tgfiles": TaskTypeTgfiles,
|
||||||
"tphpics": TaskTypeTphpics,
|
"tphpics": TaskTypeTphpics,
|
||||||
|
"parseditem": TaskTypeParseditem,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ParseTaskType attempts to convert a string to a TaskType.
|
// ParseTaskType attempts to convert a string to a TaskType.
|
||||||
|
|||||||
70
pkg/parser/parser.go
Normal file
70
pkg/parser/parser.go
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
package parser
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/md5"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Parser interface {
|
||||||
|
CanHandle(url string) bool
|
||||||
|
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.
|
||||||
|
type Resource struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
Filename string `json:"filename"` // with ext
|
||||||
|
MimeType string `json:"mime_type"`
|
||||||
|
Extension string `json:"extension"` // e.g. "mp4"
|
||||||
|
Size int64 `json:"size"` // 0 when unknown
|
||||||
|
Hash map[string]string `json:"hash"` // {"md5": "...", "sha256": "..."}
|
||||||
|
Headers map[string]string `json:"headers"` // HTTP headers when downloading
|
||||||
|
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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Resource) FileName() string {
|
||||||
|
return r.Filename
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Resource) FileSize() int64 {
|
||||||
|
return r.Size
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Resource) ID() string {
|
||||||
|
h := md5.New()
|
||||||
|
h.Write([]byte(r.URL))
|
||||||
|
h.Write([]byte(r.Filename))
|
||||||
|
h.Write([]byte(r.MimeType))
|
||||||
|
h.Write([]byte(r.Extension))
|
||||||
|
h.Write([]byte(fmt.Sprintf("%d", r.Size)))
|
||||||
|
|
||||||
|
for k, v := range r.Hash {
|
||||||
|
h.Write([]byte(k))
|
||||||
|
h.Write([]byte(v))
|
||||||
|
}
|
||||||
|
|
||||||
|
for k, v := range r.Headers {
|
||||||
|
h.Write([]byte(k))
|
||||||
|
h.Write([]byte(v))
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf("%x", h.Sum(nil))
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package consts
|
package rule
|
||||||
|
|
||||||
const (
|
const (
|
||||||
RuleStorNameChosen = "CHOSEN"
|
RuleStorNameChosen = "CHOSEN"
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package tcbdata
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"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/telegraph"
|
"github.com/krau/SaveAny-Bot/pkg/telegraph"
|
||||||
"github.com/krau/SaveAny-Bot/pkg/tfile"
|
"github.com/krau/SaveAny-Bot/pkg/tfile"
|
||||||
)
|
)
|
||||||
@@ -9,6 +10,8 @@ import (
|
|||||||
const (
|
const (
|
||||||
TypeAdd = "add"
|
TypeAdd = "add"
|
||||||
TypeSetDefault = "setdefault"
|
TypeSetDefault = "setdefault"
|
||||||
|
TypeConfig = "config"
|
||||||
|
TypeCancel = "cancel"
|
||||||
)
|
)
|
||||||
|
|
||||||
// type TaskDataTGFiles struct {
|
// type TaskDataTGFiles struct {
|
||||||
@@ -26,6 +29,7 @@ const (
|
|||||||
// }
|
// }
|
||||||
|
|
||||||
type Add struct {
|
type Add struct {
|
||||||
|
// [TODO] maybe we should to spilit this into different types...
|
||||||
TaskType tasktype.TaskType
|
TaskType tasktype.TaskType
|
||||||
SelectedStorName string
|
SelectedStorName string
|
||||||
DirID uint
|
DirID uint
|
||||||
@@ -37,6 +41,8 @@ type Add struct {
|
|||||||
TphPageNode *telegraph.Page
|
TphPageNode *telegraph.Page
|
||||||
TphPics []string
|
TphPics []string
|
||||||
TphDirPath string // unescaped telegraph.Page.Path
|
TphDirPath string // unescaped telegraph.Page.Path
|
||||||
|
// parseditem
|
||||||
|
ParsedItem *parser.Item
|
||||||
}
|
}
|
||||||
|
|
||||||
type SetDefaultStorage struct {
|
type SetDefaultStorage struct {
|
||||||
|
|||||||
@@ -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))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
161
plugins/README.md
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
# SaveAnyBot Plugins
|
||||||
|
|
||||||
|
SaveAnyBot 可通过插件扩展功能, 目前仅支持 Parser (解析器)插件.
|
||||||
|
|
||||||
|
## Parser
|
||||||
|
|
||||||
|
解析器为 SaveAnyBot 提供了处理非 Telegram 文件的能力, 例如下载其他网站的图片或视频.
|
||||||
|
|
||||||
|
当前解析器接口定义如下:
|
||||||
|
|
||||||
|
```go
|
||||||
|
type Parser interface {
|
||||||
|
CanHandle(url string) bool // 判断是否能处理给定的 URL
|
||||||
|
Parse(ctx context.Context, url string) (*Item, error) // 解析 URL, 返回 Item
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resource is a single downloadable resource with metadata.
|
||||||
|
type Resource struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
Filename string `json:"filename"` // with ext
|
||||||
|
MimeType string `json:"mime_type"`
|
||||||
|
Extension string `json:"extension"`
|
||||||
|
Size int64 `json:"size"` // 0 when unknown
|
||||||
|
Hash map[string]string `json:"hash"` // {"md5": "...", "sha256": "..."}
|
||||||
|
Headers map[string]string `json:"headers"` // HTTP headers when downloading
|
||||||
|
Extra map[string]any `json:"extra"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Item struct {
|
||||||
|
Site string `json:"site"`
|
||||||
|
URL string `json:"url"` // original URL of the item
|
||||||
|
Title string `json:"title"`
|
||||||
|
Author string `json:"author"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Tags []string `json:"tags"`
|
||||||
|
Resources []Resource `json:"resources"`
|
||||||
|
Extra map[string]any `json:"extra"`
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Write a Parser Plugin
|
||||||
|
|
||||||
|
解析器插件可使用 JavaScript 编写, SaveAnyBot 使用 [goja](https://github.com/dop251/goja) 提供运行时, 并向其中注入了以下全局函数或对象:
|
||||||
|
|
||||||
|
- **registerParser**: 用于注册解析器, 每个插件必须调用此函数以注册
|
||||||
|
- **console.log**: 调用 go 端的 logger 打印日志
|
||||||
|
- **ghttp**: 提供 HTTP 请求功能
|
||||||
|
|
||||||
|
插件需要提供元数据 `metadata` 并实现 `canHandle` 和 `parse` 两个函数, 最后调用 `registerParser` 注册解析器.
|
||||||
|
|
||||||
|
#### Plugin Metadata
|
||||||
|
|
||||||
|
插件元数据是一个 JavaScript 对象:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const metadata = {
|
||||||
|
version: "1.0.0", // 插件版本号, 必须提供, 其他字段可选
|
||||||
|
name: "Example Parser", // 插件名称
|
||||||
|
description: "A parser for example links", // 插件描述
|
||||||
|
author: "Krau", // 插件作者
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### canHandle Function
|
||||||
|
|
||||||
|
`canHandle`: `canHandle(url: string): boolean` , 用于判断当前解析器能否解析给定的 URL, 返回布尔值, 例如:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const canHandle = function (url) {
|
||||||
|
return url.includes("youtube.com/watch?v");
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
这将让 SaveAnyBot 在遇到包含 `youtube.com/watch?v` 的 url 时调用当前解析器的 `parse`.
|
||||||
|
|
||||||
|
#### parse Function
|
||||||
|
|
||||||
|
`parse`: `parse(url: string): Item` , 是核心解析函数, 用于解析给定的 url, 返回一个 `Item` 对象, 例:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const parse = function (url) {
|
||||||
|
var result = {
|
||||||
|
// 元信息
|
||||||
|
site: "YouTube",
|
||||||
|
url: url,
|
||||||
|
title: "测试 YouTube 视频",
|
||||||
|
author: "某视频作者",
|
||||||
|
description: "这是一个测试视频",
|
||||||
|
tags: ["test", "youtube"],
|
||||||
|
// 资源(可下载的文件)列表
|
||||||
|
resources: [
|
||||||
|
{
|
||||||
|
url: "https://example.com/video1.mp4", // 文件直链
|
||||||
|
filename: "somevideo.mp4", // 文件名
|
||||||
|
mime_type: "video/mp4", // 文件 MIME 类型, 可选
|
||||||
|
extension: "mp4", // 文件扩展名, 可选
|
||||||
|
size: 100 * 1024 * 1024, // 文件大小, 单位为字节, 未知可以设置为 0
|
||||||
|
hash: {}, // 文件哈希, 可选, 格式为 {"md5": "xxx", "sha256": "xxx"} 等
|
||||||
|
headers: {}, // 下载文件时所需的 HTTP 头部, 可选, 例如 {"User-Agent": "Mozilla/5.0"}
|
||||||
|
extra: {} // 额外信息, 可选, 可以包含任何自定义数据
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: "https://example.com/picture1.png",
|
||||||
|
filename: "picture1.png",
|
||||||
|
mime_type: "image/png",
|
||||||
|
extension: "png",
|
||||||
|
size: 1 * 1024 * 1024,
|
||||||
|
hash: {},
|
||||||
|
headers: {},
|
||||||
|
extra: {}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
extra: {}
|
||||||
|
};
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### HTTP Requests
|
||||||
|
|
||||||
|
使用 `ghttp` 对象以发起 HTTP 请求.
|
||||||
|
|
||||||
|
**ghttp.get(url: string)** 发起 GET 请求, 当成功时返回响应体字符串, 失败时或响应状态码不为 200 时返回一个包含 `error` 字段的对象:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const response = ghttp.get("https://example.com/someapi");
|
||||||
|
if (response.error) {
|
||||||
|
console.log("Request failed:", response.error);
|
||||||
|
}
|
||||||
|
if (response.status) {
|
||||||
|
console.log("Response status:", response.status);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**ghttp.getJSON(url: string)** 发起 GET 请求并将响应体解析为 JSON 对象, 始终返回以下对象:
|
||||||
|
|
||||||
|
```js
|
||||||
|
{
|
||||||
|
data?: any, // 当请求成功且响应体为合法 JSON 时包含解析后的数据
|
||||||
|
error?: string, // 当请求失败或响应状态码不为 200 时包含错误信息
|
||||||
|
status?: number, // 响应状态码, 仅当响应状态码不为 200 时包含
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
最后别忘了调用 `registerParser` 注册解析器:
|
||||||
|
|
||||||
|
```js
|
||||||
|
registerParser({
|
||||||
|
metadata,
|
||||||
|
canHandle,
|
||||||
|
parse
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Examples
|
||||||
|
|
||||||
|
请先查看 [example_parser_basic.js](./example_parser_basic.js) 了解最简示例解析器插件的实现.
|
||||||
|
|
||||||
|
然后查看 [example_parser_danbooru.js](./example_parser_danbooru.js) , 这是一个可直接使用的插件, 用于解析 Danbooru 图片页面并提取图片资源.
|
||||||
73
plugins/example_parser_basic.js
Normal file
73
plugins/example_parser_basic.js
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
// 这是一个最简示例解析器插件, 用于展示插件所需实现的基本功能
|
||||||
|
// 此插件将会模拟处理 YouTube 的视频链接
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 插件元数据
|
||||||
|
* 版本号是 saveany-bot 本体支持的插件规范版本号, 必须提供
|
||||||
|
*/
|
||||||
|
const metadata = {
|
||||||
|
name: "Example Parser", // 插件名称
|
||||||
|
version: "1.0.0", // 插件版本号
|
||||||
|
description: "A parser for example links", // 插件描述
|
||||||
|
author: "Krau", // 插件作者
|
||||||
|
}
|
||||||
|
|
||||||
|
// 你可以使用 console.log 来在终端中使用 go 的 logger 打印信息
|
||||||
|
console.log("Parser loaded", "name", metadata.name);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* canHandle 函数用于判断当前解析器能否解析给定的 URL
|
||||||
|
*/
|
||||||
|
const canHandle = function (url) {
|
||||||
|
// 这里我们简单地检查 URL 是否包含 "youtube.com/watch?v"
|
||||||
|
return url.includes("youtube.com/watch?v");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析 url 并返回一个 Item 对象, 类型定义在 pkg/parser.go 中
|
||||||
|
*/
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 最后需要调用 registerParser 来注册这个解析器
|
||||||
|
registerParser({
|
||||||
|
metadata,
|
||||||
|
canHandle,
|
||||||
|
parse
|
||||||
|
});
|
||||||
|
|
||||||
|
// 更进一步的插件编写信息, 请查看 plugins/example_parser_danbooru.js
|
||||||
138
plugins/example_parser_danbooru.js
Normal file
138
plugins/example_parser_danbooru.js
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
// Danbooru post parser for SaveAnyBot
|
||||||
|
// request https://danbooru.donmai.us/posts/{id}.json and parse the response
|
||||||
|
|
||||||
|
const metadata = {
|
||||||
|
name: "Danbooru Post Parser",
|
||||||
|
version: "1.0.0",
|
||||||
|
description: "Parse Danbooru post links via official JSON API",
|
||||||
|
author: "Krau",
|
||||||
|
};
|
||||||
|
|
||||||
|
// some utils
|
||||||
|
const danbooruSourceURLRegexp = /danbooru\.donmai\.us\/(posts|post\/show)\/(\d+)/;
|
||||||
|
function getPostID(url) {
|
||||||
|
const m = url.match(danbooruSourceURLRegexp);
|
||||||
|
return m ? m[2] : "";
|
||||||
|
}
|
||||||
|
function normalizePostURL(id) {
|
||||||
|
return `https://danbooru.donmai.us/posts/${id}`;
|
||||||
|
}
|
||||||
|
function apiURLFor(id) {
|
||||||
|
return `https://danbooru.donmai.us/posts/${id}.json`;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function basenameFromURL(u) {
|
||||||
|
try {
|
||||||
|
const q = u.split("?")[0];
|
||||||
|
const parts = q.split("/");
|
||||||
|
const name = parts[parts.length - 1] || "";
|
||||||
|
return name || "file";
|
||||||
|
} catch (_) {
|
||||||
|
return "file";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function extFromFilename(name) {
|
||||||
|
const idx = name.lastIndexOf(".");
|
||||||
|
if (idx < 0) return "";
|
||||||
|
return name.slice(idx + 1).toLowerCase();
|
||||||
|
}
|
||||||
|
function mimeFromExt(ext) {
|
||||||
|
switch (ext) {
|
||||||
|
case "jpg":
|
||||||
|
case "jpeg":
|
||||||
|
return "image/jpeg";
|
||||||
|
case "png":
|
||||||
|
return "image/png";
|
||||||
|
case "gif":
|
||||||
|
return "image/gif";
|
||||||
|
default:
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// implement canHandle and parse
|
||||||
|
const canHandle = function (url) {
|
||||||
|
return danbooruSourceURLRegexp.test(url);
|
||||||
|
};
|
||||||
|
|
||||||
|
const parse = function (sourceURL) {
|
||||||
|
const id = getPostID(sourceURL);
|
||||||
|
if (!id) {
|
||||||
|
throw new Error("invalid danbooru post url");
|
||||||
|
}
|
||||||
|
|
||||||
|
const normURL = normalizePostURL(id);
|
||||||
|
|
||||||
|
const apiURL = apiURLFor(id);
|
||||||
|
console.log("Danbooru requesting", "url", apiURL);
|
||||||
|
// You can use ghttp.getJSON to fetch and parse JSON in one step.
|
||||||
|
// While the ghttp.get can be used to fetch raw response.
|
||||||
|
const data = ghttp.getJSON(apiURL);
|
||||||
|
|
||||||
|
if (data && data.error) {
|
||||||
|
throw new Error(data.message || "danbooru returned error");
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileURL = data.file_url || "";
|
||||||
|
const largeURL = data.large_file_url || "";
|
||||||
|
const width = data.image_width || 0;
|
||||||
|
const height = data.image_height || 0;
|
||||||
|
|
||||||
|
if (!fileURL && !largeURL) {
|
||||||
|
throw new Error("danbooru response has no file_url / large_file_url");
|
||||||
|
}
|
||||||
|
|
||||||
|
const resources = [];
|
||||||
|
if (fileURL) {
|
||||||
|
const name = basenameFromURL(fileURL);
|
||||||
|
const ext = extFromFilename(name);
|
||||||
|
resources.push({
|
||||||
|
url: fileURL,
|
||||||
|
filename: name,
|
||||||
|
mime_type: mimeFromExt(ext),
|
||||||
|
extension: ext,
|
||||||
|
size: 0,
|
||||||
|
hash: {},
|
||||||
|
headers: {},
|
||||||
|
extra: { width, height, kind: "original" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (largeURL && largeURL !== fileURL) {
|
||||||
|
const name = basenameFromURL(largeURL);
|
||||||
|
const ext = extFromFilename(name);
|
||||||
|
resources.push({
|
||||||
|
url: largeURL,
|
||||||
|
filename: name,
|
||||||
|
mime_type: mimeFromExt(ext),
|
||||||
|
extension: ext,
|
||||||
|
size: 0,
|
||||||
|
hash: {},
|
||||||
|
headers: {},
|
||||||
|
extra: { width, height, kind: "large" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const tags = (data.tag_string ? String(data.tag_string) : "")
|
||||||
|
.split(" ")
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
const item = {
|
||||||
|
site: "Danbooru",
|
||||||
|
url: normURL,
|
||||||
|
title: `Danbooru/${data.id || id}`,
|
||||||
|
author: "Danbooru",
|
||||||
|
description: "",
|
||||||
|
tags: tags,
|
||||||
|
resources: resources,
|
||||||
|
extra: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
return item;
|
||||||
|
};
|
||||||
|
|
||||||
|
registerParser({
|
||||||
|
metadata,
|
||||||
|
canHandle,
|
||||||
|
parse,
|
||||||
|
});
|
||||||
@@ -20,7 +20,7 @@ func getStorageByName(ctx context.Context, name string) (Storage, error) {
|
|||||||
if ok {
|
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))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ func (t *Telegram) Save(ctx context.Context, r io.Reader, storagePath string) er
|
|||||||
}
|
}
|
||||||
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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user