Compare commits
89 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ae48bd52bf | ||
|
|
44de871f63 | ||
|
|
7a2a530e49 | ||
|
|
3aa84e89bf | ||
|
|
257c292679 | ||
|
|
0e2a9cacf2 | ||
|
|
a7854afb2a | ||
|
|
0e989cc1a6 | ||
|
|
76a82a38ee | ||
|
|
c7a0076c15 | ||
|
|
ea07ff7eca | ||
|
|
4d837e946c | ||
|
|
f947ee6fc7 | ||
|
|
40ad12a892 | ||
|
|
697e419643 | ||
|
|
eef051de3b | ||
|
|
6e29442c05 | ||
|
|
a3f1f75caf | ||
|
|
f05dd883e3 | ||
|
|
9cb866de8c | ||
|
|
980455fd24 | ||
|
|
24978470cd | ||
|
|
215e082028 | ||
|
|
a7b93e57fc | ||
|
|
a4b3b459a9 | ||
|
|
06f326088a | ||
|
|
b7d3ec6230 | ||
|
|
f812990e1c | ||
|
|
492900bbef | ||
|
|
764be2a083 | ||
|
|
46c21b77e9 | ||
|
|
8b389a58d5 | ||
|
|
25ad9befa0 | ||
|
|
e824b210d1 | ||
|
|
ae0aa7db3f | ||
|
|
226c15ef08 | ||
|
|
9b3f955e48 | ||
|
|
4997ec408f | ||
|
|
0756cc9eb1 | ||
|
|
37c32a23d4 | ||
|
|
3aa1e2eaed | ||
|
|
b87dd68880 | ||
|
|
68e5a51300 | ||
|
|
7300e54c40 | ||
|
|
94f796d0e8 | ||
|
|
c023fd869d | ||
|
|
e5d1e143e0 | ||
|
|
03eb4f8a18 | ||
|
|
231eb61d25 | ||
|
|
fd1b586b8d | ||
|
|
d035a3409e | ||
|
|
6112f6c240 | ||
|
|
18eedf2edb | ||
|
|
5f9bba9ff7 | ||
|
|
0d3d2209be | ||
|
|
302db2fe75 | ||
|
|
79386bdd7d | ||
|
|
f0607de2cc | ||
|
|
b2bfc96a8f | ||
|
|
0c5bb2ba77 | ||
|
|
9cc87380ff | ||
|
|
46afc14322 | ||
|
|
0c16650ea5 | ||
|
|
133453b5d4 | ||
|
|
8f9ef07d1c | ||
|
|
36285a0700 | ||
|
|
ccf206d176 | ||
|
|
4c851cbbaf | ||
|
|
b9d14f79c8 | ||
|
|
ee5e0b8ff0 | ||
|
|
6423fb25a7 | ||
|
|
03907f2d32 | ||
|
|
9e5042bda1 | ||
|
|
4ebacb02c1 | ||
|
|
818ac9b240 | ||
|
|
fc4a112f08 | ||
|
|
7a2274baa0 | ||
|
|
69a3ed6f4e | ||
|
|
36f3dd83fc | ||
|
|
501b9d844a | ||
|
|
03cec7ec01 | ||
|
|
dc0debcd1c | ||
|
|
4b136bd41e | ||
|
|
d703f11ea0 | ||
|
|
3ce9926967 | ||
|
|
80146176f0 | ||
|
|
14ba2afdf8 | ||
|
|
f4d427a1cb | ||
|
|
f84c83a7e2 |
@@ -6,6 +6,6 @@
|
||||
downloads/
|
||||
data/
|
||||
cache/
|
||||
docs
|
||||
docs/
|
||||
config.example.toml
|
||||
docker-compose.*
|
||||
|
||||
2
.github/FUNDING.yml
vendored
2
.github/FUNDING.yml
vendored
@@ -1,5 +1,5 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
custom: [
|
||||
"https://afdian.com/a/acherkrau"
|
||||
"https://afdian.com/a/unvapp"
|
||||
]
|
||||
31
.github/workflows/build-docker.yml
vendored
31
.github/workflows/build-docker.yml
vendored
@@ -29,13 +29,7 @@ jobs:
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=sha
|
||||
type=raw,value=latest
|
||||
type=ref,event=branch
|
||||
type=ref,event=tag
|
||||
labels: |
|
||||
org.opencontainers.image.title=${{ env.IMAGE_NAME }}
|
||||
org.opencontainers.image.source=https://github.com/krau/SaveAny-Bot
|
||||
org.opencontainers.image.url=https://github.com/krau/SaveAny-Bot
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
@@ -50,23 +44,26 @@ jobs:
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract version from Git Ref
|
||||
id: extract_version
|
||||
- name: Extract Dockerfile args
|
||||
id: args
|
||||
run: |
|
||||
VERSION=$(echo "${{ github.ref }}" | sed 's/refs\/tags\/v//')
|
||||
echo "VERSION=${VERSION}" >> $GITHUB_ENV
|
||||
echo "git_commit=$(git rev-parse --short HEAD)" >> "$GITHUB_OUTPUT"
|
||||
echo "build_time=$(git show -s --format=%cI)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Build and push Docker image
|
||||
id: build-and-push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
cache-from: type=gha
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: |
|
||||
type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
|
||||
type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
build-args: |
|
||||
VERSION=${{ steps.meta.outputs.version }}
|
||||
GitCommit=${{ github.sha }}
|
||||
BuildTime=${{ format(github.event.repository.updated_at, 'yyyy-MM-dd HH:mm:ss') }}
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
GitCommit=${{ steps.args.outputs.git_commit }}
|
||||
BuildTime=${{ steps.args.outputs.build_time }}
|
||||
|
||||
6
.github/workflows/build-release.yml
vendored
6
.github/workflows/build-release.yml
vendored
@@ -63,9 +63,9 @@ jobs:
|
||||
README.md
|
||||
ldflags: >-
|
||||
-s -w
|
||||
-X "github.com/krau/SaveAny-Bot/pkg/consts.Version=${{ env.VERSION }}"
|
||||
-X "github.com/krau/SaveAny-Bot/pkg/consts.BuildTime=${{ format(github.event.repository.updated_at, 'yyyy-MM-dd HH:mm:ss') }}"
|
||||
-X "github.com/krau/SaveAny-Bot/pkg/consts.GitCommit=${{ github.sha }}"
|
||||
-X "github.com/krau/SaveAny-Bot/config.Version=${{ env.VERSION }}"
|
||||
-X "github.com/krau/SaveAny-Bot/config.BuildTime=${{ format(github.event.repository.updated_at, 'yyyy-MM-dd HH:mm:ss') }}"
|
||||
-X "github.com/krau/SaveAny-Bot/config.GitCommit=${{ github.sha }}"
|
||||
binary_name: saveany-bot
|
||||
env:
|
||||
VERSION: ${{ env.VERSION }}
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -7,4 +7,4 @@ session.*
|
||||
cache.db
|
||||
.vscode/
|
||||
temp/
|
||||
.hugo_build.lock
|
||||
.hugo_build.lock
|
||||
|
||||
27
Dockerfile
27
Dockerfile
@@ -6,22 +6,33 @@ ARG BuildTime="Unknown"
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY . .
|
||||
COPY go.mod go.sum ./
|
||||
RUN --mount=type=cache,target=/go/pkg/mod \
|
||||
go mod download
|
||||
|
||||
COPY . .
|
||||
RUN --mount=type=cache,target=/root/.cache/go-build \
|
||||
--mount=type=cache,target=/go/pkg \
|
||||
CGO_ENABLED=0 \
|
||||
go build -trimpath \
|
||||
-ldflags "-s -w \
|
||||
-X github.com/krau/SaveAny-Bot/common.Version=${VERSION} \
|
||||
-X github.com/krau/SaveAny-Bot/common.GitCommit=${GiTCommit} \
|
||||
-X github.com/krau/SaveAny-Bot/common.BuildTime=${BuildTime}" \
|
||||
CGO_ENABLED=0 \
|
||||
go build -trimpath \
|
||||
-ldflags=" \
|
||||
-s -w \
|
||||
-X 'github.com/krau/SaveAny-Bot/config.Version=${VERSION}' \
|
||||
-X 'github.com/krau/SaveAny-Bot/config.GitCommit=${GitCommit}' \
|
||||
-X 'github.com/krau/SaveAny-Bot/config.BuildTime=${BuildTime}' \
|
||||
" \
|
||||
-o saveany-bot .
|
||||
|
||||
FROM alpine:latest
|
||||
|
||||
RUN apk add --no-cache curl
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=builder /app/saveany-bot .
|
||||
COPY entrypoint.sh .
|
||||
|
||||
ENTRYPOINT ["/app/saveany-bot"]
|
||||
RUN chmod +x /app/saveany-bot && \
|
||||
chmod +x /app/entrypoint.sh
|
||||
|
||||
ENTRYPOINT ["/app/entrypoint.sh"]
|
||||
|
||||
81
README.md
81
README.md
@@ -4,28 +4,70 @@
|
||||
|
||||
**简体中文** | [English](https://sabot.unv.app/en/)
|
||||
|
||||
把 Telegram 上的文件转存到多种存储端.
|
||||
> **把 Telegram 上的文件转存到多种存储端.**
|
||||
|
||||
[](https://github.com/krau/saveany-bot/releases)
|
||||
[](https://github.com/krau/saveany-bot/releases)
|
||||
[](https://github.com/krau/saveany-bot/actions/workflows/build-release.yml)
|
||||
[](https://github.com/krau/saveany-bot/stargazers)
|
||||
[](https://github.com/krau/saveany-bot/releases)
|
||||
[](https://github.com/krau/saveany-bot/issues)
|
||||
[](https://github.com/krau/saveany-bot/pulls)
|
||||
[](./LICENSE)
|
||||
|
||||
</div>
|
||||
|
||||
## 部署
|
||||
## 🎯 Features
|
||||
|
||||
请参考 [部署文档](https://sabot.unv.app/deployment/installation/)
|
||||
|
||||
## Features
|
||||
|
||||
- 支持文档/视频/图片/贴纸… 甚至还有 Telegraph
|
||||
- 支持文档/视频/图片/贴纸…甚至还有 [Telegraph](https://telegra.ph/)
|
||||
- 破解禁止保存的文件
|
||||
- 批量下载
|
||||
- 流式传输
|
||||
- 多用户
|
||||
- 多用户使用
|
||||
- 基于存储规则的自动整理
|
||||
- 支持多种存储端:
|
||||
- 监听并自动转存指定聊天的消息, 支持过滤
|
||||
- 使用 js 编写解析器插件以转存任意网站的文件
|
||||
- 存储端支持:
|
||||
- Alist
|
||||
- Minio (S3 兼容)
|
||||
- S3 (MinioSDK)
|
||||
- WebDAV
|
||||
- Telegram (重传回指定聊天)
|
||||
- 本地磁盘
|
||||
- Telegram (重传回指定聊天)
|
||||
|
||||
## 📦 Quick Start
|
||||
|
||||
创建文件 `config.toml` 并填入以下内容:
|
||||
|
||||
```toml
|
||||
[telegram]
|
||||
token = "" # 你的 Bot Token, 在 @BotFather 获取
|
||||
[telegram.proxy]
|
||||
# 启用代理连接 telegram, 当前只支持 socks5
|
||||
enable = false
|
||||
url = "socks5://127.0.0.1:7890"
|
||||
|
||||
[[storages]]
|
||||
name = "本地磁盘"
|
||||
type = "local"
|
||||
enable = true
|
||||
base_path = "./downloads"
|
||||
|
||||
[[users]]
|
||||
id = 114514 # 你的 Telegram 账号 id
|
||||
storages = []
|
||||
blacklist = true
|
||||
```
|
||||
|
||||
使用 Docker 运行 Save Any Bot:
|
||||
|
||||
```bash
|
||||
docker run -d --name saveany-bot \
|
||||
-v ./config.toml:/app/config.toml \
|
||||
-v ./downloads:/app/downloads \
|
||||
ghcr.io/krau/saveany-bot:latest
|
||||
```
|
||||
|
||||
请 [**查看文档**](https://sabot.unv.app/) 以获取更多配置选项和使用方法.
|
||||
|
||||
## Sponsors
|
||||
|
||||
@@ -48,6 +90,13 @@
|
||||
<sub><b>Krau</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://github.com/Silentely">
|
||||
<img src="https://avatars.githubusercontent.com/u/22141172?v=4" width="100;" alt="Silentely"/>
|
||||
<br />
|
||||
<sub><b>Abner</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://github.com/TG-Twilight">
|
||||
<img src="https://avatars.githubusercontent.com/u/121682528?v=4" width="100;" alt="TG-Twilight"/>
|
||||
@@ -63,8 +112,8 @@
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://github.com/ahcorn">
|
||||
<img src="https://avatars.githubusercontent.com/u/42889600?v=4" width="100;" alt="ahcorn"/>
|
||||
<a href="https://github.com/AHCorn">
|
||||
<img src="https://avatars.githubusercontent.com/u/42889600?v=4" width="100;" alt="AHCorn"/>
|
||||
<br />
|
||||
<sub><b>安和</b></sub>
|
||||
</a>
|
||||
@@ -81,3 +130,9 @@
|
||||
- [gotgproto](https://github.com/celestix/gotgproto)
|
||||
- [tdl](https://github.com/iyear/tdl)
|
||||
- All the dependencies
|
||||
|
||||
## Contact
|
||||
|
||||
- [](https://t.me/ProjectSaveAny)
|
||||
- [](https://github.com/krau/saveany-bot/discussions)
|
||||
- [](https://t.me/acherkrau)
|
||||
@@ -19,16 +19,17 @@ import (
|
||||
"golang.org/x/net/proxy"
|
||||
)
|
||||
|
||||
func Init(ctx context.Context) {
|
||||
func Init(ctx context.Context) <-chan struct{} {
|
||||
log.FromContext(ctx).Info("初始化 Bot...")
|
||||
resultChan := make(chan struct {
|
||||
client *gotgproto.Client
|
||||
err error
|
||||
})
|
||||
shouldRestart := make(chan struct{})
|
||||
go func() {
|
||||
var resolver dcs.Resolver
|
||||
if config.Cfg.Telegram.Proxy.Enable && config.Cfg.Telegram.Proxy.URL != "" {
|
||||
dialer, err := netutil.NewProxyDialer(config.Cfg.Telegram.Proxy.URL)
|
||||
if config.C().Telegram.Proxy.Enable && config.C().Telegram.Proxy.URL != "" {
|
||||
dialer, err := netutil.NewProxyDialer(config.C().Telegram.Proxy.URL)
|
||||
if err != nil {
|
||||
resultChan <- struct {
|
||||
client *gotgproto.Client
|
||||
@@ -43,19 +44,23 @@ func Init(ctx context.Context) {
|
||||
resolver = dcs.DefaultResolver()
|
||||
}
|
||||
client, err := gotgproto.NewClient(
|
||||
config.Cfg.Telegram.AppID,
|
||||
config.Cfg.Telegram.AppHash,
|
||||
gotgproto.ClientTypeBot(config.Cfg.Telegram.Token),
|
||||
config.C().Telegram.AppID,
|
||||
config.C().Telegram.AppHash,
|
||||
gotgproto.ClientTypeBot(config.C().Telegram.Token),
|
||||
&gotgproto.ClientOpts{
|
||||
Session: sessionMaker.SqlSession(gormlite.Open(config.Cfg.DB.Session)),
|
||||
Session: sessionMaker.SqlSession(gormlite.Open(config.C().DB.Session)),
|
||||
DisableCopyright: true,
|
||||
Middlewares: middleware.NewDefaultMiddlewares(ctx, 5*time.Minute),
|
||||
Resolver: resolver,
|
||||
Context: ctx,
|
||||
MaxRetries: config.Cfg.Telegram.RpcRetry,
|
||||
MaxRetries: config.C().Telegram.RpcRetry,
|
||||
AutoFetchReply: true,
|
||||
ErrorHandler: func(ctx *ext.Context, u *ext.Update, s string) error {
|
||||
log.FromContext(ctx).Errorf("Unhandled error: %s", s)
|
||||
if s == "SAVEANTBOT-RESTART" {
|
||||
shouldRestart <- struct{}{}
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
log.FromContext(ctx).Errorf("unhandled error: %s", s)
|
||||
return dispatcher.EndGroups
|
||||
},
|
||||
},
|
||||
@@ -70,17 +75,13 @@ func Init(ctx context.Context) {
|
||||
client.API().BotsSetBotCommands(ctx, &tg.BotsSetBotCommandsRequest{
|
||||
Scope: &tg.BotCommandScopeDefault{},
|
||||
})
|
||||
commands := make([]tg.BotCommand, 0, len(handlers.CommandHandlers))
|
||||
for _, info := range handlers.CommandHandlers {
|
||||
commands = append(commands, tg.BotCommand{Command: info.Cmd, Description: info.Desc})
|
||||
}
|
||||
_, err = client.API().BotsSetBotCommands(ctx, &tg.BotsSetBotCommandsRequest{
|
||||
Scope: &tg.BotCommandScopeDefault{},
|
||||
Commands: []tg.BotCommand{
|
||||
{Command: "start", Description: "开始使用"},
|
||||
{Command: "help", Description: "显示帮助"},
|
||||
{Command: "silent", Description: "开启/关闭静默模式"},
|
||||
{Command: "storage", Description: "设置默认存储端"},
|
||||
{Command: "save", Description: "保存所回复的文件"},
|
||||
{Command: "dir", Description: "管理存储文件夹"},
|
||||
{Command: "rule", Description: "管理规则"},
|
||||
},
|
||||
Scope: &tg.BotCommandScopeDefault{},
|
||||
Commands: commands,
|
||||
})
|
||||
resultChan <- struct {
|
||||
client *gotgproto.Client
|
||||
@@ -98,4 +99,5 @@ func Init(ctx context.Context) {
|
||||
handlers.Register(result.client.Dispatcher)
|
||||
log.FromContext(ctx).Info("Bot 初始化完成")
|
||||
}
|
||||
return shouldRestart
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package handlers
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"github.com/celestix/gotgproto/dispatcher"
|
||||
@@ -11,6 +12,7 @@ import (
|
||||
"github.com/gotd/td/tg"
|
||||
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/msgelem"
|
||||
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/shortcut"
|
||||
"github.com/krau/SaveAny-Bot/common/utils/fsutil"
|
||||
"github.com/krau/SaveAny-Bot/database"
|
||||
"github.com/krau/SaveAny-Bot/pkg/enums/tasktype"
|
||||
"github.com/krau/SaveAny-Bot/pkg/tcbdata"
|
||||
@@ -72,7 +74,12 @@ func handleAddCallback(ctx *ext.Context, update *ext.Update) error {
|
||||
}
|
||||
return shortcut.CreateAndAddTGFileTaskWithEdit(ctx, userID, selectedStorage, dirPath, data.Files[0], msgID)
|
||||
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:
|
||||
if len(data.ParsedItem.Resources) > 1 {
|
||||
dirPath = path.Join(dirPath, fsutil.NormalizePathname(data.ParsedItem.Title))
|
||||
}
|
||||
shortcut.CreateAndAddParsedTaskWithEdit(ctx, selectedStorage, dirPath, data.ParsedItem, msgID, userID)
|
||||
default:
|
||||
log.FromContext(ctx).Errorf("Unsupported task type: %s", data.TaskType)
|
||||
}
|
||||
|
||||
144
client/bot/handlers/config.go
Normal file
144
client/bot/handlers/config.go
Normal file
@@ -0,0 +1,144 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"text/template"
|
||||
|
||||
"github.com/celestix/gotgproto/dispatcher"
|
||||
"github.com/celestix/gotgproto/ext"
|
||||
"github.com/gotd/td/tg"
|
||||
"github.com/krau/SaveAny-Bot/database"
|
||||
"github.com/krau/SaveAny-Bot/pkg/enums/fnamest"
|
||||
"github.com/krau/SaveAny-Bot/pkg/tcbdata"
|
||||
)
|
||||
|
||||
func handleConfigCmd(ctx *ext.Context, update *ext.Update) error {
|
||||
ctx.Reply(update, ext.ReplyTextString("请选择要配置的选项"), &ext.ReplyOpts{
|
||||
Markup: &tg.ReplyInlineMarkup{
|
||||
Rows: []tg.KeyboardButtonRow{
|
||||
{
|
||||
Buttons: []tg.KeyboardButtonClass{
|
||||
&tg.KeyboardButtonCallback{
|
||||
Text: "文件名策略",
|
||||
Data: fmt.Appendf(nil, "%s %s", tcbdata.TypeConfig, "fnamest"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
|
||||
func handleConfigCallback(ctx *ext.Context, update *ext.Update) error {
|
||||
args := strings.Fields(string(update.CallbackQuery.Data))
|
||||
invaildDataAnswer := func() error {
|
||||
ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{
|
||||
QueryID: update.CallbackQuery.GetQueryID(),
|
||||
Alert: true,
|
||||
Message: "无效的回调数据",
|
||||
CacheTime: 5,
|
||||
})
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
if len(args) < 2 {
|
||||
return invaildDataAnswer()
|
||||
}
|
||||
switch args[1] {
|
||||
case "fnamest":
|
||||
return handleConfigFnameSTCallback(ctx, update)
|
||||
default:
|
||||
return invaildDataAnswer()
|
||||
}
|
||||
}
|
||||
|
||||
func handleConfigFnameSTCallback(ctx *ext.Context, update *ext.Update) error {
|
||||
userID := update.CallbackQuery.GetUserID()
|
||||
user, err := database.GetUserByChatID(ctx, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
args := strings.Fields(string(update.CallbackQuery.Data))
|
||||
if len(args) == 3 {
|
||||
selected := args[2]
|
||||
st, err := fnamest.ParseFnameST(selected)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
user.FilenameStrategy = st.String()
|
||||
if err := database.UpdateUser(ctx, user); err != nil {
|
||||
return err
|
||||
}
|
||||
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
|
||||
ID: update.CallbackQuery.GetMsgID(),
|
||||
Message: fmt.Sprintf("已将文件名策略设置为: %s", fnamest.FnameSTDisplay[st]),
|
||||
})
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
opts := fnamest.FnameSTValues()
|
||||
buttons := make([]tg.KeyboardButtonClass, 0, len(opts))
|
||||
for _, opt := range opts {
|
||||
buttons = append(buttons, &tg.KeyboardButtonCallback{
|
||||
Text: fnamest.FnameSTDisplay[opt],
|
||||
Data: fmt.Appendf(nil, "%s %s %s", tcbdata.TypeConfig, "fnamest", opt),
|
||||
})
|
||||
}
|
||||
markup := &tg.ReplyInlineMarkup{Rows: []tg.KeyboardButtonRow{
|
||||
{Buttons: buttons},
|
||||
}}
|
||||
currentStStr := user.FilenameStrategy
|
||||
if currentStStr == "" {
|
||||
currentStStr = fnamest.Default.String()
|
||||
}
|
||||
currentSt, err := fnamest.ParseFnameST(currentStStr)
|
||||
if err != nil {
|
||||
currentSt = fnamest.Default
|
||||
}
|
||||
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
|
||||
ID: update.CallbackQuery.GetMsgID(),
|
||||
Message: fmt.Sprintf("请选择文件名策略, 当前策略: %s", fnamest.FnameSTDisplay[currentSt]),
|
||||
ReplyMarkup: markup,
|
||||
})
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
|
||||
func handleConfigFnameTmpl(ctx *ext.Context, update *ext.Update) error {
|
||||
userID := update.GetUserChat().GetID()
|
||||
user, err := database.GetUserByChatID(ctx, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
args := strings.Fields(string(update.EffectiveMessage.Text))
|
||||
if len(args) <= 1 {
|
||||
text := `使用该命令设置文件名模板, 示例:
|
||||
/fnametmpl 图片_{{.msgid}}_{{.msgdate}}.jpg
|
||||
|
||||
可用变量:
|
||||
- {{.msgid}}: 消息ID
|
||||
- {{.msgtags}}: 消息中的标签, 将以下划线分隔输出
|
||||
- {{.msggen}}: 根据消息生成的文件名
|
||||
- {{.msgdate}}: 消息日期, 格式 YYYY-MM-DD_HH-MM-SS
|
||||
- {{.origname}}: 媒体的原始文件名 (如果有)
|
||||
- {{.chatid}}: 消息的聊天ID
|
||||
`
|
||||
if user.FilenameTemplate != "" {
|
||||
text += fmt.Sprintf("\n\n当前模板: %s", user.FilenameTemplate)
|
||||
}
|
||||
text += "\n\n模板仅在文件名策略设置为 '自定义模板' 时生效, 且模板解析错误时会回退到默认文件名"
|
||||
ctx.Reply(update, ext.ReplyTextString(text), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
newTmpl := strings.Join(args[1:], " ")
|
||||
_, err = template.New("filename").Parse(newTmpl)
|
||||
if err != nil {
|
||||
ctx.Reply(update, ext.ReplyTextString("无效的模板, 请检查语法\n"+err.Error()), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
user.FilenameTemplate = newTmpl
|
||||
if err := database.UpdateUser(ctx, user); err != nil {
|
||||
return err
|
||||
}
|
||||
ctx.Reply(update, ext.ReplyTextString("已更新文件名模板"), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
@@ -5,27 +5,16 @@ import (
|
||||
|
||||
"github.com/celestix/gotgproto/dispatcher"
|
||||
"github.com/celestix/gotgproto/ext"
|
||||
"github.com/krau/SaveAny-Bot/pkg/consts"
|
||||
"github.com/krau/SaveAny-Bot/common/i18n"
|
||||
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
|
||||
"github.com/krau/SaveAny-Bot/config"
|
||||
)
|
||||
|
||||
func handleHelpCmd(ctx *ext.Context, update *ext.Update) error {
|
||||
const helpText string = `
|
||||
Save Any Bot - 转存你的 Telegram 文件
|
||||
版本: %s , 提交: %s
|
||||
|
||||
命令:
|
||||
/start - 开始使用
|
||||
/help - 显示帮助
|
||||
/silent - 开关静默模式
|
||||
/storage - 设置默认存储位置
|
||||
/save [自定义文件名] - 保存文件
|
||||
|
||||
使用帮助: https://sabot.unv.app/usage/
|
||||
`
|
||||
shortHash := consts.GitCommit
|
||||
shortHash := config.GitCommit
|
||||
if len(shortHash) > 7 {
|
||||
shortHash = shortHash[:7]
|
||||
}
|
||||
ctx.Reply(update, ext.ReplyTextString(fmt.Sprintf(helpText, consts.Version, shortHash)), nil)
|
||||
ctx.Reply(update, ext.ReplyTextString(fmt.Sprintf(i18n.T(i18nk.BotMsgHelpTextFmt), config.Version, shortHash)), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
|
||||
@@ -1,23 +1,49 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"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/mediautil"
|
||||
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/msgelem"
|
||||
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/shortcut"
|
||||
"github.com/krau/SaveAny-Bot/common/utils/tgutil"
|
||||
"github.com/krau/SaveAny-Bot/database"
|
||||
"github.com/krau/SaveAny-Bot/pkg/tcbdata"
|
||||
"github.com/krau/SaveAny-Bot/pkg/tfile"
|
||||
"github.com/krau/SaveAny-Bot/storage"
|
||||
)
|
||||
|
||||
func handleMediaMessage(ctx *ext.Context, update *ext.Update) error {
|
||||
logger := log.FromContext(ctx)
|
||||
message := update.EffectiveMessage.Message
|
||||
groupID, isGroup := message.GetGroupedID()
|
||||
if isGroup && groupID != 0 {
|
||||
return handleGroupMediaMessage(ctx, update, message, groupID)
|
||||
}
|
||||
logger.Debugf("Got media: %s", message.Media.TypeName())
|
||||
msg, file, err := shortcut.GetFileFromMessageWithReply(ctx, update, message)
|
||||
userId := update.GetUserChat().GetID()
|
||||
userDB, err := database.GetUserByChatID(ctx, userId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
userId := update.GetUserChat().GetID()
|
||||
// tfOpts := make([]tfile.TGFileOption, 0)
|
||||
// switch userDB.FilenameStrategy {
|
||||
// case fnamest.Message.String():
|
||||
// tfOpts = append(tfOpts, tfile.WithName(tgutil.GenFileNameFromMessage(*message)))
|
||||
// default:
|
||||
// }
|
||||
tfOpts := mediautil.TfileOptions(ctx, userDB, message)
|
||||
msg, file, err := shortcut.GetFileFromMessageWithReply(ctx, update, message, tfOpts...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
stors := storage.GetUserStorages(ctx, userId)
|
||||
req, err := msgelem.BuildAddOneSelectStorageMessage(ctx, stors, file, msg.ID)
|
||||
if err != nil {
|
||||
@@ -38,11 +64,119 @@ func handleSilentSaveMedia(ctx *ext.Context, update *ext.Update) error {
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
message := update.EffectiveMessage.Message
|
||||
groupID, isGroup := message.GetGroupedID()
|
||||
if isGroup && groupID != 0 {
|
||||
return handleGroupMediaMessage(ctx, update, message, groupID)
|
||||
}
|
||||
logger.Debugf("Got media: %s", message.Media.TypeName())
|
||||
userID := update.GetUserChat().GetID()
|
||||
msg, file, err := shortcut.GetFileFromMessageWithReply(ctx, update, message)
|
||||
userDB, err := database.GetUserByChatID(ctx, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// tfOpts := make([]tfile.TGFileOption, 0)
|
||||
// switch userDB.FilenameStrategy {
|
||||
// case fnamest.Message.String():
|
||||
// tfOpts = append(tfOpts, tfile.WithName(tgutil.GenFileNameFromMessage(*message)))
|
||||
// default:
|
||||
// }
|
||||
tfOpts := mediautil.TfileOptions(ctx, userDB, message)
|
||||
msg, file, err := shortcut.GetFileFromMessageWithReply(ctx, update, message, tfOpts...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return shortcut.CreateAndAddTGFileTaskWithEdit(ctx, userID, stor, "", file, msg.ID)
|
||||
}
|
||||
|
||||
type MediaGroupHandler struct {
|
||||
groups map[int64][]tfile.TGFileMessage
|
||||
timers map[int64]*time.Timer
|
||||
mu sync.Mutex
|
||||
timeout time.Duration
|
||||
}
|
||||
|
||||
var mediaGroupHandler = &MediaGroupHandler{
|
||||
groups: make(map[int64][]tfile.TGFileMessage),
|
||||
timers: make(map[int64]*time.Timer),
|
||||
timeout: 1 * time.Second,
|
||||
}
|
||||
|
||||
func handleGroupMediaMessage(ctx *ext.Context, update *ext.Update, message *tg.Message, groupID int64) error {
|
||||
logger := log.FromContext(ctx)
|
||||
media := message.Media
|
||||
supported := mediautil.IsSupported(media)
|
||||
if !supported {
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
file, err := tfile.FromMediaMessage(media, ctx.Raw, message, tfile.WithNameIfEmpty(
|
||||
tgutil.GenFileNameFromMessage(*message),
|
||||
))
|
||||
if err != nil {
|
||||
logger.Errorf("Failed to get file from media: %s", err)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
mediaGroupHandler.mu.Lock()
|
||||
defer mediaGroupHandler.mu.Unlock()
|
||||
if mediaGroupHandler.groups[groupID] == nil {
|
||||
mediaGroupHandler.groups[groupID] = make([]tfile.TGFileMessage, 0)
|
||||
}
|
||||
mediaGroupHandler.groups[groupID] = append(mediaGroupHandler.groups[groupID], file)
|
||||
|
||||
if timer, exists := mediaGroupHandler.timers[groupID]; exists {
|
||||
timer.Stop()
|
||||
}
|
||||
mediaGroupHandler.timers[groupID] = time.AfterFunc(mediaGroupHandler.timeout, func() {
|
||||
processMediaGroup(ctx, update, groupID)
|
||||
})
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
|
||||
func processMediaGroup(ctx *ext.Context, update *ext.Update, groupID int64) {
|
||||
logger := log.FromContext(ctx)
|
||||
mediaGroupHandler.mu.Lock()
|
||||
items := mediaGroupHandler.groups[groupID]
|
||||
delete(mediaGroupHandler.groups, groupID)
|
||||
delete(mediaGroupHandler.timers, groupID)
|
||||
mediaGroupHandler.mu.Unlock()
|
||||
if len(items) == 0 {
|
||||
logger.Warn("No media items to process for group", "groupID", groupID)
|
||||
return
|
||||
}
|
||||
logger.Debugf("Processing media group %d with %d items", groupID, len(items))
|
||||
|
||||
userId := update.GetUserChat().GetID()
|
||||
msg, err := ctx.Reply(update, ext.ReplyTextString("正在保存文件..."), nil)
|
||||
if err != nil {
|
||||
logger.Errorf("Failed to reply: %s", err)
|
||||
return
|
||||
}
|
||||
stor := storage.FromContext(ctx)
|
||||
if stor != nil {
|
||||
// In silent mode
|
||||
if len(items) == 1 {
|
||||
shortcut.CreateAndAddTGFileTaskWithEdit(ctx, userId, stor, "", items[0], msg.ID)
|
||||
return
|
||||
}
|
||||
shortcut.CreateAndAddBatchTGFileTaskWithEdit(ctx, userId, stor, "", items, msg.ID)
|
||||
return
|
||||
}
|
||||
|
||||
stors := storage.GetUserStorages(ctx, userId)
|
||||
markup, err := msgelem.BuildAddSelectStorageKeyboard(stors, tcbdata.Add{
|
||||
Files: items,
|
||||
AsBatch: len(items) > 1,
|
||||
})
|
||||
if err != nil {
|
||||
logger.Errorf("构建存储选择键盘失败: %s", err)
|
||||
ctx.EditMessage(userId, &tg.MessagesEditMessageRequest{
|
||||
ID: msg.ID,
|
||||
Message: "构建存储选择键盘失败: " + err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
ctx.EditMessage(userId, &tg.MessagesEditMessageRequest{
|
||||
ID: msg.ID,
|
||||
Message: fmt.Sprintf("共 %d 个文件, 请选择存储位置", len(items)),
|
||||
ReplyMarkup: markup,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
|
||||
func checkPermission(ctx *ext.Context, update *ext.Update) error {
|
||||
userID := update.GetUserChat().GetID()
|
||||
if !slice.Contain(config.Cfg.GetUsersID(), userID) {
|
||||
if !slice.Contain(config.C().GetUsersID(), userID) {
|
||||
const noPermissionText string = `
|
||||
您不在白名单中, 无法使用此 Bot.
|
||||
您可以部署自己的实例: https://github.com/krau/SaveAny-Bot
|
||||
|
||||
121
client/bot/handlers/parse.go
Normal file
121
client/bot/handlers/parse.go
Normal file
@@ -0,0 +1,121 @@
|
||||
// 处理任意文本消息, 用于通用地从外部源下载文件
|
||||
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"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/common/utils/fsutil"
|
||||
"github.com/krau/SaveAny-Bot/common/utils/tgutil"
|
||||
"github.com/krau/SaveAny-Bot/parsers"
|
||||
"github.com/krau/SaveAny-Bot/pkg/enums/tasktype"
|
||||
"github.com/krau/SaveAny-Bot/pkg/tcbdata"
|
||||
"github.com/krau/SaveAny-Bot/storage"
|
||||
)
|
||||
|
||||
func handleTextMessage(ctx *ext.Context, u *ext.Update) error {
|
||||
logger := log.FromContext(ctx)
|
||||
text := u.EffectiveMessage.Text
|
||||
entityUrls := tgutil.ExtractMessageEntityUrls(u.EffectiveMessage.Message)
|
||||
if len(entityUrls) > 0 {
|
||||
text += "\n" + strings.Join(entityUrls, "\n")
|
||||
}
|
||||
ok, pser := parsers.CanHandle(text)
|
||||
if !ok {
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
msg, err := ctx.Reply(u, ext.ReplyTextString("正在解析..."), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
item, err := pser.Parse(ctx, text)
|
||||
if errors.Is(err, parsers.ErrNoParserFound) {
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
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
|
||||
}
|
||||
dirPath := ""
|
||||
if len(item.Resources) > 1 {
|
||||
dirPath = fsutil.NormalizePathname(item.Title)
|
||||
}
|
||||
return shortcut.CreateAndAddParsedTaskWithEdit(ctx, stor, dirPath, item, msg.ID, userID)
|
||||
}
|
||||
@@ -1,14 +1,41 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
|
||||
"github.com/celestix/gotgproto/dispatcher"
|
||||
"github.com/celestix/gotgproto/dispatcher/handlers"
|
||||
"github.com/celestix/gotgproto/dispatcher/handlers/filters"
|
||||
"github.com/celestix/gotgproto/ext"
|
||||
sabotfilters "github.com/krau/SaveAny-Bot/client/bot/handlers/utils/filters"
|
||||
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/re"
|
||||
userclient "github.com/krau/SaveAny-Bot/client/user"
|
||||
"github.com/krau/SaveAny-Bot/config"
|
||||
"github.com/krau/SaveAny-Bot/pkg/tcbdata"
|
||||
)
|
||||
|
||||
type DescCommandHandler struct {
|
||||
Cmd string
|
||||
Desc string
|
||||
handler func(ctx *ext.Context, u *ext.Update) error
|
||||
}
|
||||
|
||||
var CommandHandlers = []DescCommandHandler{
|
||||
{"start", "开始使用", handleHelpCmd},
|
||||
{"silent", "切换静默模式", handleSilentCmd},
|
||||
{"storage", "设置默认存储端", handleStorageCmd},
|
||||
{"dir", "管理存储文件夹", handleDirCmd},
|
||||
{"rule", "管理自动存储规则", handleRuleCmd},
|
||||
{"watch", "监听聊天(UserBot)", handleWatchCmd},
|
||||
{"unwatch", "取消监听聊天(UserBot)", handleUnwatchCmd},
|
||||
{"lswatch", "列出监听的聊天(UserBot)", handleLswatchCmd},
|
||||
{"save", "保存文件", handleSilentMode(handleSaveCmd, handleSilentSaveReplied)},
|
||||
{"config", "修改配置", handleConfigCmd},
|
||||
{"fnametmpl", "设置文件命名模板", handleConfigFnameTmpl},
|
||||
{"update", "检查更新", handleUpdateCmd},
|
||||
{"help", "显示帮助", handleHelpCmd},
|
||||
}
|
||||
|
||||
func Register(disp dispatcher.Dispatcher) {
|
||||
disp.AddHandler(handlers.NewMessage(filters.Message.ChatType(filters.ChatTypeChannel), func(ctx *ext.Context, u *ext.Update) error {
|
||||
return dispatcher.EndGroups
|
||||
@@ -17,25 +44,20 @@ func Register(disp dispatcher.Dispatcher) {
|
||||
return dispatcher.EndGroups
|
||||
}))
|
||||
disp.AddHandler(handlers.NewMessage(filters.Message.All, checkPermission))
|
||||
disp.AddHandler(handlers.NewCommand("start", handleHelpCmd))
|
||||
disp.AddHandler(handlers.NewCommand("help", handleHelpCmd))
|
||||
disp.AddHandler(handlers.NewCommand("silent", handleSilentCmd))
|
||||
disp.AddHandler(handlers.NewCommand("storage", handleStorageCmd))
|
||||
disp.AddHandler(handlers.NewCommand("dir", handleDirCmd))
|
||||
disp.AddHandler(handlers.NewCommand("rule", handleRuleCmd))
|
||||
disp.AddHandler(handlers.NewCommand("save", handleSilentMode(handleSaveCmd, handleSilentSaveReplied)))
|
||||
for _, info := range CommandHandlers {
|
||||
disp.AddHandler(handlers.NewCommand(info.Cmd, info.handler))
|
||||
}
|
||||
disp.AddHandler(handlers.NewCallbackQuery(filters.CallbackQuery.Prefix("update"), handleUpdateCallback))
|
||||
disp.AddHandler(handlers.NewCallbackQuery(filters.CallbackQuery.Prefix(tcbdata.TypeAdd), handleAddCallback))
|
||||
disp.AddHandler(handlers.NewCallbackQuery(filters.CallbackQuery.Prefix(tcbdata.TypeSetDefault), handleSetDefaultCallback))
|
||||
disp.AddHandler(handlers.NewCallbackQuery(filters.CallbackQuery.Prefix("cancel"), handleCancelCallback))
|
||||
linkRegexFilter, err := filters.Message.Regex(re.TgMessageLinkRegexString)
|
||||
if err != nil {
|
||||
panic("failed to create regex filter: " + err.Error())
|
||||
}
|
||||
disp.AddHandler(handlers.NewMessage(linkRegexFilter, handleSilentMode(handleMessageLink, handleSilentSaveLink)))
|
||||
telegraphUrlRegexFilter, err := filters.Message.Regex(re.TelegraphUrlRegexString)
|
||||
if err != nil {
|
||||
panic("failed to create Telegraph URL regex filter: " + err.Error())
|
||||
}
|
||||
disp.AddHandler(handlers.NewMessage(telegraphUrlRegexFilter, handleSilentMode(handleTelegraphUrlMessage, handleSilentSaveTelegraph)))
|
||||
disp.AddHandler(handlers.NewCallbackQuery(filters.CallbackQuery.Prefix(tcbdata.TypeCancel), handleCancelCallback))
|
||||
disp.AddHandler(handlers.NewCallbackQuery(filters.CallbackQuery.Prefix(tcbdata.TypeConfig), handleConfigCallback))
|
||||
disp.AddHandler(handlers.NewMessage(sabotfilters.RegexUrl(regexp.MustCompile(re.TgMessageLinkRegexString)), handleSilentMode(handleMessageLink, handleSilentSaveLink)))
|
||||
disp.AddHandler(handlers.NewMessage(sabotfilters.RegexUrl(regexp.MustCompile(re.TelegraphUrlRegexString)), handleSilentMode(handleTelegraphUrlMessage, handleSilentSaveTelegraph)))
|
||||
disp.AddHandler(handlers.NewMessage(filters.Message.Media, handleSilentMode(handleMediaMessage, handleSilentSaveMedia)))
|
||||
disp.AddHandler(handlers.NewMessage(filters.Message.Text, handleSilentMode(handleTextMessage, handleSilentSaveText)))
|
||||
|
||||
if config.C().Telegram.Userbot.Enable {
|
||||
go listenMediaMessageEvent(userclient.GetMediaMessageCh())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
"github.com/duke-git/lancet/v2/slice"
|
||||
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/msgelem"
|
||||
"github.com/krau/SaveAny-Bot/database"
|
||||
"github.com/krau/SaveAny-Bot/pkg/enums/rule"
|
||||
"github.com/krau/SaveAny-Bot/pkg/rule"
|
||||
)
|
||||
|
||||
func handleRuleCmd(ctx *ext.Context, update *ext.Update) error {
|
||||
|
||||
@@ -7,14 +7,16 @@ import (
|
||||
|
||||
"github.com/celestix/gotgproto/dispatcher"
|
||||
"github.com/celestix/gotgproto/ext"
|
||||
"github.com/celestix/gotgproto/functions"
|
||||
"github.com/charmbracelet/log"
|
||||
"github.com/gotd/td/tg"
|
||||
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/mediautil"
|
||||
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/msgelem"
|
||||
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/shortcut"
|
||||
"github.com/krau/SaveAny-Bot/common/i18n"
|
||||
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
|
||||
"github.com/krau/SaveAny-Bot/common/utils/strutil"
|
||||
"github.com/krau/SaveAny-Bot/common/utils/tgutil"
|
||||
"github.com/krau/SaveAny-Bot/database"
|
||||
"github.com/krau/SaveAny-Bot/pkg/tcbdata"
|
||||
"github.com/krau/SaveAny-Bot/pkg/tfile"
|
||||
|
||||
@@ -29,21 +31,30 @@ func handleSaveCmd(ctx *ext.Context, update *ext.Update) error {
|
||||
}
|
||||
replyTo := update.EffectiveMessage.ReplyToMessage
|
||||
if replyTo == nil || replyTo.Message == nil {
|
||||
ctx.Reply(update, ext.ReplyTextString(msgelem.SaveHelpText), nil)
|
||||
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgSaveHelpText)), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
genFilename := func() string {
|
||||
if len(args) > 1 {
|
||||
return args[1]
|
||||
}
|
||||
filename := tgutil.GenFileNameFromMessage(*replyTo.Message)
|
||||
return filename
|
||||
}()
|
||||
option := tfile.WithNameIfEmpty(genFilename)
|
||||
if len(args) > 1 {
|
||||
option = tfile.WithName(genFilename)
|
||||
// genFilename := func() string {
|
||||
// if len(args) > 1 {
|
||||
// return args[1]
|
||||
// }
|
||||
// filename := tgutil.GenFileNameFromMessage(*replyTo.Message)
|
||||
// return filename
|
||||
// }()
|
||||
// option := tfile.WithNameIfEmpty(genFilename)
|
||||
// if len(args) > 1 {
|
||||
// option = tfile.WithName(genFilename)
|
||||
// }
|
||||
userDB, err := database.GetUserByChatID(ctx, update.GetUserChat().GetID())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
msg, file, err := shortcut.GetFileFromMessageWithReply(ctx, update, replyTo.Message, option)
|
||||
opts := mediautil.TfileOptions(ctx, userDB, replyTo.Message)
|
||||
if len(args) > 1 {
|
||||
// custom filename via command arg
|
||||
opts = append(opts, tfile.WithName(strings.Join(args[1:], " ")))
|
||||
}
|
||||
msg, file, err := shortcut.GetFileFromMessageWithReply(ctx, update, replyTo.Message, opts...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -73,21 +84,30 @@ func handleSilentSaveReplied(ctx *ext.Context, update *ext.Update) error {
|
||||
}
|
||||
replyTo := update.EffectiveMessage.ReplyToMessage
|
||||
if replyTo == nil || replyTo.Message == nil {
|
||||
ctx.Reply(update, ext.ReplyTextString(msgelem.SaveHelpText), nil)
|
||||
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgSaveHelpText)), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
genFilename := func() string {
|
||||
if len(args) > 1 {
|
||||
return args[1]
|
||||
}
|
||||
filename := tgutil.GenFileNameFromMessage(*replyTo.Message)
|
||||
return filename
|
||||
}()
|
||||
option := tfile.WithNameIfEmpty(genFilename)
|
||||
if len(args) > 1 {
|
||||
option = tfile.WithName(genFilename)
|
||||
// genFilename := func() string {
|
||||
// if len(args) > 1 {
|
||||
// return args[1]
|
||||
// }
|
||||
// filename := tgutil.GenFileNameFromMessage(*replyTo.Message)
|
||||
// return filename
|
||||
// }()
|
||||
// option := tfile.WithNameIfEmpty(genFilename)
|
||||
// if len(args) > 1 {
|
||||
// option = tfile.WithName(genFilename)
|
||||
// }
|
||||
userDB, err := database.GetUserByChatID(ctx, update.GetUserChat().GetID())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
msg, file, err := shortcut.GetFileFromMessageWithReply(ctx, update, replyTo.Message, option)
|
||||
opts := mediautil.TfileOptions(ctx, userDB, replyTo.Message)
|
||||
if len(args) > 1 {
|
||||
// custom filename via command arg
|
||||
opts = append(opts, tfile.WithName(strings.Join(args[1:], " ")))
|
||||
}
|
||||
msg, file, err := shortcut.GetFileFromMessageWithReply(ctx, update, replyTo.Message, opts...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -125,7 +145,7 @@ func handleBatchSave(ctx *ext.Context, update *ext.Update, args []string) error
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
|
||||
// TODO: generator istead of get all messages
|
||||
// [TODO]: generator istead of get all messages
|
||||
msgs, err := tgutil.GetMessagesRange(ctx, chatID, int(startID), int(endID))
|
||||
if err != nil {
|
||||
ctx.Reply(update, ext.ReplyTextString("获取消息失败: "+err.Error()), nil)
|
||||
@@ -158,9 +178,8 @@ func handleBatchSave(ctx *ext.Context, update *ext.Update, args []string) error
|
||||
sb.Reset()
|
||||
sb.WriteString(msg.GetMessage())
|
||||
sb.WriteString(" ")
|
||||
fn, _ := functions.GetMediaFileNameWithId(media)
|
||||
fn, _ := tgutil.GetMediaFileName(media)
|
||||
sb.WriteString(fn)
|
||||
log.FromContext(ctx).Debugf("正在检查消息内容: %s", sb.String())
|
||||
if !filter.MatchString(sb.String()) {
|
||||
continue
|
||||
}
|
||||
@@ -194,5 +213,4 @@ func handleBatchSave(ctx *ext.Context, update *ext.Update, args []string) error
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
return shortcut.CreateAndAddBatchTGFileTaskWithEdit(ctx, update.GetUserChat().GetID(), stor, "", files, replied.ID)
|
||||
|
||||
}
|
||||
|
||||
@@ -71,6 +71,6 @@ func handleSilentSaveTelegraph(ctx *ext.Context, update *ext.Update) error {
|
||||
return err
|
||||
}
|
||||
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)
|
||||
|
||||
}
|
||||
|
||||
102
client/bot/handlers/update.go
Normal file
102
client/bot/handlers/update.go
Normal file
@@ -0,0 +1,102 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/blang/semver"
|
||||
"github.com/celestix/gotgproto/dispatcher"
|
||||
"github.com/celestix/gotgproto/ext"
|
||||
"github.com/gotd/td/telegram/message/html"
|
||||
"github.com/gotd/td/tg"
|
||||
"github.com/krau/SaveAny-Bot/config"
|
||||
"github.com/rhysd/go-github-selfupdate/selfupdate"
|
||||
)
|
||||
|
||||
func handleUpdateCmd(ctx *ext.Context, u *ext.Update) error {
|
||||
currentV, err := semver.Parse(config.Version)
|
||||
if err != nil {
|
||||
ctx.Reply(u, ext.ReplyTextString(fmt.Sprintf("You are in dev or the version var failed to inject: %v", err)), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
latest, ok, err := selfupdate.DetectLatest(config.GitRepo)
|
||||
if err != nil {
|
||||
ctx.Reply(u, ext.ReplyTextString(fmt.Sprintf("检测最新版本失败: %v", err)), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
if !ok {
|
||||
ctx.Reply(u, ext.ReplyTextString("没有找到版本信息"), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
if latest.Version.LT(currentV) || latest.Version.Equals(currentV) {
|
||||
ctx.Reply(u, ext.ReplyTextString(fmt.Sprintf("当前已经是最新版本: %s", config.Version)), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
ctx.Sender.To(u.GetUserChat().AsInputPeer()).StyledText(ctx, html.String(nil, func() string {
|
||||
md := latest.ReleaseNotes
|
||||
md = regexp.MustCompile(`(?m)^###\s+ (.+)$`).ReplaceAllString(md, "<b>$1</b>")
|
||||
md = regexp.MustCompile(`(?m)^#####\s+ (.+)$`).ReplaceAllString(md, "<i>$1</i>")
|
||||
|
||||
md = regexp.MustCompile(`(?m)^- `).ReplaceAllString(md, "• ")
|
||||
|
||||
md = regexp.MustCompile(`\[\((\w{6,})\)\]\((https?://[^\s)]+)\)`).ReplaceAllString(md, `(<a href="$2">$1</a>)`)
|
||||
|
||||
md = regexp.MustCompile(`\[(.+?)\]\((https?://[^\s)]+)\)`).ReplaceAllString(md, `<a href="$2">$1</a>`)
|
||||
|
||||
md = strings.ReplaceAll(md, " ", " ")
|
||||
|
||||
return `<blockquote expandable>` + md + `</blockquote>`
|
||||
}()))
|
||||
text := fmt.Sprintf(`发现新版本: %s
|
||||
当前版本: %s
|
||||
|
||||
文件大小: %.2f MB
|
||||
下载链接: %s
|
||||
发布时间: %s
|
||||
|
||||
升级将重启 Bot , 是否升级?`, latest.Version, config.Version,
|
||||
float64(latest.AssetByteSize)/(1024*1024), latest.AssetURL,
|
||||
latest.PublishedAt.Format("2006-01-02 15:04:05"),
|
||||
)
|
||||
ctx.Reply(u, ext.ReplyTextString(text), &ext.ReplyOpts{
|
||||
Markup: &tg.ReplyInlineMarkup{
|
||||
Rows: []tg.KeyboardButtonRow{
|
||||
{
|
||||
Buttons: []tg.KeyboardButtonClass{
|
||||
&tg.KeyboardButtonCallback{
|
||||
Text: "升级",
|
||||
Data: []byte("update"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
|
||||
func handleUpdateCallback(ctx *ext.Context, u *ext.Update) error {
|
||||
currentV, err := semver.Parse(config.Version)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ctx.EditMessage(u.GetUserChat().GetID(), &tg.MessagesEditMessageRequest{
|
||||
ID: u.CallbackQuery.GetMsgID(),
|
||||
Message: fmt.Sprintf("正在升级中, 当前版本: %s", config.Version),
|
||||
})
|
||||
latest, err := selfupdate.UpdateSelf(currentV, config.GitRepo)
|
||||
if err != nil {
|
||||
ctx.EditMessage(u.GetUserChat().GetID(), &tg.MessagesEditMessageRequest{
|
||||
ID: u.CallbackQuery.GetMsgID(),
|
||||
Message: fmt.Sprintf("升级失败: %v", err),
|
||||
})
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
ctx.EditMessage(u.GetUserChat().GetID(), &tg.MessagesEditMessageRequest{
|
||||
ID: u.CallbackQuery.GetMsgID(),
|
||||
Message: fmt.Sprintf("已升级至版本 %s\n若 Bot 未自动重启请手动启动", latest.Version),
|
||||
})
|
||||
return errors.New("SAVEANTBOT-RESTART")
|
||||
}
|
||||
26
client/bot/handlers/utils/filters/url.go
Normal file
26
client/bot/handlers/utils/filters/url.go
Normal file
@@ -0,0 +1,26 @@
|
||||
package filters
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"slices"
|
||||
|
||||
"github.com/celestix/gotgproto/dispatcher/handlers/filters"
|
||||
"github.com/celestix/gotgproto/types"
|
||||
"github.com/krau/SaveAny-Bot/common/utils/tgutil"
|
||||
)
|
||||
|
||||
func RegexUrl(r *regexp.Regexp) filters.MessageFilter {
|
||||
return func(m *types.Message) bool {
|
||||
if m.Text == "" {
|
||||
return false
|
||||
}
|
||||
if r.MatchString(m.Text) {
|
||||
return true
|
||||
}
|
||||
urls := tgutil.ExtractMessageEntityUrls(m.Message)
|
||||
if len(urls) == 0 {
|
||||
return false
|
||||
}
|
||||
return slices.ContainsFunc(urls, r.MatchString)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,20 @@
|
||||
package mediautil
|
||||
|
||||
import "github.com/gotd/td/tg"
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"text/template"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/log"
|
||||
"github.com/gotd/td/tg"
|
||||
"github.com/krau/SaveAny-Bot/common/utils/strutil"
|
||||
"github.com/krau/SaveAny-Bot/common/utils/tgutil"
|
||||
"github.com/krau/SaveAny-Bot/database"
|
||||
"github.com/krau/SaveAny-Bot/pkg/enums/fnamest"
|
||||
"github.com/krau/SaveAny-Bot/pkg/tfile"
|
||||
)
|
||||
|
||||
func IsSupported(media tg.MessageMediaClass) bool {
|
||||
switch media.(type) {
|
||||
@@ -10,3 +24,118 @@ func IsSupported(media tg.MessageMediaClass) bool {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
type FilenameTemplateData struct {
|
||||
MsgID string `json:"msgid,omitempty"`
|
||||
MsgTags string `json:"msgtags,omitempty"`
|
||||
MsgGen string `json:"msggen,omitempty"`
|
||||
MsgDate string `json:"msgdate,omitempty"`
|
||||
OrigName string `json:"origname,omitempty"`
|
||||
ChatID string `json:"chatid,omitempty"`
|
||||
}
|
||||
|
||||
func (f FilenameTemplateData) ToMap() map[string]string {
|
||||
return map[string]string{
|
||||
"msgid": f.MsgID,
|
||||
"msgtags": f.MsgTags,
|
||||
"msggen": f.MsgGen,
|
||||
"msgdate": f.MsgDate,
|
||||
"origname": f.OrigName,
|
||||
"chatid": f.ChatID,
|
||||
}
|
||||
}
|
||||
|
||||
func TfileOptions(ctx context.Context, user *database.User, message *tg.Message) []tfile.TGFileOption {
|
||||
opts := make([]tfile.TGFileOption, 0)
|
||||
var fnameOpt tfile.TGFileOption
|
||||
switch user.FilenameStrategy {
|
||||
case fnamest.Message.String():
|
||||
fnameOpt = tfile.WithName(tgutil.GenFileNameFromMessage(*message))
|
||||
case fnamest.Template.String():
|
||||
if user.FilenameTemplate == "" {
|
||||
log.FromContext(ctx).Warnf("empty filename template")
|
||||
fnameOpt = tfile.WithNameIfEmpty(tgutil.GenFileNameFromMessage(*message))
|
||||
break
|
||||
}
|
||||
tmpl, err := template.New("filename").Parse(user.FilenameTemplate)
|
||||
if err != nil {
|
||||
log.FromContext(ctx).Errorf("failed to parse filename template: %s", err)
|
||||
fnameOpt = tfile.WithNameIfEmpty(tgutil.GenFileNameFromMessage(*message))
|
||||
break
|
||||
}
|
||||
data := BuildFilenameTemplateData(message)
|
||||
var sb strings.Builder
|
||||
err = tmpl.Execute(&sb, data)
|
||||
if err != nil {
|
||||
log.FromContext(ctx).Errorf("failed to execute filename template: %s", err)
|
||||
fnameOpt = tfile.WithNameIfEmpty(tgutil.GenFileNameFromMessage(*message))
|
||||
break
|
||||
}
|
||||
fnameOpt = tfile.WithName(sb.String())
|
||||
default:
|
||||
fnameOpt = tfile.WithNameIfEmpty(tgutil.GenFileNameFromMessage(*message))
|
||||
}
|
||||
opts = append(opts, fnameOpt, tfile.WithMessage(message))
|
||||
return opts
|
||||
}
|
||||
|
||||
func BuildFilenameTemplateData(message *tg.Message) map[string]string {
|
||||
data := FilenameTemplateData{
|
||||
MsgID: func() string {
|
||||
id := message.GetID()
|
||||
if id == 0 {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("%d", id)
|
||||
}(),
|
||||
MsgTags: func() string {
|
||||
tags := strutil.ExtractTagsFromText(message.GetMessage())
|
||||
if len(tags) == 0 {
|
||||
return ""
|
||||
}
|
||||
return strings.Join(tags, "_")
|
||||
}(),
|
||||
MsgGen: tgutil.GenFileNameFromMessage(*message),
|
||||
OrigName: func() string {
|
||||
f, _ := tgutil.GetMediaFileName(message.Media)
|
||||
return f
|
||||
}(),
|
||||
MsgDate: func() string {
|
||||
date := message.GetDate()
|
||||
if date == 0 {
|
||||
return ""
|
||||
}
|
||||
t := time.Unix(int64(date), 0)
|
||||
return t.Format("2006-01-02_15-04-05")
|
||||
}(),
|
||||
ChatID: func() string {
|
||||
// 如果消息是频道的(从消息链接中fetch的) 直接使用其chat id, 无论它是否是从其他来源转发的
|
||||
if message.GetPost() {
|
||||
peer := message.GetPeerID()
|
||||
switch p := peer.(type) {
|
||||
case *tg.PeerChannel:
|
||||
return intToStringOmitZero(p.ChannelID)
|
||||
default: // impossible case
|
||||
return intToStringOmitZero(tgutil.ChatIdFromPeer(peer))
|
||||
}
|
||||
}
|
||||
fwdHeader, ok := message.GetFwdFrom()
|
||||
if !ok {
|
||||
return intToStringOmitZero(tgutil.ChatIdFromPeer(message.GetPeerID()))
|
||||
}
|
||||
fwdFrom, ok := fwdHeader.GetFromID()
|
||||
if !ok {
|
||||
return intToStringOmitZero(tgutil.ChatIdFromPeer(message.GetPeerID()))
|
||||
}
|
||||
return intToStringOmitZero(tgutil.ChatIdFromPeer(fwdFrom))
|
||||
}(),
|
||||
}.ToMap()
|
||||
return data
|
||||
}
|
||||
|
||||
func intToStringOmitZero(i int64) string {
|
||||
if i == 0 {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("%d", i)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
package msgelem
|
||||
|
||||
const (
|
||||
SaveHelpText = `
|
||||
使用方法:
|
||||
|
||||
1. 使用该命令回复要保存的文件, 可选文件名参数.
|
||||
示例:
|
||||
/save custom_file_name.mp4
|
||||
|
||||
2. 设置默认存储后, 发送 /save <频道ID/用户名> <消息ID范围> 来批量保存文件. 遵从存储规则, 若未匹配到任何规则则使用默认存储.
|
||||
示例:
|
||||
/save @moreacg 114-514
|
||||
`
|
||||
)
|
||||
@@ -24,6 +24,8 @@ func BuildAddSelectStorageKeyboard(stors []storage.Storage, adddata tcbdata.Add)
|
||||
taskType = tasktype.TaskTypeTgfiles
|
||||
} else if adddata.TphPageNode != nil {
|
||||
taskType = tasktype.TaskTypeTphpics
|
||||
} else if adddata.ParsedItem != nil {
|
||||
taskType = tasktype.TaskTypeParseditem
|
||||
} else {
|
||||
return nil, fmt.Errorf("unknown task type: %s", taskType)
|
||||
}
|
||||
@@ -41,6 +43,8 @@ func BuildAddSelectStorageKeyboard(stors []storage.Storage, adddata tcbdata.Add)
|
||||
TphPageNode: adddata.TphPageNode,
|
||||
TphPics: adddata.TphPics,
|
||||
TphDirPath: adddata.TphDirPath,
|
||||
|
||||
ParsedItem: adddata.ParsedItem,
|
||||
}
|
||||
dataid := xid.New().String()
|
||||
err := cache.Set(dataid, data)
|
||||
|
||||
@@ -3,10 +3,10 @@ package ruleutil
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/duke-git/lancet/v2/convertor"
|
||||
|
||||
"github.com/charmbracelet/log"
|
||||
"github.com/krau/SaveAny-Bot/database"
|
||||
"github.com/krau/SaveAny-Bot/pkg/consts"
|
||||
ruleenum "github.com/krau/SaveAny-Bot/pkg/enums/rule"
|
||||
"github.com/krau/SaveAny-Bot/pkg/rule"
|
||||
"github.com/krau/SaveAny-Bot/pkg/tfile"
|
||||
)
|
||||
@@ -33,18 +33,29 @@ func (m matchedStorName) String() string {
|
||||
return string(m)
|
||||
}
|
||||
|
||||
func (m matchedStorName) IsValid() bool {
|
||||
return m != "" && m != consts.RuleStorNameChosen
|
||||
// can we use this storage name directly?
|
||||
func (m matchedStorName) IsUsable() bool {
|
||||
return m != "" && m != rule.RuleStorNameChosen
|
||||
}
|
||||
|
||||
func ApplyRule(ctx context.Context, rules []database.Rule, inputs *ruleInput) (matchedStorageName matchedStorName, dirPath string) {
|
||||
type MatchedDirPath string
|
||||
|
||||
func (m MatchedDirPath) String() string {
|
||||
return string(m)
|
||||
}
|
||||
|
||||
func (m MatchedDirPath) NeedNewForAlbum() bool {
|
||||
return m != "" && m == rule.RuleDirPathNewForAlbum
|
||||
}
|
||||
|
||||
func ApplyRule(ctx context.Context, rules []database.Rule, inputs *ruleInput) (matched bool, matchedStorageName matchedStorName, dirPath MatchedDirPath) {
|
||||
if inputs == nil || len(rules) == 0 {
|
||||
return "", ""
|
||||
return false, "", ""
|
||||
}
|
||||
logger := log.FromContext(ctx)
|
||||
for _, ur := range rules {
|
||||
switch ur.Type {
|
||||
case ruleenum.FileNameRegex.String():
|
||||
case rule.FileNameRegex.String():
|
||||
ru, err := rule.NewRuleFileNameRegex(ur.StorageName, ur.DirPath, ur.Data)
|
||||
if err != nil {
|
||||
logger.Errorf("Failed to create rule: %s", err)
|
||||
@@ -56,10 +67,10 @@ func ApplyRule(ctx context.Context, rules []database.Rule, inputs *ruleInput) (m
|
||||
continue
|
||||
}
|
||||
if ok {
|
||||
dirPath = ru.StoragePath()
|
||||
dirPath = MatchedDirPath(ru.StoragePath())
|
||||
matchedStorageName = matchedStorName(ru.StorageName())
|
||||
}
|
||||
case ruleenum.MessageRegex.String():
|
||||
case rule.MessageRegex.String():
|
||||
ru, err := rule.NewRuleMessageRegex(ur.StorageName, ur.DirPath, ur.Data)
|
||||
if err != nil {
|
||||
logger.Errorf("Failed to create rule: %s", err)
|
||||
@@ -71,10 +82,32 @@ func ApplyRule(ctx context.Context, rules []database.Rule, inputs *ruleInput) (m
|
||||
continue
|
||||
}
|
||||
if ok {
|
||||
dirPath = ru.StoragePath()
|
||||
dirPath = MatchedDirPath(ru.StoragePath())
|
||||
matchedStorageName = matchedStorName(ru.StorageName())
|
||||
}
|
||||
case rule.IsAlbum.String():
|
||||
matchAlbum, err := convertor.ToBool(ur.Data)
|
||||
if err != nil {
|
||||
matchAlbum = false
|
||||
}
|
||||
ru, err := rule.NewRuleMediaType(ur.StorageName, ur.DirPath, matchAlbum)
|
||||
if err != nil {
|
||||
logger.Errorf("Failed to create rule: %s", err)
|
||||
continue
|
||||
}
|
||||
ok, err := ru.Match(inputs.File.Message().GroupedID != 0)
|
||||
if err != nil {
|
||||
logger.Errorf("Failed to match rule: %s", err)
|
||||
continue
|
||||
}
|
||||
if ok {
|
||||
dirPath = MatchedDirPath(ru.StoragePath())
|
||||
matchedStorageName = matchedStorName(ru.StorageName())
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
if matchedStorageName != "" || dirPath != "" {
|
||||
return true, matchedStorageName, dirPath
|
||||
}
|
||||
return false, "", ""
|
||||
}
|
||||
|
||||
@@ -10,29 +10,30 @@ import (
|
||||
"github.com/celestix/gotgproto/ext"
|
||||
"github.com/celestix/gotgproto/types"
|
||||
"github.com/charmbracelet/log"
|
||||
"github.com/gotd/td/telegram/downloader"
|
||||
"github.com/gotd/td/tg"
|
||||
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/mediautil"
|
||||
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/msgelem"
|
||||
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/re"
|
||||
"github.com/krau/SaveAny-Bot/client/user"
|
||||
uc "github.com/krau/SaveAny-Bot/client/user"
|
||||
"github.com/krau/SaveAny-Bot/common/cache"
|
||||
"github.com/krau/SaveAny-Bot/common/utils/tgutil"
|
||||
"github.com/krau/SaveAny-Bot/common/utils/tphutil"
|
||||
"github.com/krau/SaveAny-Bot/config"
|
||||
"github.com/krau/SaveAny-Bot/database"
|
||||
"github.com/krau/SaveAny-Bot/pkg/telegraph"
|
||||
"github.com/krau/SaveAny-Bot/pkg/tfile"
|
||||
)
|
||||
|
||||
// 获取消息中的文件并回复等待消息, 返回等待消息, 获取到的文件
|
||||
func GetFileFromMessageWithReply(ctx *ext.Context, update *ext.Update, message *tg.Message, tfileopts ...tfile.TGFileOptions) (replied *types.Message,
|
||||
func GetFileFromMessageWithReply(ctx *ext.Context, update *ext.Update, message *tg.Message, tfileopts ...tfile.TGFileOption) (replied *types.Message,
|
||||
file tfile.TGFileMessage, err error,
|
||||
) {
|
||||
logger := log.FromContext(ctx)
|
||||
media := message.Media
|
||||
supported := mediautil.IsSupported(media)
|
||||
if !supported {
|
||||
ctx.Reply(update, ext.ReplyTextString("不支持的消息类型"), nil)
|
||||
return nil, nil, dispatcher.EndGroups
|
||||
return nil, nil, dispatcher.ContinueGroups
|
||||
}
|
||||
|
||||
replied, err = ctx.Reply(update, ext.ReplyTextString("正在获取文件信息..."), nil)
|
||||
@@ -40,15 +41,15 @@ func GetFileFromMessageWithReply(ctx *ext.Context, update *ext.Update, message *
|
||||
logger.Errorf("Failed to reply: %s", err)
|
||||
return nil, nil, dispatcher.EndGroups
|
||||
}
|
||||
options := []tfile.TGFileOptions{
|
||||
tfile.WithMessage(message),
|
||||
}
|
||||
if len(tfileopts) > 0 {
|
||||
options = append(options, tfileopts...)
|
||||
} else {
|
||||
options = append(options, tfile.WithNameIfEmpty(tgutil.GenFileNameFromMessage(*message)))
|
||||
}
|
||||
file, err = tfile.FromMediaMessage(media, ctx.Raw, message, options...)
|
||||
// options := []tfile.TGFileOption{
|
||||
// tfile.WithMessage(message),
|
||||
// }
|
||||
// if len(tfileopts) > 0 {
|
||||
// options = append(options, tfileopts...)
|
||||
// } else {
|
||||
// options = append(options, tfile.WithNameIfEmpty(tgutil.GenFileNameFromMessage(*message)))
|
||||
// }
|
||||
file, err = tfile.FromMediaMessage(media, ctx.Raw, message, tfileopts...)
|
||||
if err != nil {
|
||||
logger.Errorf("Failed to get file from media: %s", err)
|
||||
ctx.Reply(update, ext.ReplyTextString("获取文件失败: "+err.Error()), nil)
|
||||
@@ -62,7 +63,7 @@ type EditMessageFunc func(text string, markup tg.ReplyMarkupClass)
|
||||
// 获取链接中的文件并回复等待消息
|
||||
func GetFilesFromUpdateLinkMessageWithReplyEdit(ctx *ext.Context, update *ext.Update) (replied *types.Message, files []tfile.TGFileMessage, editReplied EditMessageFunc, err error) {
|
||||
logger := log.FromContext(ctx)
|
||||
msgLinks := re.TgMessageLinkRegexp.FindAllString(update.EffectiveMessage.GetMessage(), -1)
|
||||
msgLinks := re.TgMessageLinkRegexp.FindAllString(tgutil.ExtractMessageEntityUrlsText(update.EffectiveMessage.Message), -1)
|
||||
if len(msgLinks) == 0 {
|
||||
logger.Warn("no matched message links but called handleMessageLink")
|
||||
return nil, nil, nil, dispatcher.EndGroups
|
||||
@@ -81,9 +82,14 @@ func GetFilesFromUpdateLinkMessageWithReplyEdit(ctx *ext.Context, update *ext.Up
|
||||
logger.Errorf("failed to edit message: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
user, err := database.GetUserByChatID(ctx, update.GetUserChat().GetID())
|
||||
if err != nil {
|
||||
logger.Errorf("failed to get user from db: %s", err)
|
||||
editReplied("获取用户信息失败: "+err.Error(), nil)
|
||||
return nil, nil, nil, dispatcher.EndGroups
|
||||
}
|
||||
files = make([]tfile.TGFileMessage, 0, len(msgLinks))
|
||||
addFile := func(client tfile.DlerClient, msg *tg.Message) {
|
||||
addFile := func(client downloader.Client, msg *tg.Message) {
|
||||
if msg == nil || msg.Media == nil {
|
||||
logger.Warn("message is nil, skipping")
|
||||
return
|
||||
@@ -93,7 +99,15 @@ func GetFilesFromUpdateLinkMessageWithReplyEdit(ctx *ext.Context, update *ext.Up
|
||||
logger.Debugf("message %d has no media", msg.GetID())
|
||||
return
|
||||
}
|
||||
file, err := tfile.FromMediaMessage(media, client, msg, tfile.WithNameIfEmpty(tgutil.GenFileNameFromMessage(*msg)))
|
||||
// var opt tfile.TGFileOption
|
||||
// switch user.FilenameStrategy {
|
||||
// case fnamest.Message.String():
|
||||
// opt = tfile.WithName(tgutil.GenFileNameFromMessage(*msg))
|
||||
// default:
|
||||
// opt = tfile.WithNameIfEmpty(tgutil.GenFileNameFromMessage(*msg))
|
||||
// }
|
||||
opts := mediautil.TfileOptions(ctx, user, msg)
|
||||
file, err := tfile.FromMediaMessage(media, client, msg, opts...)
|
||||
if err != nil {
|
||||
logger.Errorf("failed to create file from media: %s", err)
|
||||
return
|
||||
@@ -102,8 +116,8 @@ func GetFilesFromUpdateLinkMessageWithReplyEdit(ctx *ext.Context, update *ext.Up
|
||||
}
|
||||
|
||||
tctx := ctx
|
||||
if config.Cfg.Telegram.Userbot.Enable {
|
||||
tctx = user.GetCtx()
|
||||
if config.C().Telegram.Userbot.Enable {
|
||||
tctx = uc.GetCtx()
|
||||
}
|
||||
|
||||
for _, link := range msgLinks {
|
||||
@@ -164,7 +178,7 @@ type TelegraphResult struct {
|
||||
// return replied message, image urls, telegraph path(unescaped), error
|
||||
func GetTphPicsFromMessageWithReply(ctx *ext.Context, update *ext.Update) (*types.Message, *TelegraphResult, error) {
|
||||
logger := log.FromContext(ctx)
|
||||
tphurl := re.TelegraphUrlRegexp.FindString(update.EffectiveMessage.GetMessage()) // TODO: batch urls
|
||||
tphurl := re.TelegraphUrlRegexp.FindString(tgutil.ExtractMessageEntityUrlsText(update.EffectiveMessage.Message))
|
||||
if tphurl == "" {
|
||||
logger.Warnf("No telegraph url found but called handleTelegraph")
|
||||
return nil, nil, dispatcher.ContinueGroups
|
||||
@@ -176,11 +190,13 @@ func GetTphPicsFromMessageWithReply(ctx *ext.Context, update *ext.Update) (*type
|
||||
ctx.Reply(update, ext.ReplyTextString("解析 telegraph 路径失败: "+err.Error()), nil)
|
||||
return nil, nil, dispatcher.EndGroups
|
||||
}
|
||||
tphdir = strings.TrimSpace(tphdir)
|
||||
msg, err := ctx.Reply(update, ext.ReplyTextString("正在获取 telegraph 页面..."), nil)
|
||||
if err != nil {
|
||||
logger.Errorf("Failed to reply to update: %s", err)
|
||||
return nil, nil, dispatcher.EndGroups
|
||||
}
|
||||
logger.Debugf("Fetching telegraph page: %s", pagepath)
|
||||
page, err := tphutil.DefaultClient().GetPage(ctx, pagepath)
|
||||
if err != nil {
|
||||
logger.Errorf("Failed to get telegraph page: %s", err)
|
||||
@@ -208,6 +224,10 @@ func GetTphPicsFromMessageWithReply(ctx *ext.Context, update *ext.Update) (*type
|
||||
}
|
||||
if node.Tag == "img" {
|
||||
if src, ok := node.Attrs["src"]; ok {
|
||||
if strings.HasPrefix(src, "/file/") {
|
||||
// handle images on telegra.ph server
|
||||
src = "https://telegra.ph" + src
|
||||
}
|
||||
imgs = append(imgs, src)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package shortcut
|
||||
import (
|
||||
"fmt"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"github.com/celestix/gotgproto/dispatcher"
|
||||
"github.com/celestix/gotgproto/ext"
|
||||
@@ -12,15 +13,15 @@ import (
|
||||
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/ruleutil"
|
||||
"github.com/krau/SaveAny-Bot/common/utils/tgutil"
|
||||
"github.com/krau/SaveAny-Bot/core"
|
||||
"github.com/krau/SaveAny-Bot/core/batchtftask"
|
||||
"github.com/krau/SaveAny-Bot/core/tftask"
|
||||
"github.com/krau/SaveAny-Bot/core/tasks/batchtfile"
|
||||
tftask "github.com/krau/SaveAny-Bot/core/tasks/tfile"
|
||||
"github.com/krau/SaveAny-Bot/database"
|
||||
"github.com/krau/SaveAny-Bot/pkg/tfile"
|
||||
"github.com/krau/SaveAny-Bot/storage"
|
||||
"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 {
|
||||
logger := log.FromContext(ctx)
|
||||
user, err := database.GetUserByChatID(ctx, userID)
|
||||
@@ -33,9 +34,14 @@ func CreateAndAddTGFileTaskWithEdit(ctx *ext.Context, userID int64, stor storage
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
if user.ApplyRule && user.Rules != nil {
|
||||
matchedStorageName, matchedDirPath := ruleutil.ApplyRule(ctx, user.Rules, ruleutil.NewInput(file))
|
||||
dirPath = matchedDirPath
|
||||
if matchedStorageName.IsValid() {
|
||||
matched, matchedStorageName, matchedDirPath := ruleutil.ApplyRule(ctx, user.Rules, ruleutil.NewInput(file))
|
||||
if !matched {
|
||||
goto startCreateTask
|
||||
}
|
||||
if matchedDirPath != "" {
|
||||
dirPath = matchedDirPath.String()
|
||||
}
|
||||
if matchedStorageName.IsUsable() {
|
||||
stor, err = storage.GetStorageByUserIDAndName(ctx, user.ChatID, matchedStorageName.String())
|
||||
if err != nil {
|
||||
logger.Errorf("Failed to get storage by user ID and name: %s", err)
|
||||
@@ -47,7 +53,7 @@ func CreateAndAddTGFileTaskWithEdit(ctx *ext.Context, userID int64, stor storage
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
startCreateTask:
|
||||
storagePath := stor.JoinStoragePath(path.Join(dirPath, file.Name()))
|
||||
injectCtx := tgutil.ExtWithContext(ctx.Context, ctx)
|
||||
taskid := xid.New().String()
|
||||
@@ -81,7 +87,7 @@ func CreateAndAddTGFileTaskWithEdit(ctx *ext.Context, userID int64, stor storage
|
||||
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 {
|
||||
logger := log.FromContext(ctx)
|
||||
user, err := database.GetUserByChatID(ctx, userID)
|
||||
@@ -93,19 +99,30 @@ func CreateAndAddBatchTGFileTaskWithEdit(ctx *ext.Context, userID int64, stor st
|
||||
})
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
|
||||
useRule := user.ApplyRule && user.Rules != nil
|
||||
applyRule := func(file tfile.TGFileMessage) (string, string) {
|
||||
|
||||
applyRule := func(file tfile.TGFileMessage) (string, ruleutil.MatchedDirPath) {
|
||||
if !useRule {
|
||||
return stor.Name(), dirPath
|
||||
return stor.Name(), ruleutil.MatchedDirPath(dirPath)
|
||||
}
|
||||
storName, dirP := ruleutil.ApplyRule(ctx, user.Rules, ruleutil.NewInput(file))
|
||||
if !storName.IsValid() {
|
||||
return stor.Name(), dirP
|
||||
matched, storName, dirP := ruleutil.ApplyRule(ctx, user.Rules, ruleutil.NewInput(file))
|
||||
if !matched {
|
||||
return stor.Name(), ruleutil.MatchedDirPath(dirPath)
|
||||
}
|
||||
return storName.String(), dirP
|
||||
storname := storName.String()
|
||||
if !storName.IsUsable() {
|
||||
storname = stor.Name()
|
||||
}
|
||||
return storname, dirP
|
||||
}
|
||||
|
||||
elems := make([]batchtftask.TaskElement, 0, len(files))
|
||||
elems := make([]batchtfile.TaskElement, 0, len(files))
|
||||
type albumFile struct {
|
||||
file tfile.TGFileMessage
|
||||
storage storage.Storage
|
||||
}
|
||||
albumFiles := make(map[int64][]albumFile, 0)
|
||||
for _, file := range files {
|
||||
storName, dirPath := applyRule(file)
|
||||
fileStor := stor
|
||||
@@ -120,21 +137,59 @@ func CreateAndAddBatchTGFileTaskWithEdit(ctx *ext.Context, userID int64, stor st
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
}
|
||||
storPath := fileStor.JoinStoragePath(path.Join(dirPath, file.Name()))
|
||||
elem, err := batchtftask.NewTaskElement(fileStor, storPath, file)
|
||||
if err != nil {
|
||||
logger.Errorf("Failed to create task element: %s", err)
|
||||
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
|
||||
ID: trackMsgID,
|
||||
Message: "任务创建失败: " + err.Error(),
|
||||
if !dirPath.NeedNewForAlbum() {
|
||||
storPath := fileStor.JoinStoragePath(path.Join(dirPath.String(), file.Name()))
|
||||
elem, err := batchtfile.NewTaskElement(fileStor, storPath, file)
|
||||
if err != nil {
|
||||
logger.Errorf("Failed to create task element: %s", err)
|
||||
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
|
||||
ID: trackMsgID,
|
||||
Message: "任务创建失败: " + err.Error(),
|
||||
})
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
elems = append(elems, *elem)
|
||||
} else {
|
||||
groupId, isGroup := file.Message().GetGroupedID()
|
||||
if !isGroup || groupId == 0 {
|
||||
logger.Warnf("File %s is not in a group, skipping album handling", file.Name())
|
||||
continue
|
||||
}
|
||||
if _, ok := albumFiles[groupId]; !ok {
|
||||
albumFiles[groupId] = make([]albumFile, 0)
|
||||
}
|
||||
albumFiles[groupId] = append(albumFiles[groupId], albumFile{
|
||||
file: file,
|
||||
storage: fileStor,
|
||||
})
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
elems = append(elems, *elem)
|
||||
}
|
||||
for _, afiles := range albumFiles {
|
||||
if len(afiles) <= 1 {
|
||||
continue
|
||||
}
|
||||
// 对于需要新建目录的文件, 将第一个文件的文件名(去除扩展名)作为目录名
|
||||
// 存储以第一个文件的存储为准
|
||||
albumDir := strings.TrimSuffix(path.Base(afiles[0].file.Name()), path.Ext(afiles[0].file.Name()))
|
||||
albumStor := afiles[0].storage
|
||||
for _, af := range afiles {
|
||||
afstorPath := af.storage.JoinStoragePath(path.Join(dirPath, albumDir, af.file.Name()))
|
||||
elem, err := batchtfile.NewTaskElement(albumStor, afstorPath, af.file)
|
||||
if err != nil {
|
||||
logger.Errorf("Failed to create task element for album file: %s", err)
|
||||
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
|
||||
ID: trackMsgID,
|
||||
Message: "任务创建失败: " + err.Error(),
|
||||
})
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
elems = append(elems, *elem)
|
||||
}
|
||||
}
|
||||
|
||||
injectCtx := tgutil.ExtWithContext(ctx.Context, ctx)
|
||||
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 {
|
||||
logger.Errorf("Failed to add batch task: %s", err)
|
||||
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/tphutil"
|
||||
"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/storage"
|
||||
"github.com/rs/xid"
|
||||
)
|
||||
|
||||
func CreateAndAddTphTaskWithEdit(ctx *ext.Context,
|
||||
func CreateAndAddtelegraphWithEdit(
|
||||
ctx *ext.Context,
|
||||
userID int64,
|
||||
tphpage *telegraph.Page,
|
||||
dirPath string, // unescaped ph path for file storage
|
||||
pics []string,
|
||||
stor storage.Storage,
|
||||
trackMsgID int) error {
|
||||
|
||||
injectCtx := tgutil.ExtWithContext(ctx.Context, ctx)
|
||||
task := tphtask.NewTask(xid.New().String(),
|
||||
injectCtx,
|
||||
|
||||
255
client/bot/handlers/watch.go
Normal file
255
client/bot/handlers/watch.go
Normal file
@@ -0,0 +1,255 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path"
|
||||
"regexp"
|
||||
"strings"
|
||||
"text/template"
|
||||
|
||||
"github.com/celestix/gotgproto/dispatcher"
|
||||
"github.com/celestix/gotgproto/ext"
|
||||
"github.com/charmbracelet/log"
|
||||
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/mediautil"
|
||||
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/ruleutil"
|
||||
userclient "github.com/krau/SaveAny-Bot/client/user"
|
||||
"github.com/krau/SaveAny-Bot/common/i18n"
|
||||
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
|
||||
"github.com/krau/SaveAny-Bot/common/utils/tgutil"
|
||||
"github.com/krau/SaveAny-Bot/core"
|
||||
"github.com/krau/SaveAny-Bot/core/tasks/tfile"
|
||||
"github.com/krau/SaveAny-Bot/database"
|
||||
"github.com/krau/SaveAny-Bot/pkg/enums/fnamest"
|
||||
"github.com/krau/SaveAny-Bot/storage"
|
||||
"github.com/rs/xid"
|
||||
)
|
||||
|
||||
func handleWatchCmd(ctx *ext.Context, update *ext.Update) error {
|
||||
logger := log.FromContext(ctx)
|
||||
args := strings.Split(update.EffectiveMessage.Text, " ")
|
||||
if len(args) < 2 {
|
||||
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgWatchHelpText)), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
userChatID := update.GetUserChat().GetID()
|
||||
user, err := database.GetUserByChatID(ctx, userChatID)
|
||||
if err != nil {
|
||||
logger.Errorf("获取用户失败: %s", err)
|
||||
ctx.Reply(update, ext.ReplyTextString("获取用户失败"), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
if user.DefaultStorage == "" {
|
||||
ctx.Reply(update, ext.ReplyTextString("请先设置默认存储, 使用 /storage 命令"), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
chatArg := args[1]
|
||||
chatID, err := tgutil.ParseChatID(ctx, chatArg)
|
||||
if err != nil {
|
||||
ctx.Reply(update, ext.ReplyTextString("无效的ID或用户名: "+err.Error()), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
watching, err := user.WatchingChat(ctx, chatID)
|
||||
if err != nil {
|
||||
logger.Errorf("Failed to check if user is watching chat %d: %s", chatID, err)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
if watching {
|
||||
ctx.Reply(update, ext.ReplyTextString("已经在监听此聊天"), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
filter := ""
|
||||
if len(args) > 2 {
|
||||
filterArg := strings.Join(args[2:], " ")
|
||||
filterType := strings.Split(filterArg, ":")[0]
|
||||
filterData := strings.Split(filterArg, ":")[1]
|
||||
if filterType == "" || filterData == "" {
|
||||
ctx.Reply(update, ext.ReplyTextString("过滤器格式错误, 请使用 <过滤器类型>:<表达式>"), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
switch filterType {
|
||||
case "msgre":
|
||||
_, err := regexp.Compile(filterData)
|
||||
if err != nil {
|
||||
ctx.Reply(update, ext.ReplyTextString("正则表达式格式错误: "+err.Error()), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
filter = filterType + ":" + filterData
|
||||
default:
|
||||
ctx.Reply(update, ext.ReplyTextString("不支持的过滤器类型, 请参阅文档"), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
}
|
||||
if err := user.WatchChat(ctx, database.WatchChat{
|
||||
UserID: user.ID,
|
||||
ChatID: chatID,
|
||||
Filter: filter,
|
||||
}); err != nil {
|
||||
logger.Errorf("Failed to watch chat %d: %s", chatID, err)
|
||||
ctx.Reply(update, ext.ReplyTextString("监听聊天失败: "+err.Error()), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
ctx.Reply(update, ext.ReplyTextString("已开始监听聊天: "+chatArg), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
|
||||
func handleLswatchCmd(ctx *ext.Context, update *ext.Update) error {
|
||||
logger := log.FromContext(ctx)
|
||||
userChatID := update.GetUserChat().GetID()
|
||||
user, err := database.GetUserByChatID(ctx, userChatID)
|
||||
if err != nil {
|
||||
logger.Errorf("获取用户失败: %s", err)
|
||||
ctx.Reply(update, ext.ReplyTextString("获取用户失败"), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
chats := user.WatchChats
|
||||
if len(chats) == 0 {
|
||||
ctx.Reply(update, ext.ReplyTextString("当前没有监听任何聊天"), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
var sb strings.Builder
|
||||
sb.WriteString("当前监听的聊天:\n")
|
||||
for _, chat := range chats {
|
||||
sb.WriteString("- ")
|
||||
sb.WriteString(fmt.Sprintf("%d", chat.ChatID))
|
||||
if chat.Filter != "" {
|
||||
sb.WriteString(" (过滤器: ")
|
||||
sb.WriteString(chat.Filter)
|
||||
sb.WriteString(")")
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
ctx.Reply(update, ext.ReplyTextString(sb.String()), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
|
||||
func handleUnwatchCmd(ctx *ext.Context, update *ext.Update) error {
|
||||
logger := log.FromContext(ctx)
|
||||
args := strings.Split(update.EffectiveMessage.Text, " ")
|
||||
if len(args) < 2 {
|
||||
ctx.Reply(update, ext.ReplyTextString("请提供要取消监听的聊天ID或用户名"), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
userChatID := update.GetUserChat().GetID()
|
||||
user, err := database.GetUserByChatID(ctx, userChatID)
|
||||
if err != nil {
|
||||
logger.Errorf("获取用户失败: %s", err)
|
||||
ctx.Reply(update, ext.ReplyTextString("获取用户失败"), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
chatArg := args[1]
|
||||
chatID, err := tgutil.ParseChatID(ctx, chatArg)
|
||||
if err != nil {
|
||||
ctx.Reply(update, ext.ReplyTextString("无效的ID或用户名: "+err.Error()), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
if err := user.UnwatchChat(ctx, chatID); err != nil {
|
||||
logger.Errorf("Failed to unwatch chat %d: %s", chatID, err)
|
||||
ctx.Reply(update, ext.ReplyTextString("取消监听聊天失败: "+err.Error()), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
ctx.Reply(update, ext.ReplyTextString("已取消监听聊天: "+chatArg), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
|
||||
func listenMediaMessageEvent(ch chan userclient.MediaMessageEvent) {
|
||||
logger := log.FromContext(userclient.GetCtx())
|
||||
for event := range ch {
|
||||
logger.Debug("Received media message event", "chat_id", event.ChatID, "file_name", event.File.Name())
|
||||
ctx := event.Ctx
|
||||
file := event.File
|
||||
chats, err := database.GetWatchChatsByChatID(ctx, event.ChatID)
|
||||
if err != nil {
|
||||
logger.Errorf("Failed to get watch chats for chat ID %d: %v", event.ChatID, err)
|
||||
continue
|
||||
}
|
||||
msgText := event.File.Message().GetMessage()
|
||||
for _, chat := range chats {
|
||||
if chat.Filter != "" {
|
||||
filter := strings.Split(chat.Filter, ":")
|
||||
if len(filter) != 2 {
|
||||
logger.Warnf("Invalid filter format in chat %d, skipping", chat.ChatID)
|
||||
continue
|
||||
}
|
||||
filterType := filter[0]
|
||||
filterData := filter[1]
|
||||
switch filterType {
|
||||
case "msgre": // [TODO] enums for filter types
|
||||
if ok, err := regexp.MatchString(filterData, msgText); err != nil {
|
||||
continue
|
||||
} else if !ok {
|
||||
continue
|
||||
}
|
||||
default:
|
||||
logger.Warnf("Unsupported filter type %s in chat %d, skipping", filterType, chat.ChatID)
|
||||
continue
|
||||
}
|
||||
}
|
||||
user, err := database.GetUserByID(ctx, chat.UserID)
|
||||
if err != nil {
|
||||
logger.Errorf("Failed to get user by ID %d: %v", chat.UserID, err)
|
||||
continue
|
||||
}
|
||||
if user.DefaultStorage == "" {
|
||||
logger.Warnf("User %d has no default storage set, skipping media message handling", chat.UserID)
|
||||
continue
|
||||
}
|
||||
stor, err := storage.GetStorageByUserIDAndName(ctx, user.ChatID, user.DefaultStorage)
|
||||
if err != nil {
|
||||
logger.Errorf("Failed to get storage by user ID %d and name %s: %v", user.ChatID, user.DefaultStorage, err)
|
||||
continue
|
||||
}
|
||||
switch user.FilenameStrategy {
|
||||
case fnamest.Message.String():
|
||||
file.SetName(tgutil.GenFileNameFromMessage(*file.Message()))
|
||||
case fnamest.Template.String():
|
||||
if user.FilenameTemplate == "" {
|
||||
logger.Warnf("Empty filename template for user %d, using default filename", user.ChatID)
|
||||
break
|
||||
}
|
||||
message := file.Message()
|
||||
tmpl, err := template.New("filename").Parse(user.FilenameTemplate)
|
||||
if err != nil {
|
||||
logger.Errorf("Failed to parse filename template for user %d: %s", user.ChatID, err)
|
||||
break
|
||||
}
|
||||
data := mediautil.BuildFilenameTemplateData(message)
|
||||
var sb strings.Builder
|
||||
err = tmpl.Execute(&sb, data)
|
||||
if err != nil {
|
||||
log.FromContext(ctx).Errorf("failed to execute filename template: %s", err)
|
||||
break
|
||||
}
|
||||
file.SetName(sb.String())
|
||||
}
|
||||
var dirPath string
|
||||
if user.ApplyRule && user.Rules != nil {
|
||||
matched, matchedStorageName, matchedDirPath := ruleutil.ApplyRule(ctx, user.Rules, ruleutil.NewInput(file))
|
||||
if !matched {
|
||||
goto startCreateTask
|
||||
}
|
||||
dirPath = matchedDirPath.String()
|
||||
if matchedStorageName.IsUsable() {
|
||||
stor, err = storage.GetStorageByUserIDAndName(ctx, user.ChatID, matchedStorageName.String())
|
||||
if err != nil {
|
||||
logger.Errorf("Failed to get storage by user ID and name: %s", err)
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
startCreateTask:
|
||||
storagePath := stor.JoinStoragePath(path.Join(dirPath, file.Name()))
|
||||
injectCtx := tgutil.ExtWithContext(ctx.Context, ctx)
|
||||
taskid := xid.New().String()
|
||||
task, err := tfile.NewTGFileTask(taskid, injectCtx, file, stor, storagePath, nil)
|
||||
if err != nil {
|
||||
logger.Errorf("create task failed: %s", err)
|
||||
continue
|
||||
}
|
||||
if err := core.AddTask(injectCtx, task); err != nil {
|
||||
logger.Errorf("add task failed: %s", err)
|
||||
continue
|
||||
}
|
||||
logger.Infof("Added media message task for user %d in chat %d: %s", chat.UserID, event.ChatID, file.Name())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,13 +9,14 @@ import (
|
||||
"github.com/gotd/td/telegram"
|
||||
"github.com/krau/SaveAny-Bot/client/middleware/recovery"
|
||||
"github.com/krau/SaveAny-Bot/client/middleware/retry"
|
||||
"github.com/krau/SaveAny-Bot/config"
|
||||
)
|
||||
|
||||
// https://github.com/iyear/tdl/blob/master/core/tclient/tclient.go
|
||||
func NewDefaultMiddlewares(ctx context.Context, timeout time.Duration) []telegram.Middleware {
|
||||
return []telegram.Middleware{
|
||||
recovery.New(ctx, newBackoff(timeout)),
|
||||
retry.New(5),
|
||||
retry.New(config.C().Telegram.RpcRetry),
|
||||
floodwait.NewSimpleWaiter(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,11 +2,11 @@ package recovery
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/cenkalti/backoff/v4"
|
||||
"github.com/charmbracelet/log"
|
||||
"github.com/go-faster/errors"
|
||||
"github.com/gotd/td/bin"
|
||||
"github.com/gotd/td/telegram"
|
||||
"github.com/gotd/td/tg"
|
||||
@@ -31,7 +31,7 @@ func (r *recovery) Handle(next tg.Invoker) telegram.InvokeFunc {
|
||||
return backoff.RetryNotify(func() error {
|
||||
if err := next.Invoke(ctx, input, output); err != nil {
|
||||
if r.shouldRecover(ctx, err) {
|
||||
return errors.Wrap(err, "recover")
|
||||
return fmt.Errorf("recovery: %w", err)
|
||||
}
|
||||
|
||||
return backoff.Permanent(err)
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"fmt"
|
||||
|
||||
"github.com/charmbracelet/log"
|
||||
"github.com/go-faster/errors"
|
||||
"github.com/gotd/td/bin"
|
||||
"github.com/gotd/td/telegram"
|
||||
"github.com/gotd/td/tg"
|
||||
@@ -37,7 +36,8 @@ func (r retry) Handle(next tg.Invoker) telegram.InvokeFunc {
|
||||
retries++
|
||||
continue
|
||||
}
|
||||
return errors.Wrap(err, "retry middleware skip")
|
||||
// retry middleware skip
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@@ -6,13 +6,18 @@ import (
|
||||
|
||||
"github.com/celestix/gotgproto"
|
||||
"github.com/celestix/gotgproto/dispatcher"
|
||||
"github.com/celestix/gotgproto/dispatcher/handlers"
|
||||
"github.com/celestix/gotgproto/dispatcher/handlers/filters"
|
||||
"github.com/celestix/gotgproto/ext"
|
||||
"github.com/celestix/gotgproto/sessionMaker"
|
||||
|
||||
"github.com/charmbracelet/log"
|
||||
"github.com/gotd/td/telegram/dcs"
|
||||
"github.com/gotd/td/tg"
|
||||
"github.com/krau/SaveAny-Bot/client/middleware"
|
||||
"github.com/krau/SaveAny-Bot/common/utils/netutil"
|
||||
"github.com/krau/SaveAny-Bot/config"
|
||||
"github.com/krau/SaveAny-Bot/database"
|
||||
"github.com/ncruces/go-sqlite3/gormlite"
|
||||
"golang.org/x/net/proxy"
|
||||
)
|
||||
@@ -49,8 +54,8 @@ func Login(ctx context.Context) (*gotgproto.Client, error) {
|
||||
})
|
||||
go func() {
|
||||
var resolver dcs.Resolver
|
||||
if config.Cfg.Telegram.Proxy.Enable && config.Cfg.Telegram.Proxy.URL != "" {
|
||||
dialer, err := netutil.NewProxyDialer(config.Cfg.Telegram.Proxy.URL)
|
||||
if config.C().Telegram.Proxy.Enable && config.C().Telegram.Proxy.URL != "" {
|
||||
dialer, err := netutil.NewProxyDialer(config.C().Telegram.Proxy.URL)
|
||||
if err != nil {
|
||||
res <- struct {
|
||||
client *gotgproto.Client
|
||||
@@ -65,16 +70,16 @@ func Login(ctx context.Context) (*gotgproto.Client, error) {
|
||||
resolver = dcs.DefaultResolver()
|
||||
}
|
||||
tclient, err := gotgproto.NewClient(
|
||||
config.Cfg.Telegram.AppID,
|
||||
config.Cfg.Telegram.AppHash,
|
||||
config.C().Telegram.AppID,
|
||||
config.C().Telegram.AppHash,
|
||||
gotgproto.ClientTypePhone(""),
|
||||
&gotgproto.ClientOpts{
|
||||
Session: sessionMaker.SqlSession(gormlite.Open(config.Cfg.Telegram.Userbot.Session)),
|
||||
Session: sessionMaker.SqlSession(gormlite.Open(config.C().Telegram.Userbot.Session)),
|
||||
AuthConversator: &terminalAuthConversator{},
|
||||
Context: ctx,
|
||||
DisableCopyright: true,
|
||||
Resolver: resolver,
|
||||
MaxRetries: config.Cfg.Telegram.RpcRetry,
|
||||
MaxRetries: config.C().Telegram.RpcRetry,
|
||||
AutoFetchReply: true,
|
||||
Middlewares: middleware.NewDefaultMiddlewares(ctx, 5*time.Minute),
|
||||
ErrorHandler: func(ctx *ext.Context, u *ext.Update, s string) error {
|
||||
@@ -106,6 +111,19 @@ func Login(ctx context.Context) (*gotgproto.Client, error) {
|
||||
return nil, r.err
|
||||
}
|
||||
uc = r.client
|
||||
uc.Dispatcher.AddHandler(handlers.NewMessage(filters.Message.Media, func(ctx *ext.Context, u *ext.Update) error {
|
||||
switch u.UpdateClass.(type) {
|
||||
case *tg.UpdateEditChannelMessage, *tg.UpdateEditMessage, *tg.UpdateDeleteChannelMessages, *tg.UpdateDeleteMessages:
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
chatId := u.EffectiveChat().GetID()
|
||||
watchChats, err := database.GetWatchChatsByChatID(ctx, chatId)
|
||||
if err != nil || len(watchChats) == 0 {
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
return dispatcher.ContinueGroups
|
||||
}))
|
||||
uc.Dispatcher.AddHandler(handlers.NewMessage(filters.Message.Media, handleMediaMessage))
|
||||
log.FromContext(ctx).Infof("User client logged in successfully: %s", uc.Self.FirstName+" "+uc.Self.LastName)
|
||||
return uc, nil
|
||||
}
|
||||
|
||||
100
client/user/watch.go
Normal file
100
client/user/watch.go
Normal file
@@ -0,0 +1,100 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/celestix/gotgproto/dispatcher"
|
||||
"github.com/celestix/gotgproto/ext"
|
||||
"github.com/gotd/td/tg"
|
||||
"github.com/krau/SaveAny-Bot/common/utils/tgutil"
|
||||
"github.com/krau/SaveAny-Bot/pkg/tfile"
|
||||
)
|
||||
|
||||
type MediaMessageEvent struct {
|
||||
Ctx *ext.Context
|
||||
ChatID int64 // from witch the media message was sent
|
||||
MessageID int
|
||||
File tfile.TGFileMessage
|
||||
}
|
||||
|
||||
type messageKey struct {
|
||||
ChatID int64
|
||||
MessageID int
|
||||
}
|
||||
|
||||
type MediaMessageHandler struct {
|
||||
events map[messageKey]MediaMessageEvent
|
||||
timers map[messageKey]*time.Timer
|
||||
mu sync.Mutex
|
||||
debounce time.Duration
|
||||
}
|
||||
|
||||
var (
|
||||
mediaMessageCh = make(chan MediaMessageEvent, 100)
|
||||
mediaMessageHandler = &MediaMessageHandler{
|
||||
events: make(map[messageKey]MediaMessageEvent),
|
||||
timers: make(map[messageKey]*time.Timer),
|
||||
debounce: 5 * time.Second,
|
||||
}
|
||||
)
|
||||
|
||||
func GetMediaMessageCh() chan MediaMessageEvent {
|
||||
return mediaMessageCh
|
||||
}
|
||||
|
||||
func sendMediaMessageEvent(event MediaMessageEvent) {
|
||||
key := messageKey{ChatID: event.ChatID, MessageID: event.MessageID}
|
||||
|
||||
mediaMessageHandler.mu.Lock()
|
||||
defer mediaMessageHandler.mu.Unlock()
|
||||
|
||||
if timer, exists := mediaMessageHandler.timers[key]; exists {
|
||||
timer.Stop()
|
||||
} else {
|
||||
mediaMessageHandler.events[key] = event
|
||||
}
|
||||
|
||||
mediaMessageHandler.timers[key] = time.AfterFunc(mediaMessageHandler.debounce, func() {
|
||||
mediaMessageHandler.mu.Lock()
|
||||
event := mediaMessageHandler.events[key]
|
||||
delete(mediaMessageHandler.events, key)
|
||||
delete(mediaMessageHandler.timers, key)
|
||||
mediaMessageHandler.mu.Unlock()
|
||||
|
||||
mediaMessageCh <- event
|
||||
})
|
||||
}
|
||||
|
||||
func handleMediaMessage(ctx *ext.Context, update *ext.Update) error {
|
||||
message := update.EffectiveMessage
|
||||
media, ok := message.GetMedia()
|
||||
if !ok || media == nil {
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
support := func() bool {
|
||||
switch media.(type) {
|
||||
case *tg.MessageMediaDocument, *tg.MessageMediaPhoto:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}()
|
||||
if !support {
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
file, err := tfile.FromMediaMessage(media, ctx.Raw, message.Message, tfile.WithNameIfEmpty(
|
||||
tgutil.GenFileNameFromMessage(*message.Message),
|
||||
))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
chatId := update.EffectiveChat().GetID()
|
||||
sendMediaMessageEvent(MediaMessageEvent{
|
||||
Ctx: ctx,
|
||||
ChatID: chatId,
|
||||
MessageID: message.ID,
|
||||
File: file,
|
||||
})
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
@@ -8,9 +8,10 @@ import (
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/goccy/go-yaml"
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -20,28 +21,27 @@ func main() {
|
||||
flag.Parse()
|
||||
|
||||
keys := make(map[string]struct{})
|
||||
re := regexp.MustCompile(`^\s*\[+\s*([^\]\[]+)\s*\]+`)
|
||||
|
||||
err := filepath.WalkDir(*dir, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if d.IsDir() || !strings.HasSuffix(d.Name(), ".toml") {
|
||||
if d.IsDir() || !(strings.HasSuffix(d.Name(), ".yaml") || strings.HasSuffix(d.Name(), ".yml")) {
|
||||
return nil
|
||||
}
|
||||
f, err := os.Open(path)
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
s := bufio.NewScanner(f)
|
||||
for s.Scan() {
|
||||
if m := re.FindStringSubmatch(s.Text()); m != nil {
|
||||
keys[m[1]] = struct{}{}
|
||||
}
|
||||
var content map[string]interface{}
|
||||
if err := yaml.Unmarshal(data, &content); err != nil {
|
||||
return fmt.Errorf("failed to parse yaml %s: %w", path, err)
|
||||
}
|
||||
return s.Err()
|
||||
|
||||
collectKeys(content, "", keys)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error walking directory: %v\n", err)
|
||||
@@ -62,23 +62,44 @@ func main() {
|
||||
defer f.Close()
|
||||
|
||||
w := bufio.NewWriter(f)
|
||||
fmt.Fprintf(w, "// Code generated by cmd/gen_i18n. DO NOT EDIT.\n")
|
||||
fmt.Fprintf(w, "// Code generated by cmd/geni18n. DO NOT EDIT.\n")
|
||||
fmt.Fprintf(w, "package %s\n\n", *pkg)
|
||||
fmt.Fprintf(w, "type Key string\n\n")
|
||||
fmt.Fprintf(w, "const (\n")
|
||||
for _, key := range list {
|
||||
name := toPascal(key)
|
||||
fmt.Fprintf(w, "\t%s = %q\n", name, key)
|
||||
fmt.Fprintf(w, "\t%s Key = %q\n", name, key)
|
||||
}
|
||||
fmt.Fprintf(w, ")\n")
|
||||
w.Flush()
|
||||
}
|
||||
|
||||
func collectKeys(node map[string]interface{}, prefix string, keys map[string]struct{}) {
|
||||
for k, v := range node {
|
||||
fullKey := k
|
||||
if prefix != "" {
|
||||
fullKey = prefix + "." + k
|
||||
}
|
||||
switch val := v.(type) {
|
||||
case map[string]interface{}:
|
||||
collectKeys(val, fullKey, keys)
|
||||
default:
|
||||
keys[fullKey] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 转 PascalCase
|
||||
func toPascal(key string) string {
|
||||
parts := strings.Split(key, ".")
|
||||
for i, p := range parts {
|
||||
if len(p) > 0 {
|
||||
parts[i] = strings.ToUpper(string(p[0])) + p[1:]
|
||||
subs := strings.Split(p, "_")
|
||||
for j, s := range subs {
|
||||
if len(s) > 0 {
|
||||
subs[j] = strings.ToUpper(s[:1]) + s[1:]
|
||||
}
|
||||
}
|
||||
parts[i] = strings.Join(subs, "")
|
||||
}
|
||||
return strings.Join(parts, "")
|
||||
}
|
||||
|
||||
67
cmd/run.go
67
cmd/run.go
@@ -19,12 +19,13 @@ import (
|
||||
"github.com/krau/SaveAny-Bot/config"
|
||||
"github.com/krau/SaveAny-Bot/core"
|
||||
"github.com/krau/SaveAny-Bot/database"
|
||||
"github.com/krau/SaveAny-Bot/parsers"
|
||||
"github.com/krau/SaveAny-Bot/storage"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func Run(cmd *cobra.Command, _ []string) {
|
||||
ctx := cmd.Context()
|
||||
ctx, cancel := context.WithCancel(cmd.Context())
|
||||
logger := log.NewWithOptions(os.Stdout, log.Options{
|
||||
Level: log.DebugLevel,
|
||||
ReportTimestamp: true,
|
||||
@@ -33,66 +34,84 @@ func Run(cmd *cobra.Command, _ []string) {
|
||||
})
|
||||
ctx = log.WithContext(ctx, logger)
|
||||
|
||||
initAll(ctx)
|
||||
exitChan, err := initAll(ctx)
|
||||
if err != nil {
|
||||
logger.Fatal("Init failed", "error", err)
|
||||
}
|
||||
go func() {
|
||||
<-exitChan
|
||||
cancel()
|
||||
}()
|
||||
|
||||
core.Run(ctx)
|
||||
|
||||
<-ctx.Done()
|
||||
logger.Info(i18n.T(i18nk.Exiting))
|
||||
defer logger.Info(i18n.T(i18nk.Bye))
|
||||
logger.Info(i18n.T(i18nk.LifetimeExiting))
|
||||
defer logger.Info(i18n.T(i18nk.LifetimeBye))
|
||||
cleanCache()
|
||||
}
|
||||
|
||||
func initAll(ctx context.Context) {
|
||||
func initAll(ctx context.Context) (<-chan struct{}, error) {
|
||||
if err := config.Init(ctx); err != nil {
|
||||
fmt.Println("Failed to load config:", err)
|
||||
os.Exit(1)
|
||||
return nil, fmt.Errorf("failed to load config: %w", err)
|
||||
}
|
||||
cache.Init()
|
||||
logger := log.FromContext(ctx)
|
||||
i18n.Init(config.Cfg.Lang)
|
||||
logger.Info(i18n.T(i18nk.Initing))
|
||||
i18n.Init(config.C().Lang)
|
||||
logger.Info(i18n.T(i18nk.LifetimeIniting))
|
||||
database.Init(ctx)
|
||||
storage.LoadStorages(ctx)
|
||||
if config.Cfg.Telegram.Userbot.Enable {
|
||||
_, err := userclient.Login(ctx)
|
||||
if err != nil {
|
||||
logger.Fatalf("User client login failed: %s", err)
|
||||
if config.C().Parser.PluginEnable {
|
||||
for _, dir := range config.C().Parser.PluginDirs {
|
||||
if err := parsers.LoadPlugins(ctx, dir); err != nil {
|
||||
logger.Error(i18n.T(i18nk.ParserPluginLoadFailed), "dir", dir, "error", err)
|
||||
} else {
|
||||
logger.Debug(i18n.T(i18nk.ParserPluginLoadedDir), "dir", dir)
|
||||
}
|
||||
}
|
||||
}
|
||||
bot.Init(ctx)
|
||||
if config.C().Telegram.Userbot.Enable {
|
||||
_, err := userclient.Login(ctx)
|
||||
if err != nil {
|
||||
logger.Fatal(i18n.T(i18nk.LifetimeUserLoginFailed, map[string]any{
|
||||
"Error": err,
|
||||
}))
|
||||
}
|
||||
}
|
||||
return bot.Init(ctx), nil
|
||||
}
|
||||
|
||||
func cleanCache() {
|
||||
if config.Cfg.NoCleanCache {
|
||||
if config.C().NoCleanCache {
|
||||
return
|
||||
}
|
||||
if config.Cfg.Temp.BasePath != "" && !config.Cfg.Stream {
|
||||
if slices.Contains([]string{"/", ".", "\\", ".."}, filepath.Clean(config.Cfg.Temp.BasePath)) {
|
||||
log.Error(i18n.T(i18nk.InvalidCacheDir, map[string]any{
|
||||
"Path": config.Cfg.Temp.BasePath,
|
||||
if config.C().Temp.BasePath != "" && !config.C().Stream {
|
||||
if slices.Contains([]string{"/", ".", "\\", ".."}, filepath.Clean(config.C().Temp.BasePath)) {
|
||||
log.Error(i18n.T(i18nk.ConfigErrInvalidCacheDir, map[string]any{
|
||||
"Path": config.C().Temp.BasePath,
|
||||
}))
|
||||
return
|
||||
}
|
||||
currentDir, err := os.Getwd()
|
||||
if err != nil {
|
||||
log.Error(i18n.T(i18nk.GetWorkdirFailed, map[string]any{
|
||||
log.Error(i18n.T(i18nk.ErrGetWorkdirFailed, map[string]any{
|
||||
"Error": err,
|
||||
}))
|
||||
return
|
||||
}
|
||||
cachePath := filepath.Join(currentDir, config.Cfg.Temp.BasePath)
|
||||
cachePath := filepath.Join(currentDir, config.C().Temp.BasePath)
|
||||
cachePath, err = filepath.Abs(cachePath)
|
||||
if err != nil {
|
||||
log.Error(i18n.T(i18nk.GetCacheAbsPathFailed, map[string]any{
|
||||
log.Error(i18n.T(i18nk.ErrGetCacheAbsPathFailed, map[string]any{
|
||||
"Error": err,
|
||||
}))
|
||||
return
|
||||
}
|
||||
log.Info(i18n.T(i18nk.CleaningCache, map[string]any{
|
||||
log.Info(i18n.T(i18nk.LifetimeCleaningCache, map[string]any{
|
||||
"Path": cachePath,
|
||||
}))
|
||||
if err := fsutil.RemoveAllInDir(cachePath); err != nil {
|
||||
log.Error(i18n.T(i18nk.CleanCacheFailed, map[string]any{
|
||||
log.Error(i18n.T(i18nk.ErrCleanCacheFailed, map[string]any{
|
||||
"Error": err,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"fmt"
|
||||
"runtime"
|
||||
|
||||
"github.com/krau/SaveAny-Bot/pkg/consts"
|
||||
"github.com/krau/SaveAny-Bot/config"
|
||||
"github.com/rhysd/go-github-selfupdate/selfupdate"
|
||||
|
||||
"github.com/blang/semver"
|
||||
@@ -16,7 +16,7 @@ var VersionCmd = &cobra.Command{
|
||||
Aliases: []string{"v"},
|
||||
Short: "Print the version number of saveany-bot",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
fmt.Printf("saveany-bot version: %s %s/%s\nBuildTime: %s, Commit: %s\n", consts.Version, runtime.GOOS, runtime.GOARCH, consts.BuildTime, consts.GitCommit)
|
||||
fmt.Printf("saveany-bot version: %s %s/%s\nBuildTime: %s, Commit: %s\n", config.Version, runtime.GOOS, runtime.GOARCH, config.BuildTime, config.GitCommit)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -25,14 +25,14 @@ var upgradeCmd = &cobra.Command{
|
||||
Aliases: []string{"up"},
|
||||
Short: "Upgrade saveany-bot to the latest version",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
v := semver.MustParse(consts.Version)
|
||||
latest, err := selfupdate.UpdateSelf(v, "krau/SaveAny-Bot")
|
||||
v := semver.MustParse(config.Version)
|
||||
latest, err := selfupdate.UpdateSelf(v, config.GitRepo)
|
||||
if err != nil {
|
||||
fmt.Println("Binary update failed:", err)
|
||||
fmt.Println("Update failed:", err)
|
||||
return
|
||||
}
|
||||
if latest.Version.Equals(v) {
|
||||
fmt.Println("Current binary is the latest version", consts.Version)
|
||||
fmt.Println("Current binary is the latest version", config.Version)
|
||||
} else {
|
||||
fmt.Println("Successfully updated to version", latest.Version)
|
||||
fmt.Println("Release note:\n", latest.ReleaseNotes)
|
||||
|
||||
6
common/cache/ristretto.go
vendored
6
common/cache/ristretto.go
vendored
@@ -16,8 +16,8 @@ func Init() {
|
||||
panic("cache already initialized")
|
||||
}
|
||||
c, err := ristretto.NewCache(&ristretto.Config[string, any]{
|
||||
NumCounters: config.Cfg.Cache.NumCounters,
|
||||
MaxCost: config.Cfg.Cache.MaxCost,
|
||||
NumCounters: config.C().Cache.NumCounters,
|
||||
MaxCost: config.C().Cache.MaxCost,
|
||||
BufferItems: 64,
|
||||
OnReject: func(item *ristretto.Item[any]) {
|
||||
log.Warnf("Cache item rejected: key=%d, value=%v", item.Key, item.Value)
|
||||
@@ -30,7 +30,7 @@ func Init() {
|
||||
}
|
||||
|
||||
func Set(key string, value any) error {
|
||||
ok := cache.SetWithTTL(key, value, 0, time.Duration(config.Cfg.Cache.TTL)*time.Second)
|
||||
ok := cache.SetWithTTL(key, value, 0, time.Duration(config.C().Cache.TTL)*time.Second)
|
||||
if !ok {
|
||||
return fmt.Errorf("failed to set value in cache")
|
||||
}
|
||||
|
||||
@@ -5,12 +5,13 @@ import (
|
||||
|
||||
"maps"
|
||||
|
||||
"github.com/goccy/go-yaml"
|
||||
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
|
||||
"github.com/nicksnyder/go-i18n/v2/i18n"
|
||||
"github.com/pelletier/go-toml/v2"
|
||||
"golang.org/x/text/language"
|
||||
)
|
||||
|
||||
//go:embed locale/*.toml
|
||||
//go:embed locale/*
|
||||
var localesFS embed.FS
|
||||
|
||||
var (
|
||||
@@ -20,7 +21,7 @@ var (
|
||||
|
||||
func Init(lang string) {
|
||||
bundle = i18n.NewBundle(language.SimplifiedChinese)
|
||||
bundle.RegisterUnmarshalFunc("toml", toml.Unmarshal)
|
||||
bundle.RegisterUnmarshalFunc("yaml", yaml.Unmarshal)
|
||||
files, err := localesFS.ReadDir("locale")
|
||||
if err != nil {
|
||||
panic("failed to read locale directory: " + err.Error())
|
||||
@@ -39,7 +40,7 @@ func Init(lang string) {
|
||||
}
|
||||
}
|
||||
|
||||
func T(key string, templateData ...map[string]any) string {
|
||||
func T(key i18nk.Key, templateData ...map[string]any) string {
|
||||
if localizer == nil || bundle == nil {
|
||||
panic("localizer or bundle is not initialized, call Init() first")
|
||||
}
|
||||
@@ -48,11 +49,11 @@ func T(key string, templateData ...map[string]any) string {
|
||||
maps.Copy(templateDataMap, data)
|
||||
}
|
||||
msg, err := localizer.Localize(&i18n.LocalizeConfig{
|
||||
MessageID: key,
|
||||
MessageID: string(key),
|
||||
TemplateData: templateDataMap,
|
||||
})
|
||||
if err != nil {
|
||||
return key
|
||||
return string(key)
|
||||
}
|
||||
return msg
|
||||
}
|
||||
@@ -77,32 +78,32 @@ func TWithLang(lang, key string, templateData ...map[string]any) string {
|
||||
}
|
||||
|
||||
// Only use in tests or packages that load before i18n
|
||||
func TWithoutInit(lang, key string, templateData ...map[string]any) string {
|
||||
func TWithoutInit(lang string, key i18nk.Key, templateData ...map[string]any) string {
|
||||
bundle := i18n.NewBundle(language.SimplifiedChinese)
|
||||
bundle.RegisterUnmarshalFunc("toml", toml.Unmarshal)
|
||||
bundle.RegisterUnmarshalFunc("yaml", yaml.Unmarshal)
|
||||
files, err := localesFS.ReadDir("locale")
|
||||
if err != nil {
|
||||
return key
|
||||
return string(key)
|
||||
}
|
||||
for _, file := range files {
|
||||
if _, err := bundle.LoadMessageFileFS(localesFS, "locale/"+file.Name()); err != nil {
|
||||
return key
|
||||
return string(key)
|
||||
}
|
||||
}
|
||||
localizer := i18n.NewLocalizer(bundle, lang)
|
||||
if localizer == nil {
|
||||
return key
|
||||
return string(key)
|
||||
}
|
||||
templateDataMap := make(map[string]any)
|
||||
for _, data := range templateData {
|
||||
maps.Copy(templateDataMap, data)
|
||||
}
|
||||
msg, err := localizer.Localize(&i18n.LocalizeConfig{
|
||||
MessageID: key,
|
||||
MessageID: string(key),
|
||||
TemplateData: templateDataMap,
|
||||
})
|
||||
if err != nil {
|
||||
return key
|
||||
return string(key)
|
||||
}
|
||||
return msg
|
||||
}
|
||||
|
||||
@@ -1,19 +1,24 @@
|
||||
// Code generated by cmd/gen_i18n. DO NOT EDIT.
|
||||
// Code generated by cmd/geni18n. DO NOT EDIT.
|
||||
package i18nk
|
||||
|
||||
type Key string
|
||||
|
||||
const (
|
||||
CleanCacheFailed = "CleanCacheFailed"
|
||||
CleaningCache = "CleaningCache"
|
||||
ConfigInvalidDuplicateStorageName = "ConfigInvalid.DuplicateStorageName"
|
||||
ConfigInvalidWorkersOrRetry = "ConfigInvalid.WorkersOrRetry"
|
||||
CreateRmTimerFailed = "CreateRmTimerFailed"
|
||||
GetCacheAbsPathFailed = "GetCacheAbsPathFailed"
|
||||
GetWorkdirFailed = "GetWorkdirFailed"
|
||||
InvalidCacheDir = "InvalidCacheDir"
|
||||
LoadedStorages = "LoadedStorages"
|
||||
RemoveFileAfter = "RemoveFileAfter"
|
||||
RemoveFileFailed = "RemoveFileFailed"
|
||||
Bye = "bye"
|
||||
Exiting = "exiting"
|
||||
Initing = "initing"
|
||||
BotMsgHelpTextFmt Key = "bot.msg.help_text_fmt"
|
||||
BotMsgSaveHelpText Key = "bot.msg.save_help_text"
|
||||
BotMsgWatchHelpText Key = "bot.msg.watch_help_text"
|
||||
ConfigErrDuplicateStorageName Key = "config.err.duplicate_storage_name"
|
||||
ConfigErrInvalidCacheDir Key = "config.err.invalid_cache_dir"
|
||||
ConfigLoadedStorages Key = "config.loaded_storages"
|
||||
ErrCleanCacheFailed Key = "err.clean_cache_failed"
|
||||
ErrGetCacheAbsPathFailed Key = "err.get_cache_abs_path_failed"
|
||||
ErrGetWorkdirFailed Key = "err.get_workdir_failed"
|
||||
LifetimeBye Key = "lifetime.bye"
|
||||
LifetimeCleaningCache Key = "lifetime.cleaning_cache"
|
||||
LifetimeExiting Key = "lifetime.exiting"
|
||||
LifetimeInitfailed Key = "lifetime.initfailed"
|
||||
LifetimeIniting Key = "lifetime.initing"
|
||||
LifetimeUserLoginFailed Key = "lifetime.user_login_failed"
|
||||
ParserPluginLoadFailed Key = "parser.plugin.load_failed"
|
||||
ParserPluginLoadedDir Key = "parser.plugin.loaded_dir"
|
||||
)
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
[initing]
|
||||
other = "正在启动..."
|
||||
[exiting]
|
||||
other = "正在退出..."
|
||||
[bye]
|
||||
other = "已退出"
|
||||
[InvalidCacheDir]
|
||||
other = "无效的缓存文件夹: {{.Path}}"
|
||||
[GetWorkdirFailed]
|
||||
other = "获取工作目录失败: {{.Error}}"
|
||||
[GetCacheAbsPathFailed]
|
||||
other = "获取缓存绝对路径失败: {{.Error}}"
|
||||
[CleaningCache]
|
||||
other = "正在清理缓存文件夹: {{.Path}}"
|
||||
[CleanCacheFailed]
|
||||
other = "清理缓存失败: {{.Error}}"
|
||||
[CreateRmTimerFailed]
|
||||
other = "创建清理定时器失败, 路径: {{.Path}}, 错误: {{.Error}}"
|
||||
[RemoveFileAfter]
|
||||
other = "将在 {{.Duration}} 后删除文件: {{.Path}}"
|
||||
[RemoveFileFailed]
|
||||
other = "删除文件失败: {{.Path}}, 错误: {{.Error}}"
|
||||
[LoadedStorages]
|
||||
other = "已加载 {{.Count}} 个存储"
|
||||
[ConfigInvalid.WorkersOrRetry]
|
||||
other = "配置无效: workers 或 retry 必须大于 0, 但当前值为: workers={{.Workers}}, retry={{.Retry}}"
|
||||
[ConfigInvalid.DuplicateStorageName]
|
||||
other = "存储名称重复: {{.Name}}"
|
||||
62
common/i18n/locale/zh-Hans.yaml
Normal file
62
common/i18n/locale/zh-Hans.yaml
Normal file
@@ -0,0 +1,62 @@
|
||||
lifetime:
|
||||
initing: 正在启动
|
||||
initfailed: 初始化失败
|
||||
exiting: 正在退出
|
||||
user_login_failed: "用户登录失败: {{.Error}}"
|
||||
cleaning_cache: "正在清理缓存 {{.Path}}"
|
||||
bye: 已退出
|
||||
config:
|
||||
loaded_storages: "已加载 {{.Count}} 个存储后端"
|
||||
err:
|
||||
invalid_cache_dir: "无效的缓存目录: {{.Path}},请检查配置文件"
|
||||
duplicate_storage_name: "存储名称 '{{.Name}}' 重复,请检查配置文件"
|
||||
err:
|
||||
get_workdir_failed: "获取工作目录失败: {{.Error}}"
|
||||
get_cache_abs_path_failed: "获取缓存绝对路径失败: {{.Error}}"
|
||||
clean_cache_failed: "清理缓存失败: {{.Error}}"
|
||||
parser:
|
||||
plugin:
|
||||
load_failed: 加载解析器插件失败
|
||||
loaded_dir: 解析器插件已加载
|
||||
bot:
|
||||
msg:
|
||||
help_text_fmt: |
|
||||
Save Any Bot - 转存你的 Telegram 文件
|
||||
版本: %s , 提交: %s
|
||||
|
||||
命令:
|
||||
/start - 开始使用
|
||||
/help - 显示帮助
|
||||
/silent - 开关静默模式
|
||||
/storage - 设置默认存储位置
|
||||
/save [自定义文件名] - 保存文件
|
||||
/dir - 管理存储目录
|
||||
/rule - 管理规则
|
||||
/update - 检查更新并升级
|
||||
|
||||
使用帮助: https://sabot.unv.app/usage
|
||||
反馈群组: https://t.me/ProjectSaveAny
|
||||
save_help_text: |
|
||||
使用方法:
|
||||
|
||||
1. 使用该命令回复要保存的文件, 可选文件名参数.
|
||||
示例:
|
||||
/save custom_file_name.mp4
|
||||
|
||||
2. 设置默认存储后, 发送 /save <频道ID/用户名> <消息ID范围> 来批量保存文件. 遵从存储规则, 若未匹配到任何规则则使用默认存储.
|
||||
示例:
|
||||
/save @acherkrau 114-514
|
||||
watch_help_text: |
|
||||
使用 /watch 命令监听一个聊天的消息, 并自动保存到默认存储中, 遵从存储规则.
|
||||
|
||||
命令语法:
|
||||
/watch <chat_id> [filter]
|
||||
|
||||
参数:
|
||||
- <chat_id>: 聊天的 ID 或用户名
|
||||
- [filter]: 可选, 格式为 过滤器类型:表达式 , 所有支持类型的过滤器请查看文档
|
||||
|
||||
命令示例:
|
||||
/watch 2229835658 msgre:.*plana.*
|
||||
|
||||
这将监听 ID 为 2229835658 的聊天, 并转存所有包含 "plana" 的媒体消息
|
||||
@@ -3,6 +3,8 @@ package fsutil
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"github.com/gabriel-vasile/mimetype"
|
||||
)
|
||||
@@ -55,3 +57,21 @@ func CreateFile(fp string) (*File, error) {
|
||||
}
|
||||
return &File{File: file}, nil
|
||||
}
|
||||
|
||||
func NormalizePathname(s string) string {
|
||||
specials := `\/:*?"<>|` + "\n\r\t"
|
||||
var builder strings.Builder
|
||||
for _, ch := range s {
|
||||
if strings.ContainsRune(specials, ch) || unicode.IsControl(ch) {
|
||||
builder.WriteRune('_')
|
||||
} else {
|
||||
builder.WriteRune(ch)
|
||||
}
|
||||
}
|
||||
|
||||
result := strings.TrimRightFunc(builder.String(), func(r rune) bool {
|
||||
return r == '.' || r == '_' || unicode.IsSpace(r)
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
46
common/utils/fsutil/normalize_pathname_test.go
Normal file
46
common/utils/fsutil/normalize_pathname_test.go
Normal file
@@ -0,0 +1,46 @@
|
||||
package fsutil_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/krau/SaveAny-Bot/common/utils/fsutil"
|
||||
)
|
||||
|
||||
func TestNormalizePathname(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
input: "hello/world?.txt ",
|
||||
expected: "hello_world_.txt",
|
||||
},
|
||||
{
|
||||
input: "bad|name:\nfile\r.",
|
||||
expected: "bad_name__file",
|
||||
},
|
||||
{
|
||||
input: "normal.txt",
|
||||
expected: "normal.txt",
|
||||
},
|
||||
{
|
||||
input: "test.... ",
|
||||
expected: "test",
|
||||
},
|
||||
{
|
||||
input: "abc<>def",
|
||||
expected: "abc__def",
|
||||
},
|
||||
{
|
||||
input: "with\tcontrol",
|
||||
expected: "with_control",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
got := fsutil.NormalizePathname(tc.input)
|
||||
if got != tc.expected {
|
||||
t.Errorf("NormalizePathname(%q) = %q; want %q", tc.input, got, tc.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,15 @@
|
||||
package netutil
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sync"
|
||||
|
||||
"github.com/charmbracelet/log"
|
||||
"github.com/krau/SaveAny-Bot/config"
|
||||
"golang.org/x/net/proxy"
|
||||
)
|
||||
|
||||
@@ -13,3 +20,59 @@ func NewProxyDialer(proxyUrl string) (proxy.Dialer, error) {
|
||||
}
|
||||
return proxy.FromURL(url, proxy.Direct)
|
||||
}
|
||||
|
||||
func NewProxyHTTPClient(proxyUrl string) (*http.Client, error) {
|
||||
if proxyUrl == "" {
|
||||
return &http.Client{
|
||||
Transport: &http.Transport{
|
||||
Proxy: http.ProxyFromEnvironment,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
u, err := url.Parse(proxyUrl)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
switch u.Scheme {
|
||||
case "http", "https":
|
||||
return &http.Client{
|
||||
Transport: &http.Transport{
|
||||
Proxy: http.ProxyURL(u),
|
||||
},
|
||||
}, nil
|
||||
case "socks5":
|
||||
dialer, err := proxy.FromURL(u, proxy.Direct)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &http.Client{
|
||||
Transport: &http.Transport{
|
||||
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
return dialer.Dial(network, addr)
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported proxy scheme: %s", u.Scheme)
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
defaultProxyHttpClient *http.Client
|
||||
onceLoadDefaultProxyHttpClient sync.Once
|
||||
)
|
||||
|
||||
func DefaultParserHTTPClient() *http.Client {
|
||||
onceLoadDefaultProxyHttpClient.Do(func() {
|
||||
client, err := NewProxyHTTPClient(config.C().Parser.Proxy)
|
||||
if err != nil {
|
||||
log.Warn("Failed to create default proxy HTTP client, using http.DefaultClient", "error", err)
|
||||
defaultProxyHttpClient = http.DefaultClient
|
||||
} else {
|
||||
defaultProxyHttpClient = client
|
||||
}
|
||||
})
|
||||
return defaultProxyHttpClient
|
||||
}
|
||||
|
||||
40
common/utils/tgutil/media.go
Normal file
40
common/utils/tgutil/media.go
Normal file
@@ -0,0 +1,40 @@
|
||||
package tgutil
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/gabriel-vasile/mimetype"
|
||||
"github.com/gotd/td/tg"
|
||||
)
|
||||
|
||||
func GetMediaFileName(media tg.MessageMediaClass) (string, error) {
|
||||
switch v := media.(type) {
|
||||
case *tg.MessageMediaPhoto:
|
||||
f, ok := v.Photo.AsNotEmpty()
|
||||
if !ok {
|
||||
return "", fmt.Errorf("unknown type media: %T", media)
|
||||
}
|
||||
return fmt.Sprintf("%d.png", f.ID), nil
|
||||
case *tg.MessageMediaDocument:
|
||||
f, ok := v.Document.AsNotEmpty()
|
||||
if !ok {
|
||||
return "", fmt.Errorf("unknown type media: %T", media)
|
||||
}
|
||||
fileName := ""
|
||||
for _, attribute := range f.Attributes {
|
||||
if name, ok := attribute.(*tg.DocumentAttributeFilename); ok {
|
||||
fileName = name.GetFileName()
|
||||
break
|
||||
}
|
||||
}
|
||||
if fileName == "" {
|
||||
mmt := mimetype.Lookup(f.GetMimeType())
|
||||
if mmt != nil {
|
||||
fileName = fmt.Sprintf("%d.%s", f.GetID(), mmt.Extension())
|
||||
}
|
||||
}
|
||||
return fileName, nil
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported type media: %T", media)
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode"
|
||||
"unicode/utf16"
|
||||
|
||||
"github.com/celestix/gotgproto/ext"
|
||||
"github.com/duke-git/lancet/v2/maputil"
|
||||
@@ -19,6 +21,9 @@ import (
|
||||
"github.com/rs/xid"
|
||||
)
|
||||
|
||||
// generate a file name from the message content and media type
|
||||
//
|
||||
// it will never return an empty string
|
||||
func GenFileNameFromMessage(message tg.Message) string {
|
||||
ext := func(media tg.MessageMediaClass) string {
|
||||
switch media := media.(type) {
|
||||
@@ -58,16 +63,12 @@ func GenFileNameFromMessage(message tg.Message) string {
|
||||
return fmt.Sprintf("%s_%s", tagStr, strconv.Itoa(message.GetID()))
|
||||
}
|
||||
text = lcstrutil.Substring(strings.Map(func(r rune) rune {
|
||||
if r < 0x20 || r == 0x7F {
|
||||
return '_'
|
||||
}
|
||||
switch r {
|
||||
// invalid characters
|
||||
case '/', '\\',
|
||||
':', '*', '?', '"', '<', '>', '|':
|
||||
return '_'
|
||||
// empty
|
||||
case ' ', '\t', '\r', '\n':
|
||||
}
|
||||
if unicode.IsControl(r) || unicode.IsSpace(r) {
|
||||
return '_'
|
||||
}
|
||||
if validator.IsPrintable(string(r)) {
|
||||
@@ -82,7 +83,13 @@ func GenFileNameFromMessage(message tg.Message) string {
|
||||
}()
|
||||
|
||||
if filename == "" {
|
||||
filename = fmt.Sprintf("%d_%s", message.GetID(), xid.New().String())
|
||||
mname, err := GetMediaFileName(message.Media)
|
||||
if err != nil {
|
||||
filename = fmt.Sprintf("%d_%s", message.GetID(), xid.New().String())
|
||||
} else {
|
||||
filename = mname
|
||||
}
|
||||
|
||||
}
|
||||
return filename + ext
|
||||
}
|
||||
@@ -300,3 +307,49 @@ func GetGroupedMessages(ctx *ext.Context, chatID int64, msg *tg.Message) ([]*tg.
|
||||
}
|
||||
return groupedMessages, nil
|
||||
}
|
||||
|
||||
func ExtractMessageEntityUrls(msg *tg.Message) []string {
|
||||
if len(msg.Entities) == 0 {
|
||||
return nil
|
||||
}
|
||||
msgText := msg.GetMessage()
|
||||
if msgText == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
runes := []rune(msgText)
|
||||
utf16Codes := utf16.Encode(runes)
|
||||
|
||||
var urls []string
|
||||
for _, entity := range msg.Entities {
|
||||
switch ent := entity.(type) {
|
||||
case *tg.MessageEntityTextURL:
|
||||
urls = append(urls, ent.GetURL())
|
||||
case *tg.MessageEntityURL:
|
||||
start := ent.Offset
|
||||
end := ent.Offset + ent.Length
|
||||
if start < 0 || end > len(utf16Codes) {
|
||||
continue
|
||||
}
|
||||
subRunes := utf16.Decode(utf16Codes[start:end])
|
||||
urls = append(urls, string(subRunes))
|
||||
}
|
||||
}
|
||||
return urls
|
||||
}
|
||||
|
||||
func ExtractMessageEntityUrlsText(msg *tg.Message) string {
|
||||
if msg == nil {
|
||||
return ""
|
||||
}
|
||||
urls := ExtractMessageEntityUrls(msg)
|
||||
if len(urls) == 0 {
|
||||
return msg.GetMessage()
|
||||
}
|
||||
var sb strings.Builder
|
||||
for _, url := range urls {
|
||||
sb.WriteString(url)
|
||||
sb.WriteString(" ")
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
16
common/utils/tgutil/peer.go
Normal file
16
common/utils/tgutil/peer.go
Normal file
@@ -0,0 +1,16 @@
|
||||
package tgutil
|
||||
|
||||
import "github.com/gotd/td/tg"
|
||||
|
||||
func ChatIdFromPeer(peer tg.PeerClass) int64 {
|
||||
switch peer := peer.(type) {
|
||||
case *tg.PeerChannel:
|
||||
return peer.ChannelID
|
||||
case *tg.PeerUser:
|
||||
return peer.UserID
|
||||
case *tg.PeerChat:
|
||||
return peer.ChatID
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
@@ -86,7 +86,7 @@ func ParseMessageLink(ctx *ext.Context, link string) (int64, int, error) {
|
||||
return chatID, msgID, nil
|
||||
case 3:
|
||||
// https://t.me/c/123456789/123
|
||||
// https://t.me/acherkrau/123/456 , 456: message thread ID
|
||||
// https://t.me/acherkrau/123/456 , 123: topic id
|
||||
chatPart, msgPart := paths[1], paths[2]
|
||||
if paths[0] != "c" {
|
||||
chatPart = paths[0]
|
||||
|
||||
@@ -2,28 +2,38 @@ package tphutil
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/krau/SaveAny-Bot/config"
|
||||
"github.com/krau/SaveAny-Bot/pkg/telegraph"
|
||||
)
|
||||
|
||||
var tphClient *telegraph.Client
|
||||
var (
|
||||
tphClient *telegraph.Client
|
||||
once sync.Once
|
||||
)
|
||||
|
||||
func DefaultClient() *telegraph.Client {
|
||||
if tphClient != nil {
|
||||
return tphClient
|
||||
}
|
||||
if config.Cfg.Telegram.Proxy.Enable && config.Cfg.Telegram.Proxy.URL != "" {
|
||||
proxyUrl := config.Cfg.Telegram.Proxy.URL
|
||||
once.Do(func() {
|
||||
tphClient = initDefault()
|
||||
})
|
||||
return tphClient
|
||||
}
|
||||
|
||||
func initDefault() *telegraph.Client {
|
||||
var client *telegraph.Client
|
||||
if config.C().Telegram.Proxy.Enable && config.C().Telegram.Proxy.URL != "" {
|
||||
proxyUrl := config.C().Telegram.Proxy.URL
|
||||
var err error
|
||||
tphClient, err = telegraph.NewClientWithProxy(proxyUrl)
|
||||
client, err = telegraph.NewClientWithProxy(proxyUrl)
|
||||
if err != nil {
|
||||
tphClient = telegraph.NewClient()
|
||||
client = telegraph.NewClient()
|
||||
}
|
||||
} else {
|
||||
tphClient = telegraph.NewClient()
|
||||
client = telegraph.NewClient()
|
||||
}
|
||||
return tphClient
|
||||
return client
|
||||
}
|
||||
|
||||
func GetNodeImages(node telegraph.Node) []string {
|
||||
@@ -41,6 +51,10 @@ func GetNodeImages(node telegraph.Node) []string {
|
||||
|
||||
if nodeElement.Tag == "img" {
|
||||
if src, exists := nodeElement.Attrs["src"]; exists {
|
||||
if strings.HasPrefix(src, "/file/") {
|
||||
// handle images on telegra.ph server
|
||||
src = "https://telegra.ph" + src
|
||||
}
|
||||
srcs = append(srcs, src)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,69 +1,34 @@
|
||||
#创建文件时,若需要保留中文注释,请务必确保本文件编码为 UTF-8 ,否则会无法读取。
|
||||
workers = 4 # 同时下载文件数
|
||||
retry = 3 # 下载失败重试次数
|
||||
threads = 4 # 单个任务下载最大线程数
|
||||
stream = false # 使用stream模式, 详情请查看文档
|
||||
# 创建文件时,若需要保留中文注释,请务必确保本文件编码为 UTF-8 ,否则会无法读取。
|
||||
# 更详细的配置请在 https://sabot.unv.app/deployment/configuration 查看
|
||||
workers = 4 # 同时下载文件数
|
||||
retry = 3 # 下载失败重试次数
|
||||
threads = 4 # 单个任务下载使用的最大线程数
|
||||
stream = false # 使用流式传输模式, 建议仅在硬盘空间十分有限时使用.
|
||||
|
||||
[telegram]
|
||||
# Bot Token
|
||||
# 更换 Bot Token 后请删除数据库文件和 session.db
|
||||
# 更换 Bot Token 后请删除会话数据库文件 (默认路径为 data/session.db )
|
||||
token = ""
|
||||
# Telegram API 配置, 若不配置也可运行, 将使用默认的 API ID 和 API HASH
|
||||
# 推荐使用自己的 API ID 和 API HASH (https://my.telegram.org)
|
||||
# app_id = 1025907
|
||||
# app_hash = "452b0359b988148995f22ff0f4229750"
|
||||
|
||||
# 初始化超时时间, 单位: 秒
|
||||
timeout = 60
|
||||
# flood_retry = 5
|
||||
# rpc_retry = 5
|
||||
|
||||
[telegram.proxy]
|
||||
# 启用代理连接 telegram, 只支持 socks5
|
||||
enable = false
|
||||
url = "socks5://127.0.0.1:7890"
|
||||
|
||||
# 用户列表
|
||||
[[users]]
|
||||
# telegram user id
|
||||
id = 114514
|
||||
# 使用黑名单模式,开启后下方留空以使用所有存储,反之则为白名单,白名单请在下方输入允许的存储名
|
||||
blacklist = true
|
||||
# 将列表留空并开启黑名单模式以允许使用所有存储,此处示例为黑名单模式,用户 114514 可使用所有存储
|
||||
storages = []
|
||||
|
||||
[[users]]
|
||||
id = 123456
|
||||
blacklist = false # 使用白名单模式,此时,用户123456 仅可使用下方列表中的存储
|
||||
# 此时该用户只能使用名为 本机1 的存储
|
||||
storages = ["本机1"]
|
||||
|
||||
# 存储列表
|
||||
[[storages]]
|
||||
# 标识名, 需要唯一
|
||||
name = "本机1"
|
||||
# 存储类型, 目前可用: local, alist, webdav, minio
|
||||
# 存储类型, 目前可用: local, alist, webdav, minio, telegram
|
||||
type = "local"
|
||||
# 启用存储
|
||||
enable = true
|
||||
# 文件保存根路径
|
||||
base_path = "./downloads"
|
||||
|
||||
[[storages]]
|
||||
name = "MyAlist"
|
||||
type = "alist"
|
||||
enable = false #记得启用
|
||||
base_path = '/'
|
||||
url = 'https://alist.com'
|
||||
username = 'admin'
|
||||
password = 'password'
|
||||
# alist token 刷新时间
|
||||
# 86400--1天 604800--7天 1296000--15天 2592000--30天 15552000--180天
|
||||
token_exp = 86400
|
||||
# alist 可直接使用 token 登录, 此时 username, password, token_exp 将被忽略
|
||||
# 请自行在 alist 侧配置合理的 token 过期时间
|
||||
# token = ""
|
||||
|
||||
[[storages]]
|
||||
name = "MyWebdav"
|
||||
type = "webdav"
|
||||
@@ -73,28 +38,17 @@ url = 'https://example.com/dav'
|
||||
username = 'username'
|
||||
password = 'password'
|
||||
|
||||
[[storages]]
|
||||
name = "MyMinio"
|
||||
type = "minio"
|
||||
enable = true
|
||||
endpoint = 'play.min.io'
|
||||
use_ssl = true
|
||||
access_key_id = 'Q3AM3UQ867SPQQA43P2F'
|
||||
secret_access_key = 'zuf+tfteSlswRu7BJ86wekitnifILbZam1KYY3TG'
|
||||
bucket_name = 'saveanybot'
|
||||
base_path = '/path/telegram'
|
||||
|
||||
[[storages]]
|
||||
name = "mychannel"
|
||||
type = "telegram"
|
||||
enable = true
|
||||
chat_id = 1820371480
|
||||
|
||||
# [temp]
|
||||
# # 下载文件临时目录, 请不要在此目录下存放任何其他文件
|
||||
# base_path = "cache/"
|
||||
|
||||
# [db]
|
||||
# path = "data/data.db" # 数据库文件路径
|
||||
# session = "data/session.db"
|
||||
# 用户列表
|
||||
[[users]]
|
||||
# telegram user id
|
||||
id = 114514
|
||||
# 存储过滤列表, 元素为存储标识名.
|
||||
# 将该列表留空并开启黑名单过滤模式以允许使用所有存储,此处示例为黑名单模式,用户 114514 可使用所有存储
|
||||
storages = []
|
||||
# 使用列表过滤黑名单模式,反之则为白名单,白名单请在列表中指定可用的存储.
|
||||
blacklist = true
|
||||
|
||||
[[users]]
|
||||
id = 123456
|
||||
storages = ["本机1"]
|
||||
blacklist = false # 使用白名单模式,此时,用户 123456 仅可使用标识名为 '本地1' 的存储
|
||||
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]
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
type TelegramStorageConfig struct {
|
||||
BaseConfig
|
||||
ChatID int64 `toml:"chat_id" mapstructure:"chat_id" json:"chat_id"`
|
||||
ForceFile bool `toml:"force_file" mapstructure:"force_file" json:"force_file"`
|
||||
RateLimit int `toml:"rate_limit" mapstructure:"rate_limit" json:"rate_limit"`
|
||||
RateBurst int `toml:"rate_burst" mapstructure:"rate_burst" json:"rate_burst"`
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ type telegramConfig struct {
|
||||
AppHash string `toml:"app_hash" mapstructure:"app_hash" json:"app_hash"`
|
||||
Proxy tgProxyConfig `toml:"proxy" mapstructure:"proxy"`
|
||||
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 {
|
||||
|
||||
@@ -14,7 +14,7 @@ var userIDs []int64
|
||||
var storages []string
|
||||
var userStorages = make(map[int64][]string)
|
||||
|
||||
func (c *Config) GetStorageNamesByUserID(userID int64) []string {
|
||||
func (c Config) GetStorageNamesByUserID(userID int64) []string {
|
||||
us, ok := userStorages[userID]
|
||||
if ok {
|
||||
return us
|
||||
@@ -22,11 +22,11 @@ func (c *Config) GetStorageNamesByUserID(userID int64) []string {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Config) GetUsersID() []int64 {
|
||||
func (c Config) GetUsersID() []int64 {
|
||||
return userIDs
|
||||
}
|
||||
|
||||
func (c *Config) HasStorage(userID int64, storageName string) bool {
|
||||
func (c Config) HasStorage(userID int64, storageName string) bool {
|
||||
us, ok := userStorages[userID]
|
||||
if !ok {
|
||||
return false
|
||||
|
||||
13
config/version.go
Normal file
13
config/version.go
Normal file
@@ -0,0 +1,13 @@
|
||||
package config
|
||||
|
||||
// inject version by '-X' flag
|
||||
// go build -ldflags "-X github.com/krau/SaveAny-Bot/config.Version=${{ env.VERSION }}"
|
||||
var (
|
||||
Version string = "dev"
|
||||
BuildTime string = "unknown"
|
||||
GitCommit string = "unknown"
|
||||
)
|
||||
|
||||
const (
|
||||
GitRepo = "krau/SaveAny-Bot"
|
||||
)
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/duke-git/lancet/v2/slice"
|
||||
@@ -28,10 +27,15 @@ type Config struct {
|
||||
DB dbConfig `toml:"db" mapstructure:"db"`
|
||||
Telegram telegramConfig `toml:"telegram" mapstructure:"telegram"`
|
||||
Storages []storage.StorageConfig `toml:"-" mapstructure:"-" json:"storages"`
|
||||
Parser parserConfig `toml:"parser" mapstructure:"parser" json:"parser"`
|
||||
Hook hookConfig `toml:"hook" mapstructure:"hook" json:"hook"`
|
||||
}
|
||||
|
||||
var Cfg *Config = &Config{}
|
||||
var cfg = &Config{}
|
||||
|
||||
func C() Config {
|
||||
return *cfg
|
||||
}
|
||||
|
||||
func (c Config) GetStorageByName(name string) storage.StorageConfig {
|
||||
for _, storage := range c.Storages {
|
||||
@@ -91,48 +95,51 @@ func Init(ctx context.Context) error {
|
||||
|
||||
if err := viper.ReadInConfig(); err != nil {
|
||||
fmt.Println("Error reading config file, ", err)
|
||||
os.Exit(1)
|
||||
return err
|
||||
}
|
||||
|
||||
if err := viper.Unmarshal(Cfg); err != nil {
|
||||
if err := viper.Unmarshal(cfg); err != nil {
|
||||
fmt.Println("Error unmarshalling config file, ", err)
|
||||
os.Exit(1)
|
||||
return err
|
||||
}
|
||||
|
||||
storagesConfig, err := storage.LoadStorageConfigs(viper.GetViper())
|
||||
if err != nil {
|
||||
return fmt.Errorf("error loading storage configs: %w", err)
|
||||
}
|
||||
Cfg.Storages = storagesConfig
|
||||
cfg.Storages = storagesConfig
|
||||
|
||||
storageNames := make(map[string]struct{})
|
||||
for _, storage := range Cfg.Storages {
|
||||
for _, storage := range cfg.Storages {
|
||||
if _, ok := storageNames[storage.GetName()]; ok {
|
||||
return errors.New(i18n.TWithoutInit(Cfg.Lang, i18nk.ConfigInvalidDuplicateStorageName, map[string]any{
|
||||
return errors.New(i18n.TWithoutInit(cfg.Lang, i18nk.ConfigErrDuplicateStorageName, map[string]any{
|
||||
"Name": storage.GetName(),
|
||||
}))
|
||||
}
|
||||
storageNames[storage.GetName()] = struct{}{}
|
||||
}
|
||||
|
||||
fmt.Println(i18n.TWithoutInit(Cfg.Lang, i18nk.LoadedStorages, map[string]any{
|
||||
"Count": len(Cfg.Storages),
|
||||
fmt.Println(i18n.TWithoutInit(cfg.Lang, i18nk.ConfigLoadedStorages, map[string]any{
|
||||
"Count": len(cfg.Storages),
|
||||
}))
|
||||
for _, storage := range Cfg.Storages {
|
||||
for _, storage := range cfg.Storages {
|
||||
fmt.Printf(" - %s (%s)\n", storage.GetName(), storage.GetType())
|
||||
}
|
||||
|
||||
if Cfg.Workers < 1 || Cfg.Retry < 1 {
|
||||
return errors.New(i18n.TWithoutInit(Cfg.Lang, i18nk.ConfigInvalidWorkersOrRetry, map[string]any{
|
||||
"Workers": Cfg.Workers,
|
||||
"Retry": Cfg.Retry,
|
||||
}))
|
||||
if cfg.Workers < 1 {
|
||||
cfg.Workers = 1
|
||||
}
|
||||
if cfg.Threads < 1 {
|
||||
cfg.Threads = 1
|
||||
}
|
||||
if cfg.Retry < 1 {
|
||||
cfg.Retry = 1
|
||||
}
|
||||
|
||||
for _, storage := range Cfg.Storages {
|
||||
for _, storage := range cfg.Storages {
|
||||
storages = append(storages, storage.GetName())
|
||||
}
|
||||
for _, user := range Cfg.Users {
|
||||
for _, user := range cfg.Users {
|
||||
userIDs = append(userIDs, user.ID)
|
||||
if user.Blacklist {
|
||||
userStorages[user.ID] = slice.Compact(slice.Difference(storages, user.Storages))
|
||||
@@ -142,20 +149,3 @@ func Init(ctx context.Context) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func Set(key string, value any) {
|
||||
viper.Set(key, value)
|
||||
}
|
||||
|
||||
func ReloadConfig() error {
|
||||
if err := viper.WriteConfig(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := viper.ReadInConfig(); err != nil {
|
||||
return err
|
||||
}
|
||||
if error := viper.Unmarshal(Cfg); error != nil {
|
||||
return error
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ type Exectable interface {
|
||||
|
||||
func worker(ctx context.Context, qe *queue.TaskQueue[Exectable], semaphore chan struct{}) {
|
||||
logger := log.FromContext(ctx)
|
||||
execHooks := config.Cfg.Hook.Exec
|
||||
execHooks := config.C().Hook.Exec
|
||||
for {
|
||||
semaphore <- struct{}{}
|
||||
qtask, err := qe.Get()
|
||||
@@ -58,11 +58,11 @@ func worker(ctx context.Context, qe *queue.TaskQueue[Exectable], semaphore chan
|
||||
|
||||
func Run(ctx context.Context) {
|
||||
log.FromContext(ctx).Info("Start processing tasks...")
|
||||
semaphore := make(chan struct{}, config.Cfg.Workers)
|
||||
semaphore := make(chan struct{}, config.C().Workers)
|
||||
if queueInstance == nil {
|
||||
queueInstance = queue.NewTaskQueue[Exectable]()
|
||||
}
|
||||
for range config.Cfg.Workers {
|
||||
for range config.C().Workers {
|
||||
go worker(ctx, queueInstance, semaphore)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package batchtftask
|
||||
package batchtfile
|
||||
|
||||
import (
|
||||
"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.Info("Starting batch file task")
|
||||
t.Progress.OnStart(ctx, t)
|
||||
workers := config.Cfg.Workers
|
||||
workers := config.C().Workers
|
||||
eg, gctx := errgroup.WithContext(ctx)
|
||||
eg.SetLimit(workers)
|
||||
for _, elem := range t.Elems {
|
||||
elem := elem
|
||||
eg.Go(func() error {
|
||||
t.processingMu.RLock()
|
||||
if t.processing[elem.ID] != nil {
|
||||
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.processingMu.Unlock()
|
||||
defer func() {
|
||||
t.processingMu.Lock()
|
||||
delete(t.processing, elem.ID)
|
||||
t.processingMu.Unlock()
|
||||
}()
|
||||
return t.processElement(gctx, elem)
|
||||
})
|
||||
@@ -61,10 +66,12 @@ func (t *Task) processElement(ctx context.Context, elem TaskElement) error {
|
||||
t.Progress.OnProgress(ctx, t)
|
||||
})
|
||||
errg.Go(func() error {
|
||||
defer pw.Close()
|
||||
logger.Info("Starting file download in stream mode")
|
||||
_, err := tfile.NewDownloader(elem.File).Stream(uploadCtx, wr)
|
||||
if closeErr := pw.CloseWithError(err); closeErr != nil {
|
||||
logger.Errorf("Failed to close pipe writer: %v", closeErr)
|
||||
if err != nil {
|
||||
logger.Errorf("Failed to download file: %v", err)
|
||||
pw.CloseWithError(err)
|
||||
}
|
||||
return err
|
||||
})
|
||||
@@ -117,6 +124,6 @@ func (t *Task) processElement(ctx context.Context, elem TaskElement) error {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}, retry.Context(vctx), retry.RetryTimes(uint(config.Cfg.Retry)))
|
||||
}, retry.Context(vctx), retry.RetryTimes(uint(config.C().Retry)))
|
||||
return err
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package batchtftask
|
||||
package batchtfile
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -1,9 +1,10 @@
|
||||
package batchtftask
|
||||
package batchtfile
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/krau/SaveAny-Bot/config"
|
||||
@@ -31,7 +32,8 @@ type Task struct {
|
||||
downloaded atomic.Int64
|
||||
totalSize int64
|
||||
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 {
|
||||
@@ -45,8 +47,8 @@ func NewTaskElement(
|
||||
) (*TaskElement, error) {
|
||||
id := xid.New().String()
|
||||
_, ok := stor.(storage.StorageCannotStream)
|
||||
if !config.Cfg.Stream || ok {
|
||||
cachePath, err := filepath.Abs(filepath.Join(config.Cfg.Temp.BasePath, fmt.Sprintf("%s_%s", id, file.Name())))
|
||||
if !config.C().Stream || ok {
|
||||
cachePath, err := filepath.Abs(filepath.Join(config.C().Temp.BasePath, fmt.Sprintf("%s_%s", id, file.Name())))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get absolute path for cache: %w", err)
|
||||
}
|
||||
@@ -89,6 +91,7 @@ func NewBatchTGFileTask(
|
||||
}(),
|
||||
processing: make(map[string]TaskElementInfo),
|
||||
IgnoreErrors: ignoreErrors,
|
||||
processingMu: sync.RWMutex{},
|
||||
failed: make(map[string]error),
|
||||
}
|
||||
return task
|
||||
@@ -1,4 +1,4 @@
|
||||
package batchtftask
|
||||
package batchtfile
|
||||
|
||||
type TaskElementInfo interface {
|
||||
FileName() string
|
||||
@@ -1,4 +1,4 @@
|
||||
package batchtftask
|
||||
package batchtfile
|
||||
|
||||
var progressUpdatesLevels = []struct {
|
||||
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,
|
||||
}
|
||||
}
|
||||
80
core/tasks/parsed/task.go
Normal file
80
core/tasks/parsed/task.go
Normal file
@@ -0,0 +1,80 @@
|
||||
package parsed
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/krau/SaveAny-Bot/common/utils/netutil"
|
||||
"github.com/krau/SaveAny-Bot/config"
|
||||
"github.com/krau/SaveAny-Bot/pkg/enums/tasktype"
|
||||
"github.com/krau/SaveAny-Bot/pkg/parser"
|
||||
"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 := netutil.DefaultParserHTTPClient()
|
||||
_, 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 (
|
||||
"context"
|
||||
@@ -20,10 +20,8 @@ func (t *Task) Execute(ctx context.Context) error {
|
||||
logger.Infof("Starting Telegraph task %s", t.PhPath)
|
||||
t.progress.OnStart(ctx, t)
|
||||
eg, gctx := errgroup.WithContext(ctx)
|
||||
eg.SetLimit(config.Cfg.Workers)
|
||||
eg.SetLimit(config.C().Workers)
|
||||
for i, pic := range t.Pics {
|
||||
pic := pic
|
||||
i := i
|
||||
eg.Go(func() error {
|
||||
err := t.processPic(gctx, pic, i)
|
||||
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 {
|
||||
retryOpts := []retry.Option{
|
||||
retry.Context(ctx),
|
||||
retry.RetryTimes(uint(config.Cfg.Retry)),
|
||||
retry.RetryTimes(uint(config.C().Retry)),
|
||||
}
|
||||
var lastErr error
|
||||
err := retry.Retry(func() error {
|
||||
@@ -61,7 +59,7 @@ func (t *Task) processPic(ctx context.Context, picUrl string, index int) error {
|
||||
defer body.Close()
|
||||
filename := fmt.Sprintf("%d%s", index+1, path.Ext(picUrl))
|
||||
if t.cannotStream {
|
||||
cacheFile, err := fsutil.CreateFile(filepath.Join(config.Cfg.Temp.BasePath,
|
||||
cacheFile, err := fsutil.CreateFile(filepath.Join(config.C().Temp.BasePath,
|
||||
fmt.Sprintf("tph_%s_%s", t.TaskID(), filename),
|
||||
))
|
||||
if err != nil {
|
||||
@@ -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)
|
||||
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))
|
||||
} else {
|
||||
lastErr = t.Stor.Save(ctx, body, path.Join(t.StorPath, filename))
|
||||
@@ -1,4 +1,4 @@
|
||||
package tphtask
|
||||
package telegraph
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -1,4 +1,4 @@
|
||||
package tphtask
|
||||
package telegraph
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -39,7 +39,7 @@ func NewTask(
|
||||
progress ProgressTracker,
|
||||
) *Task {
|
||||
_, cannotStream := stor.(storage.StorageCannotStream)
|
||||
tphtask := &Task{
|
||||
telegraph := &Task{
|
||||
ID: id,
|
||||
Ctx: ctx,
|
||||
PhPath: phPath,
|
||||
@@ -52,5 +52,5 @@ func NewTask(
|
||||
totalpics: len(pics),
|
||||
downloaded: atomic.Int64{},
|
||||
}
|
||||
return tphtask
|
||||
return telegraph
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package tphtask
|
||||
package telegraph
|
||||
|
||||
type TaskInfo interface {
|
||||
TaskID() string
|
||||
@@ -1,4 +1,4 @@
|
||||
package tphtask
|
||||
package telegraph
|
||||
|
||||
func shouldUpdateProgress(downloaded int64, total int64) bool {
|
||||
if total <= 0 || downloaded <= 0 {
|
||||
@@ -1,4 +1,4 @@
|
||||
package tftask
|
||||
package tfile
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -16,7 +16,9 @@ import (
|
||||
|
||||
func (t *Task) Execute(ctx context.Context) error {
|
||||
logger := log.FromContext(ctx).WithPrefix(fmt.Sprintf("file[%s]", t.File.Name()))
|
||||
t.Progress.OnStart(ctx, t)
|
||||
if t.Progress != nil {
|
||||
t.Progress.OnStart(ctx, t)
|
||||
}
|
||||
if t.stream {
|
||||
return executeStream(ctx, t)
|
||||
}
|
||||
@@ -34,7 +36,9 @@ func (t *Task) Execute(ctx context.Context) error {
|
||||
wrAt := newWriterAt(ctx, localFile, t.Progress, t)
|
||||
|
||||
defer func() {
|
||||
t.Progress.OnDone(ctx, t, err)
|
||||
if t.Progress != nil {
|
||||
t.Progress.OnDone(ctx, t, err)
|
||||
}
|
||||
}()
|
||||
_, err = tfile.NewDownloader(t.File).Parallel(ctx, wrAt)
|
||||
if err != nil {
|
||||
@@ -53,7 +57,7 @@ func (t *Task) Execute(ctx context.Context) error {
|
||||
return fmt.Errorf("failed to get file stat: %w", err)
|
||||
}
|
||||
vctx := context.WithValue(ctx, ctxkey.ContentLength, fileStat.Size())
|
||||
for i := range config.Cfg.Retry + 1 {
|
||||
for i := range config.C().Retry + 1 {
|
||||
if err = vctx.Err(); err != nil {
|
||||
return fmt.Errorf("context canceled while saving file: %w", err)
|
||||
}
|
||||
@@ -64,7 +68,7 @@ func (t *Task) Execute(ctx context.Context) error {
|
||||
}
|
||||
defer file.Close()
|
||||
if err = t.Storage.Save(vctx, file, t.Path); err != nil {
|
||||
if i == config.Cfg.Retry {
|
||||
if i == config.C().Retry {
|
||||
return fmt.Errorf("failed to save file: %w", err)
|
||||
}
|
||||
logger.Errorf("Failed to save file: %s, retrying...", err)
|
||||
@@ -1,4 +1,4 @@
|
||||
package tftask
|
||||
package tfile
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -1,4 +1,4 @@
|
||||
package tftask
|
||||
package tfile
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -21,16 +21,20 @@ func executeStream(ctx context.Context, task *Task) error {
|
||||
})
|
||||
wr := newWriter(ctx, pw, task.Progress, task)
|
||||
errg.Go(func() error {
|
||||
defer pw.Close()
|
||||
logger.Info("Starting file download in stream mode")
|
||||
_, err := tfile.NewDownloader(task.File).Stream(uploadCtx, wr)
|
||||
if closeErr := pw.CloseWithError(err); closeErr != nil {
|
||||
logger.Errorf("Failed to close pipe writer: %v", closeErr)
|
||||
if err != nil {
|
||||
logger.Errorf("Failed to download file: %v", err)
|
||||
pw.CloseWithError(err)
|
||||
}
|
||||
return err
|
||||
})
|
||||
var err error
|
||||
defer func() {
|
||||
task.Progress.OnDone(ctx, task, err)
|
||||
if task.Progress != nil {
|
||||
task.Progress.OnDone(ctx, task, err)
|
||||
}
|
||||
}()
|
||||
if err = errg.Wait(); err != nil {
|
||||
return err
|
||||
@@ -1,4 +1,4 @@
|
||||
package tftask
|
||||
package tfile
|
||||
|
||||
type TaskInfo interface {
|
||||
TaskID() string
|
||||
@@ -1,4 +1,4 @@
|
||||
package tftask
|
||||
package tfile
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -35,12 +35,12 @@ func NewTGFileTask(
|
||||
progress ProgressTracker,
|
||||
) (*Task, error) {
|
||||
_, ok := stor.(storage.StorageCannotStream)
|
||||
if !config.Cfg.Stream || ok {
|
||||
cachePath, err := filepath.Abs(filepath.Join(config.Cfg.Temp.BasePath, fmt.Sprintf("%s_%s", id, file.Name())))
|
||||
if !config.C().Stream || ok {
|
||||
cachePath, err := filepath.Abs(filepath.Join(config.C().Temp.BasePath, fmt.Sprintf("%s_%s", id, file.Name())))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get absolute path for cache: %w", err)
|
||||
}
|
||||
tftask := &Task{
|
||||
tfile := &Task{
|
||||
ID: id,
|
||||
Ctx: ctx,
|
||||
File: file,
|
||||
@@ -49,7 +49,7 @@ func NewTGFileTask(
|
||||
Progress: progress,
|
||||
localPath: cachePath,
|
||||
}
|
||||
return tftask, nil
|
||||
return tfile, nil
|
||||
}
|
||||
tfileTask := &Task{
|
||||
ID: id,
|
||||
@@ -1,4 +1,4 @@
|
||||
package tftask
|
||||
package tfile
|
||||
|
||||
var progressUpdatesLevels = []struct {
|
||||
size int64 // 文件大小阈值
|
||||
@@ -1,4 +1,4 @@
|
||||
package tftask
|
||||
package tfile
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -20,7 +20,9 @@ func (w *ProgressWriterAt) WriteAt(p []byte, off int64) (int, error) {
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
w.progress.OnProgress(w.ctx, w.info, w.downloaded.Add(int64(at)), w.total)
|
||||
if w.progress != nil {
|
||||
w.progress.OnProgress(w.ctx, w.info, w.downloaded.Add(int64(at)), w.total)
|
||||
}
|
||||
return at, nil
|
||||
}
|
||||
|
||||
@@ -54,7 +56,9 @@ func (w *ProgressWriter) Write(p []byte) (int, error) {
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
w.progress.OnProgress(w.ctx, w.info, w.downloaded.Add(int64(at)), w.total)
|
||||
if w.progress != nil {
|
||||
w.progress.OnProgress(w.ctx, w.info, w.downloaded.Add(int64(at)), w.total)
|
||||
}
|
||||
return at, nil
|
||||
}
|
||||
|
||||
39
database/chat.go
Normal file
39
database/chat.go
Normal file
@@ -0,0 +1,39 @@
|
||||
package database
|
||||
|
||||
import "context"
|
||||
|
||||
func (user *User) WatchChat(ctx context.Context, chat WatchChat) error {
|
||||
if len(user.WatchChats) == 0 {
|
||||
user.WatchChats = make([]WatchChat, 0)
|
||||
}
|
||||
|
||||
user.WatchChats = append(user.WatchChats, chat)
|
||||
return db.WithContext(ctx).Save(user.WatchChats).Error
|
||||
}
|
||||
|
||||
func (user *User) UnwatchChat(ctx context.Context, chatID int64) error {
|
||||
var watchChat WatchChat
|
||||
err := db.WithContext(ctx).Where("chat_id = ? AND user_id = ?", chatID, user.ID).First(&watchChat).Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return db.WithContext(ctx).Unscoped().Delete(&watchChat).Error
|
||||
}
|
||||
|
||||
func (user *User) WatchingChat(ctx context.Context, chatID int64) (bool, error) {
|
||||
var count int64
|
||||
err := db.WithContext(ctx).Model(&WatchChat{}).Where("chat_id = ? AND user_id = ?", chatID, user.ID).Count(&count).Error
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
func GetWatchChatsByChatID(ctx context.Context, chatID int64) ([]*WatchChat, error) {
|
||||
var watchChats []*WatchChat
|
||||
err := db.WithContext(ctx).Where("chat_id = ?", chatID).Find(&watchChats).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return watchChats, nil
|
||||
}
|
||||
@@ -19,11 +19,11 @@ var db *gorm.DB
|
||||
|
||||
func Init(ctx context.Context) {
|
||||
logger := log.FromContext(ctx)
|
||||
if err := os.MkdirAll(filepath.Dir(config.Cfg.DB.Path), 0755); err != nil {
|
||||
if err := os.MkdirAll(filepath.Dir(config.C().DB.Path), 0755); err != nil {
|
||||
logger.Fatal("Failed to create data directory: ", err)
|
||||
}
|
||||
var err error
|
||||
db, err = gorm.Open(gormlite.Open(config.Cfg.DB.Path), &gorm.Config{
|
||||
db, err = gorm.Open(gormlite.Open(config.C().DB.Path), &gorm.Config{
|
||||
Logger: glogger.New(logger, glogger.Config{
|
||||
Colorful: true,
|
||||
SlowThreshold: time.Second * 5,
|
||||
@@ -37,7 +37,7 @@ func Init(ctx context.Context) {
|
||||
logger.Fatal("Failed to open database: ", err)
|
||||
}
|
||||
logger.Debug("Database connected")
|
||||
if err := db.AutoMigrate(&User{}, &Dir{}, &Rule{}); err != nil {
|
||||
if err := db.AutoMigrate(&User{}, &Dir{}, &Rule{}, &WatchChat{}); err != nil {
|
||||
logger.Fatal("迁移数据库失败, 如果您从旧版本升级, 建议手动删除数据库文件后重试: ", err)
|
||||
}
|
||||
if err := syncUsers(ctx); err != nil {
|
||||
@@ -60,7 +60,7 @@ func syncUsers(ctx context.Context) error {
|
||||
}
|
||||
|
||||
cfgUserMap := make(map[int64]struct{})
|
||||
for _, u := range config.Cfg.Users {
|
||||
for _, u := range config.C().Users {
|
||||
cfgUserMap[u.ID] = struct{}{}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,12 +6,22 @@ import (
|
||||
|
||||
type User struct {
|
||||
gorm.Model
|
||||
ChatID int64 `gorm:"uniqueIndex;not null"`
|
||||
Silent bool
|
||||
DefaultStorage string
|
||||
Dirs []Dir
|
||||
ApplyRule bool
|
||||
Rules []Rule
|
||||
ChatID int64 `gorm:"uniqueIndex;not null"`
|
||||
Silent bool
|
||||
DefaultStorage string
|
||||
Dirs []Dir
|
||||
ApplyRule bool
|
||||
Rules []Rule
|
||||
WatchChats []WatchChat
|
||||
FilenameStrategy string
|
||||
FilenameTemplate string
|
||||
}
|
||||
|
||||
type WatchChat struct {
|
||||
gorm.Model
|
||||
UserID uint // User's database ID (not chat ID)
|
||||
ChatID int64
|
||||
Filter string
|
||||
}
|
||||
|
||||
type Dir struct {
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
package database
|
||||
|
||||
import "context"
|
||||
import (
|
||||
"context"
|
||||
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
func CreateUser(ctx context.Context, chatID int64) error {
|
||||
if _, err := GetUserByChatID(ctx, chatID); err == nil {
|
||||
@@ -11,19 +15,16 @@ func CreateUser(ctx context.Context, chatID int64) error {
|
||||
|
||||
func GetAllUsers(ctx context.Context) ([]User, error) {
|
||||
var users []User
|
||||
err := db.Preload("Dirs").
|
||||
WithContext(ctx).
|
||||
Preload("Rules").
|
||||
err := db.WithContext(ctx).
|
||||
Preload(clause.Associations).
|
||||
Find(&users).Error
|
||||
return users, err
|
||||
}
|
||||
|
||||
func GetUserByChatID(ctx context.Context, chatID int64) (*User, error) {
|
||||
var user User
|
||||
err := db.
|
||||
Preload("Dirs").
|
||||
WithContext(ctx).
|
||||
Preload("Rules").
|
||||
err := db.WithContext(ctx).
|
||||
Preload(clause.Associations).
|
||||
Where("chat_id = ?", chatID).First(&user).Error
|
||||
return &user, err
|
||||
}
|
||||
@@ -36,5 +37,16 @@ func UpdateUser(ctx context.Context, user *User) error {
|
||||
}
|
||||
|
||||
func DeleteUser(ctx context.Context, user *User) error {
|
||||
return db.WithContext(ctx).Unscoped().Select("Dirs", "Rules").Delete(user).Error
|
||||
return db.WithContext(ctx).
|
||||
Unscoped().
|
||||
Select(clause.Associations).
|
||||
Delete(user).Error
|
||||
}
|
||||
|
||||
func GetUserByID(ctx context.Context, id uint) (*User, error) {
|
||||
var user User
|
||||
err := db.WithContext(ctx).
|
||||
Preload(clause.Associations).
|
||||
Where("id = ?", id).First(&user).Error
|
||||
return &user, err
|
||||
}
|
||||
@@ -33,7 +33,7 @@ After=systemd-user-sessions.service
|
||||
Type=simple
|
||||
WorkingDirectory=/yourpath/
|
||||
ExecStart=/yourpath/saveany-bot
|
||||
Restart=on-failure
|
||||
Restart=always
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
||||
@@ -11,20 +11,22 @@ title: 介绍
|
||||
|
||||
把 Telegram 上的文件转存到多种存储端.
|
||||
|
||||
## 特性
|
||||
## 🎯 特性
|
||||
|
||||
- 支持文档/视频/图片/贴纸... 甚至还有 Telegraph
|
||||
- 支持文档/视频/图片/贴纸…甚至还有 [Telegraph](https://telegra.ph/)
|
||||
- 破解禁止保存的文件
|
||||
- 批量下载
|
||||
- 流式传输
|
||||
- 多用户
|
||||
- 多用户使用
|
||||
- 基于存储规则的自动整理
|
||||
- 支持多种存储端:
|
||||
- Alist
|
||||
- Minio (S3 兼容)
|
||||
- WebDAV
|
||||
- Telegram (重传回指定聊天)
|
||||
- 本地磁盘
|
||||
- 监听并自动转存指定聊天的消息, 支持过滤
|
||||
- 使用 js 编写解析器插件以转存任意网站的文件
|
||||
- 存储端支持:
|
||||
- Alist
|
||||
- S3 (MinioSDK)
|
||||
- WebDAV
|
||||
- 本地磁盘
|
||||
- Telegram (重传回指定聊天)
|
||||
|
||||
## [贡献者](https://github.com/krau/SaveAny-Bot/graphs/contributors)
|
||||
|
||||
|
||||
@@ -5,10 +5,30 @@ weight: 20
|
||||
|
||||
# 参与开发
|
||||
|
||||
在开始之前, 请 Fork 本项目, 并克隆到本地, 并确保 Go 版本 >= 1.23.
|
||||
|
||||
以下是一些贡献代码的指南或建议, 你不必完全遵守, 但将有助于快速 review 并合并你的提交:
|
||||
|
||||
- **新功能请先提交 Issue**, 以便讨论设计和实现细节, 并避免因与项目设计不符而被拒绝.
|
||||
- **使用现代开发工具**, 确保提交前格式化代码, 并保持风格一致.
|
||||
- **使用[语义化提交](https://www.conventionalcommits.org/zh-hans/v1.0.0/)**, 避免提交消息模糊或过于简单.
|
||||
|
||||
## 贡献新存储端
|
||||
|
||||
1. Fork 本项目, 克隆到本地
|
||||
2. 在 `pkg/enums/storage/storages.go` 中添加新的存储端类型, 并运行代码生成
|
||||
3. 在 `config/storage` 目录下定义存储端配置, 并添加到 `config/storage/factory.go` 中
|
||||
4. 在 `storage` 目录下新建一个包, 编写存储端实现, 然后在 `storage/storage.go` 中导入并添加它
|
||||
5. 更新文档, 添加配置说明
|
||||
1. 在 `pkg/enums/storage/storages.go` 中添加新的存储端类型, 并运行代码生成
|
||||
2. 在 `config/storage` 目录下定义存储端配置, 并添加到 `config/storage/factory.go` 中
|
||||
3. 在 `storage` 目录下新建一个包, 编写存储端实现, 然后在 `storage/storage.go` 中导入并添加它
|
||||
4. 更新文档, 添加配置说明
|
||||
|
||||
## 贡献新解析器
|
||||
|
||||
你可以选择使用 Go 编写原生的解析器实现(推荐), 或是使用 JavaScript 以插件的方式实现.
|
||||
|
||||
如果使用 Go 编写, 请:
|
||||
|
||||
1. 在 `parsers` 目录下新建一个包, 编写解析器实现
|
||||
2. 在 `parsers/parser.go` 的 `init` 中注册解析器
|
||||
|
||||
如果使用 JavaScript 编写, 请参考 `plugins/example_parser_basic.js` 的实现, 并在该文件夹下新建一个 js 文件, 实现你的解析逻辑.
|
||||
|
||||
需要注意, `plugins` 目录下解析器默认不会被编译到二进制文件中, 用户需要手动下载它们并放到本地指定目录下以启用它们.
|
||||
@@ -51,9 +51,20 @@ Stream 模式对于磁盘空间有限的部署环境十分有用, 但也有一
|
||||
- `app_id`, `app_hash`: Telegram API ID & Hash, 在 [Telegram API](https://my.telegram.org/apps) 创建应用获取, 若不提供则使用默认值.
|
||||
- `flood_retry`: Flood 控制重试次数, 默认为 5.
|
||||
- `rpc_retry`: RPC 请求重试次数, 默认为 5.
|
||||
- `proxy`: 代理配置, 可选项.
|
||||
- `proxy`: 代理配置, 可选.
|
||||
- `enable`: 是否启用代理.
|
||||
- `url`: 代理地址, 只支持 `socks5://`
|
||||
- `userbot`: userbot 配置, 可选.
|
||||
- `enable`: 启用 userbot 集成, 需要登录用户账号, 此时请务必使用自己的 api id & hash.
|
||||
- `session`: userbot 会话文件路径, 默认为 `data/usersession.db`.
|
||||
|
||||
{{< hint warning >}}
|
||||
启用 userbot 集成后, bot 可以下载私密频道和群组的文件, 但具有无法避免的账号被封禁的风险.
|
||||
<br />
|
||||
开启 userbot 集成后第一次启动 bot 时需要通过终端交互输入手机号, 2FA 和验证码.
|
||||
<br />
|
||||
如果你使用 docker 部署, 请使用 -it 参数为容器提供交互式环境, 然后执行登录操作.
|
||||
{{< /hint >}}
|
||||
|
||||
```toml
|
||||
[telegram]
|
||||
@@ -65,6 +76,9 @@ rpc_retry = 5
|
||||
[telegram.proxy]
|
||||
enable = false
|
||||
url = "socks5://127.0.0.1:7890"
|
||||
[telegram.userbot]
|
||||
enable = false
|
||||
session = "data/usersession.db"
|
||||
```
|
||||
|
||||
### 存储端列表
|
||||
@@ -152,6 +166,18 @@ task_fail = "curl -X POST https://example.com/api/notify -d '任务失败'"
|
||||
task_cancel = "bash /path/to/cancel_script.sh"
|
||||
```
|
||||
|
||||
### 解析器
|
||||
|
||||
解析器为 Bot 提供了处理非 Telegram 文件的能力, 例如从其他网站下载文件. 使用 `[parsers]` 配置.
|
||||
|
||||
```toml
|
||||
[parsers]
|
||||
plugin_enable = true # 是否启用解析器插件
|
||||
plugin_dirs = ["./plugins"] # 插件目录, 可以是多个目录
|
||||
```
|
||||
|
||||
上述两个配置项只用于控制以 JavaScript 编写的解析器插件, Bot 还有内置的使用 Go 实现的解析器, 目前默认开启.
|
||||
|
||||
### 杂项
|
||||
|
||||
```toml
|
||||
|
||||
@@ -4,7 +4,7 @@ title: "安装与更新"
|
||||
|
||||
# 安装与更新
|
||||
|
||||
## 从预编译文件部署
|
||||
## 从预编译文件部署(推荐)
|
||||
|
||||
在 [Release](https://github.com/krau/SaveAny-Bot/releases) 页面下载对应平台的二进制文件.
|
||||
|
||||
@@ -33,7 +33,7 @@ After=systemd-user-sessions.service
|
||||
Type=simple
|
||||
WorkingDirectory=/yourpath/
|
||||
ExecStart=/yourpath/saveany-bot
|
||||
Restart=on-failure
|
||||
Restart=always
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -131,13 +131,13 @@ docker run -d --name saveany-bot \
|
||||
|
||||
## 更新
|
||||
|
||||
使用 `upgrade` 或 `up` 升级到最新版
|
||||
向 Bot 发送 `/update` 指令检查更新并升级, 或者使用 CLI 命令更新:
|
||||
|
||||
```bash
|
||||
./saveany-bot upgrade
|
||||
./saveany-bot up
|
||||
```
|
||||
|
||||
如果是 Docker 部署, 使用以下命令更新:
|
||||
如果是 Docker 部署, 还可以使用以下命令更新:
|
||||
|
||||
```bash
|
||||
docker pull ghcr.io/krau/saveany-bot:latest
|
||||
|
||||
@@ -9,12 +9,11 @@ weight: 10
|
||||
|
||||
## 转存文件
|
||||
|
||||
Bot 接受两种消息: 文件和链接.
|
||||
要使用 Bot 的转存 Telegram 文件功能, 需要向 Bot 发送或转发以下类型的消息.
|
||||
|
||||
对于链接, 目前支持以下类型的链接:
|
||||
|
||||
1. Telegram 消息链接, 例如: `https://t.me/acherkrau/1097`. **即使频道禁止了转发和保存, Bot 依然可以下载其文件.**
|
||||
2. Telegra.ph 的文章链接, Bot 将下载其中的所有图片
|
||||
1. 文件或媒体消息, 如图片, 视频, 文档等
|
||||
2. Telegram 消息链接, 例如: `https://t.me/acherkrau/1097`. **即使频道禁止了转发和保存, Bot 依然可以下载其文件.**
|
||||
3. Telegra.ph 的文章链接, Bot 将下载其中的所有图片
|
||||
|
||||
## 静默模式 (silent)
|
||||
|
||||
@@ -26,7 +25,6 @@ Bot 接受两种消息: 文件和链接.
|
||||
|
||||
在开启静默模式之前, 需要使用 `/storage` 命令设置默认保存位置.
|
||||
|
||||
|
||||
## 存储规则
|
||||
|
||||
允许你为 Bot 在上传文件到存储时设置一些重定向规则, 用于自动整理所保存的文件.
|
||||
@@ -37,6 +35,7 @@ Bot 接受两种消息: 文件和链接.
|
||||
|
||||
1. FILENAME-REGEX
|
||||
2. MESSAGE-REGEX
|
||||
3. IS-ALBUM
|
||||
|
||||
添加规则的基本语法:
|
||||
|
||||
@@ -50,7 +49,7 @@ Bot 接受两种消息: 文件和链接.
|
||||
|
||||
此外, 规则中的存储名若使用 "CHOSEN" , 则表示存储到点击按钮选择的存储端的路径下
|
||||
|
||||
规则介绍:
|
||||
规则类型:
|
||||
|
||||
### FILENAME-REGEX
|
||||
|
||||
@@ -64,4 +63,62 @@ FILENAME-REGEX (?i)\.(mp4|mkv|ts|avi|flv)$ MyAlist /视频
|
||||
|
||||
### MESSAGE-REGEX
|
||||
|
||||
同上, 但是是根据消息本身的文本内容正则匹配
|
||||
同上, 但是是根据消息本身的文本内容正则匹配
|
||||
|
||||
### IS-ALBUM
|
||||
|
||||
匹配相册消息 (media group), 规则内容只能为 `true` 或 `false`.
|
||||
|
||||
规则中的路径若使用 "NEW-FOR-ALBUM" , 则表示为该组消息新建一个文件夹来存储它们. 见: https://github.com/krau/SaveAny-Bot/issues/87
|
||||
|
||||
例如:
|
||||
|
||||
```
|
||||
IS-ALBUM true MyWebdav NEW-FOR-ALBUM
|
||||
```
|
||||
|
||||
这将会把以 media group 形式发送的消息保存到名为 MyWebdav 的存储下, 并为每个相册新建一个文件夹(由第一个文件生成)来存储它们.
|
||||
|
||||
|
||||
## 监听聊天
|
||||
|
||||
{{< hint warning >}}
|
||||
该功能需开启 UserBot 集成.
|
||||
{{< /hint >}}
|
||||
|
||||
监听指定聊天的消息, 并自动保存到默认存储中, 遵从存储规则, 并且可以设置过滤器来只保存匹配的消息.
|
||||
|
||||
监听聊天:
|
||||
|
||||
```
|
||||
/watch <chat_id/username> [filter]
|
||||
```
|
||||
|
||||
取消监听:
|
||||
|
||||
```
|
||||
/unwatch <chat_id/username>
|
||||
```
|
||||
|
||||
过滤器类型:
|
||||
|
||||
### msgre
|
||||
|
||||
正则匹配消息文本, 例如:
|
||||
|
||||
```
|
||||
/watch 12345678 msgre:.*hello.*
|
||||
```
|
||||
|
||||
这将会监听 ID 为 12345678 的聊天, 并且只保存消息文本中包含 "hello" 的消息.
|
||||
|
||||
## 转存 Telegram 之外的文件
|
||||
|
||||
除了 Telegram 上的文件, Bot 还可通过 JavaScript 插件或内置解析器来支持转存其他网站的文件.
|
||||
|
||||
> 查看[贡献解析器](../contribute)文档了解详情
|
||||
|
||||
只需向 Bot 发送符合解析器要求的链接即可使用, 当前内置的解析器:
|
||||
|
||||
- Twitter
|
||||
- Kemono
|
||||
18
entrypoint.sh
Normal file
18
entrypoint.sh
Normal file
@@ -0,0 +1,18 @@
|
||||
#!/bin/sh
|
||||
|
||||
if [ -n "$CONFIG_URL" ]; then
|
||||
echo "[INFO] Downloading config from $CONFIG_URL"
|
||||
if curl -sSLo /app/config.toml "$CONFIG_URL"; then
|
||||
echo "[INFO] Configuration downloaded successfully"
|
||||
else
|
||||
echo "[ERROR] Failed to download config from $CONFIG_URL"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ ! -f /app/config.toml ]; then
|
||||
echo "[ERROR] Missing config.toml: 请通过挂载或 CONFIG_URL 提供配置文件"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
exec /app/saveany-bot
|
||||
115
go.mod
115
go.mod
@@ -1,129 +1,134 @@
|
||||
module github.com/krau/SaveAny-Bot
|
||||
|
||||
go 1.23.5
|
||||
go 1.24.0
|
||||
|
||||
require (
|
||||
github.com/blang/semver v3.5.1+incompatible
|
||||
github.com/celestix/gotgproto v1.0.0-beta21
|
||||
github.com/celestix/gotgproto v1.0.0-beta22
|
||||
github.com/cenkalti/backoff/v4 v4.3.0
|
||||
github.com/charmbracelet/huh v0.7.0
|
||||
github.com/charmbracelet/huh v0.8.0
|
||||
github.com/charmbracelet/log v0.4.2
|
||||
github.com/fatih/color v1.18.0
|
||||
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/td v0.125.0
|
||||
github.com/minio/minio-go/v7 v7.0.94
|
||||
github.com/gabriel-vasile/mimetype v1.4.10
|
||||
github.com/goccy/go-yaml v1.18.0
|
||||
github.com/gotd/contrib v0.21.1
|
||||
github.com/gotd/td v0.132.0
|
||||
github.com/minio/minio-go/v7 v7.0.95
|
||||
github.com/rhysd/go-github-selfupdate v1.2.3
|
||||
github.com/rs/xid v1.6.0
|
||||
github.com/spf13/cobra v1.9.1
|
||||
github.com/spf13/viper v1.20.1
|
||||
golang.org/x/net v0.41.0
|
||||
golang.org/x/time v0.12.0
|
||||
github.com/spf13/cobra v1.10.1
|
||||
github.com/spf13/viper v1.21.0
|
||||
golang.org/x/net v0.46.0
|
||||
golang.org/x/time v0.14.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/AnimeKaizoku/cacher v1.0.2 // indirect
|
||||
github.com/AnimeKaizoku/cacher v1.0.3 // indirect
|
||||
github.com/atotto/clipboard v0.1.4 // indirect
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||
github.com/catppuccin/go v0.3.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/charmbracelet/bubbles v0.21.0 // indirect
|
||||
github.com/charmbracelet/bubbletea v1.3.5 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.3.1 // indirect
|
||||
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 // indirect
|
||||
github.com/charmbracelet/bubbletea v1.3.10 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.3.2 // indirect
|
||||
github.com/charmbracelet/lipgloss v1.1.0 // indirect
|
||||
github.com/charmbracelet/x/ansi v0.9.3 // indirect
|
||||
github.com/charmbracelet/x/ansi v0.10.2 // indirect
|
||||
github.com/charmbracelet/x/cellbuf v0.0.13 // indirect
|
||||
github.com/charmbracelet/x/exp/strings v0.0.0-20250629123816-066ae234febc // indirect
|
||||
github.com/charmbracelet/x/exp/strings v0.0.0-20251023181713-f594ac034d6b // indirect
|
||||
github.com/charmbracelet/x/term v0.2.1 // indirect
|
||||
github.com/coder/websocket v1.8.13 // indirect
|
||||
github.com/clipperhouse/uax29/v2 v2.2.0 // indirect
|
||||
github.com/coder/websocket v1.8.14 // indirect
|
||||
github.com/dlclark/regexp2 v1.11.5 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||
github.com/ghodss/yaml v1.0.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/xor v1.0.0 // indirect
|
||||
github.com/go-faster/yaml v0.4.6 // indirect
|
||||
github.com/go-ini/ini v1.67.0 // indirect
|
||||
github.com/go-logfmt/logfmt v0.6.0 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.3.0 // indirect
|
||||
github.com/go-logfmt/logfmt v0.6.1 // indirect
|
||||
github.com/go-sourcemap/sourcemap v2.1.4+incompatible // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/google/go-github/v30 v30.1.0 // indirect
|
||||
github.com/google/go-querystring v1.1.0 // indirect
|
||||
github.com/google/pprof v0.0.0-20251007162407-5df77e3f7d1d // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/gotd/ige v0.2.2 // indirect
|
||||
github.com/gotd/neo v0.1.5 // indirect
|
||||
github.com/inconshreveable/go-update v0.0.0-20160112193335-8152e7eb6ccf // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.11 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.16 // indirect
|
||||
github.com/minio/crc64nvme v1.0.2 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.19 // indirect
|
||||
github.com/minio/crc64nvme v1.1.1 // indirect
|
||||
github.com/minio/md5-simd v1.1.2 // indirect
|
||||
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||
github.com/muesli/termenv v0.16.0 // indirect
|
||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/ncruces/julianday v1.0.0 // indirect
|
||||
github.com/ogen-go/ogen v1.14.0 // indirect
|
||||
github.com/ogen-go/ogen v1.16.0 // indirect
|
||||
github.com/onsi/gomega v1.36.2 // indirect
|
||||
github.com/philhofer/fwd v1.2.0 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/segmentio/asm v1.2.0 // indirect
|
||||
github.com/segmentio/asm v1.2.1 // indirect
|
||||
github.com/shopspring/decimal v1.4.0 // indirect
|
||||
github.com/tcnksm/go-gitconfig v0.1.2 // indirect
|
||||
github.com/tetratelabs/wazero v1.9.0 // indirect
|
||||
github.com/tinylib/msgp v1.3.0 // indirect
|
||||
github.com/ulikunitz/xz v0.5.12 // indirect
|
||||
go.opentelemetry.io/otel v1.37.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.37.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.37.0 // indirect
|
||||
github.com/tinylib/msgp v1.4.0 // indirect
|
||||
github.com/ulikunitz/xz v0.5.15 // indirect
|
||||
go.opentelemetry.io/otel v1.38.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.38.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.38.0 // indirect
|
||||
go.uber.org/atomic v1.11.0 // indirect
|
||||
go.uber.org/zap v1.27.0 // indirect
|
||||
golang.org/x/crypto v0.39.0 // indirect
|
||||
golang.org/x/mod v0.25.0 // indirect
|
||||
golang.org/x/oauth2 v0.30.0 // indirect
|
||||
golang.org/x/tools v0.34.0 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/crypto v0.43.0 // indirect
|
||||
golang.org/x/mod v0.29.0 // indirect
|
||||
golang.org/x/oauth2 v0.32.0 // indirect
|
||||
golang.org/x/tools v0.38.0 // indirect
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
|
||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||
modernc.org/libc v1.66.1 // indirect
|
||||
modernc.org/libc v1.66.10 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
modernc.org/sqlite v1.38.0 // indirect
|
||||
modernc.org/sqlite v1.39.1 // indirect
|
||||
rsc.io/qr v0.2.0 // indirect
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/dgraph-io/ristretto/v2 v2.2.0
|
||||
github.com/duke-git/lancet/v2 v2.3.6
|
||||
github.com/dgraph-io/ristretto/v2 v2.3.0
|
||||
github.com/dop251/goja v0.0.0-20251008123653-cf18d89f3cf6
|
||||
github.com/duke-git/lancet/v2 v2.3.7
|
||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||
github.com/glebarez/sqlite v1.11.0 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/klauspost/compress v1.18.0 // indirect
|
||||
github.com/klauspost/compress v1.18.1 // indirect
|
||||
github.com/mitchellh/mapstructure v1.5.0
|
||||
github.com/ncruces/go-sqlite3 v0.26.2
|
||||
github.com/ncruces/go-sqlite3 v0.29.1
|
||||
github.com/ncruces/go-sqlite3/gormlite v0.24.0
|
||||
github.com/nicksnyder/go-i18n/v2 v2.6.0
|
||||
github.com/pelletier/go-toml/v2 v2.2.4
|
||||
github.com/sagikazarmark/locafero v0.9.0 // indirect
|
||||
github.com/sourcegraph/conc v0.3.0 // indirect
|
||||
github.com/spf13/afero v1.14.0 // indirect
|
||||
github.com/spf13/cast v1.9.2 // indirect
|
||||
github.com/spf13/pflag v1.0.6 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/sagikazarmark/locafero v0.12.0 // indirect
|
||||
github.com/spf13/afero v1.15.0 // indirect
|
||||
github.com/spf13/cast v1.10.0 // indirect
|
||||
github.com/spf13/pflag v1.0.10 // indirect
|
||||
github.com/subosito/gotenv v1.6.0 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
go.uber.org/multierr v1.11.0
|
||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
|
||||
golang.org/x/sync v0.15.0
|
||||
golang.org/x/sys v0.33.0 // indirect
|
||||
golang.org/x/text v0.26.0
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
gorm.io/gorm v1.30.0
|
||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
|
||||
golang.org/x/sync v0.17.0
|
||||
golang.org/x/sys v0.37.0 // indirect
|
||||
golang.org/x/text v0.30.0
|
||||
gorm.io/gorm v1.31.0
|
||||
)
|
||||
|
||||
275
go.sum
275
go.sum
@@ -1,45 +1,41 @@
|
||||
github.com/AnimeKaizoku/cacher v1.0.2 h1:7Bf5qRylWb7q2Evib0OXlhG37/t7BP2HK/7IyPvSmGQ=
|
||||
github.com/AnimeKaizoku/cacher v1.0.2/go.mod h1:jw0de/b0K6W7Y3T9rHCMGVKUf6oG7hENNcssxYcZTCc=
|
||||
github.com/AnimeKaizoku/cacher v1.0.3 h1:foNAmLfY/DXfA4yEy4uP6WK2Ni7JC+s3QhZv72Dn6zs=
|
||||
github.com/AnimeKaizoku/cacher v1.0.3/go.mod h1:jw0de/b0K6W7Y3T9rHCMGVKUf6oG7hENNcssxYcZTCc=
|
||||
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/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
|
||||
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/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||
github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8=
|
||||
github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA=
|
||||
github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
|
||||
github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=
|
||||
github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ=
|
||||
github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk=
|
||||
github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY=
|
||||
github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
|
||||
github.com/celestix/gotgproto v1.0.0-beta21 h1:VUuAC/Kj5Sdu/WZan3ZUb0GFNAavFxMYxmHAhCBX0J8=
|
||||
github.com/celestix/gotgproto v1.0.0-beta21/go.mod h1:viDkHe9rBegJoEE/jNuFfbBM0XZ3pSx/ugjaNaVnbvU=
|
||||
github.com/celestix/gotgproto v1.0.0-beta22 h1:Iu78cFA08nV8+flmxKs9CJ3W73+HG30fx0nLOs5A6fI=
|
||||
github.com/celestix/gotgproto v1.0.0-beta22/go.mod h1:JYC9Js/5KLUhFR5M2RslQi2DFAcF7EdrgJMXo0YrzGQ=
|
||||
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
|
||||
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs=
|
||||
github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg=
|
||||
github.com/charmbracelet/bubbletea v1.3.4 h1:kCg7B+jSCFPLYRA52SDZjr51kG/fMUEoPoZrkaDHyoI=
|
||||
github.com/charmbracelet/bubbletea v1.3.4/go.mod h1:dtcUCyCGEX3g9tosuYiut3MXgY/Jsv9nKVdibKKRRXo=
|
||||
github.com/charmbracelet/bubbletea v1.3.5 h1:JAMNLTbqMOhSwoELIr0qyP4VidFq72/6E9j7HHmRKQc=
|
||||
github.com/charmbracelet/bubbletea v1.3.5/go.mod h1:TkCnmH+aBd4LrXhXcqrKiYwRs7qyQx5rBgH5fVY3v54=
|
||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
|
||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
|
||||
github.com/charmbracelet/colorprofile v0.3.1 h1:k8dTHMd7fgw4bnFd7jXTLZrSU/CQrKnL3m+AxCzDz40=
|
||||
github.com/charmbracelet/colorprofile v0.3.1/go.mod h1:/GkGusxNs8VB/RSOh3fu0TJmQ4ICMMPApIIVn0KszZ0=
|
||||
github.com/charmbracelet/huh v0.7.0 h1:W8S1uyGETgj9Tuda3/JdVkc3x7DBLZYPZc4c+/rnRdc=
|
||||
github.com/charmbracelet/huh v0.7.0/go.mod h1:UGC3DZHlgOKHvHC07a5vHag41zzhpPFj34U92sOmyuk=
|
||||
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 h1:JFgG/xnwFfbezlUnFMJy0nusZvytYysV4SCS2cYbvws=
|
||||
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7/go.mod h1:ISC1gtLcVilLOf23wvTfoQuYbW2q0JevFxPfUzZ9Ybw=
|
||||
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
|
||||
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
|
||||
github.com/charmbracelet/colorprofile v0.3.2 h1:9J27WdztfJQVAQKX2WOlSSRB+5gaKqqITmrvb1uTIiI=
|
||||
github.com/charmbracelet/colorprofile v0.3.2/go.mod h1:mTD5XzNeWHj8oqHb+S1bssQb7vIHbepiebQ2kPKVKbI=
|
||||
github.com/charmbracelet/huh v0.8.0 h1:Xz/Pm2h64cXQZn/Jvele4J3r7DDiqFCNIVteYukxDvY=
|
||||
github.com/charmbracelet/huh v0.8.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4=
|
||||
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
|
||||
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
|
||||
github.com/charmbracelet/log v0.4.2 h1:hYt8Qj6a8yLnvR+h7MwsJv/XvmBJXiueUcI3cIxsyig=
|
||||
github.com/charmbracelet/log v0.4.2/go.mod h1:qifHGX/tc7eluv2R6pWIpyHDDrrb/AG71Pf2ysQu5nw=
|
||||
github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE=
|
||||
github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q=
|
||||
github.com/charmbracelet/x/ansi v0.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh6GXd0=
|
||||
github.com/charmbracelet/x/ansi v0.9.3/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
|
||||
github.com/charmbracelet/x/ansi v0.10.2 h1:ith2ArZS0CJG30cIUfID1LXN7ZFXRCww6RUvAPA+Pzw=
|
||||
github.com/charmbracelet/x/ansi v0.10.2/go.mod h1:HbLdJjQH4UH4AqA2HpRWuWNluRE6zxJH/yteYEYCFa8=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
|
||||
github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U=
|
||||
@@ -48,31 +44,33 @@ github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9
|
||||
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0=
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
|
||||
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4=
|
||||
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ=
|
||||
github.com/charmbracelet/x/exp/strings v0.0.0-20250629123816-066ae234febc h1:XFsX2G2Z1k1p9/52+7TYs2iYW//XCJXSD7xWlEeGvBM=
|
||||
github.com/charmbracelet/x/exp/strings v0.0.0-20250629123816-066ae234febc/go.mod h1:Rgw3/F+xlcUc5XygUtimVSxAqCOsqyvJjqF5UHRvc5k=
|
||||
github.com/charmbracelet/x/exp/strings v0.0.0-20251023181713-f594ac034d6b h1:LUXEpSryQXJyP2lwAi/vBto9n+cJzlXSIefnCol3FVw=
|
||||
github.com/charmbracelet/x/exp/strings v0.0.0-20251023181713-f594ac034d6b/go.mod h1:/ehtMPNh9K4odGFkqYJKpIYyePhdp1hLBRvyY4bWkH8=
|
||||
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
|
||||
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
|
||||
github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
|
||||
github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
|
||||
github.com/charmbracelet/x/xpty v0.1.2 h1:Pqmu4TEJ8KeA9uSkISKMU3f+C1F6OGBn8ABuGlqCbtI=
|
||||
github.com/charmbracelet/x/xpty v0.1.2/go.mod h1:XK2Z0id5rtLWcpeNiMYBccNNBrP2IJnzHI0Lq13Xzq4=
|
||||
github.com/coder/websocket v1.8.13 h1:f3QZdXy7uGVz+4uCJy2nTZyM0yTBj8yANEHhqlXZ9FE=
|
||||
github.com/coder/websocket v1.8.13/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs=
|
||||
github.com/clipperhouse/uax29/v2 v2.2.0 h1:ChwIKnQN3kcZteTXMgb1wztSgaU+ZemkgWdohwgs8tY=
|
||||
github.com/clipperhouse/uax29/v2 v2.2.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
|
||||
github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
|
||||
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
|
||||
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dgraph-io/ristretto/v2 v2.2.0 h1:bkY3XzJcXoMuELV8F+vS8kzNgicwQFAaGINAEJdWGOM=
|
||||
github.com/dgraph-io/ristretto/v2 v2.2.0/go.mod h1:RZrm63UmcBAaYWC1DotLYBmTvgkrs0+XhBd7Npn7/zI=
|
||||
github.com/dgraph-io/ristretto/v2 v2.3.0 h1:qTQ38m7oIyd4GAed/QkUZyPFNMnvVWyazGXRwvOt5zk=
|
||||
github.com/dgraph-io/ristretto/v2 v2.3.0/go.mod h1:gpoRV3VzrEY1a9dWAYV6T1U7YzfgttXdd/ZzL1s9OZM=
|
||||
github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da h1:aIftn67I1fkbMa512G+w+Pxci9hJPB8oMnkcP3iZF38=
|
||||
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/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/duke-git/lancet/v2 v2.3.6 h1:NKxSSh+dlgp37funvxLCf3xLBeUYa7VW1thYQP6j3Y8=
|
||||
github.com/duke-git/lancet/v2 v2.3.6/go.mod h1:zGa2R4xswg6EG9I6WnyubDbFO/+A/RROxIbXcwryTsc=
|
||||
github.com/dop251/goja v0.0.0-20251008123653-cf18d89f3cf6 h1:6dE1TmjqkY6tehR4A67gDNhvDtuZ54ocu7ab4K9o540=
|
||||
github.com/dop251/goja v0.0.0-20251008123653-cf18d89f3cf6/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/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/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
||||
@@ -84,8 +82,8 @@ github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7z
|
||||
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY=
|
||||
github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok=
|
||||
github.com/gabriel-vasile/mimetype v1.4.10 h1:zyueNbySn/z8mJZHLt6IPw0KoZsiQNszIpU+bX4+ZK0=
|
||||
github.com/gabriel-vasile/mimetype v1.4.10/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk=
|
||||
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
|
||||
github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ=
|
||||
@@ -103,19 +101,20 @@ github.com/go-faster/yaml v0.4.6 h1:lOK/EhI04gCpPgPhgt0bChS6bvw7G3WwI8xxVe0sw9I=
|
||||
github.com/go-faster/yaml v0.4.6/go.mod h1:390dRIvV4zbnO7qC9FGo6YYutc+wyyUSHBgbXL52eXk=
|
||||
github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A=
|
||||
github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
|
||||
github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4=
|
||||
github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs=
|
||||
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
|
||||
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logfmt/logfmt v0.6.1 h1:4hvbpePJKnIzH1B+8OR/JPbTx37NktoI9LE2QZBBkvE=
|
||||
github.com/go-logfmt/logfmt v0.6.1/go.mod h1:EV2pOAQoZaT1ZXZbqDl5hrymndi4SY9ED9/z6CO0XAk=
|
||||
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/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss=
|
||||
github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
github.com/go-viper/mapstructure/v2 v2.3.0 h1:27XbWsHIqhbdR5TIC911OfYvgSaW93HM+dX7970Q7jk=
|
||||
github.com/go-viper/mapstructure/v2 v2.3.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
github.com/go-sourcemap/sourcemap v2.1.4+incompatible h1:a+iTbH5auLKxaNwQFg0B+TCYl6lbukKPc7b5x0n1s6Q=
|
||||
github.com/go-sourcemap/sourcemap v2.1.4+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/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
|
||||
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
@@ -126,18 +125,18 @@ github.com/google/go-github/v30 v30.1.0/go.mod h1:n8jBpHl45a/rlBUtRJMOG4GhNADUQF
|
||||
github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck=
|
||||
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
|
||||
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
github.com/google/pprof v0.0.0-20251007162407-5df77e3f7d1d h1:KJIErDwbSHjnp/SGzE5ed8Aol7JsKiI5X7yWKAtzhM0=
|
||||
github.com/google/pprof v0.0.0-20251007162407-5df77e3f7d1d/go.mod h1:I6V7YzU0XDpsHqbsyrghnFZLO1gwK6NPTNvmetQIk9U=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gotd/contrib v0.21.0 h1:4Fj05jnyBE84toXZl7mVTvt7f732n5uglvztyG6nTr4=
|
||||
github.com/gotd/contrib v0.21.0/go.mod h1:ENoUh75IhHGxfz/puVJg8BU4ZF89yrL6Q47TyoNqFYo=
|
||||
github.com/gotd/contrib v0.21.1 h1:NSF+0YEnosQ34QEo2o4s6MA5YFDAor1LVvLhN1L3H1M=
|
||||
github.com/gotd/contrib v0.21.1/go.mod h1:trVJBP9Q/TJbjmJbVnLc0cnX/8T4N0RpQBULVa3BNnE=
|
||||
github.com/gotd/ige v0.2.2 h1:XQ9dJZwBfDnOGSTxKXBGP4gMud3Qku2ekScRjDWWfEk=
|
||||
github.com/gotd/ige v0.2.2/go.mod h1:tuCRb+Y5Y3eNTo3ypIfNpQ4MFjrnONiL2jN2AKZXmb0=
|
||||
github.com/gotd/neo v0.1.5 h1:oj0iQfMbGClP8xI59x7fE/uHoTJD7NZH9oV1WNuPukQ=
|
||||
github.com/gotd/neo v0.1.5/go.mod h1:9A2a4bn9zL6FADufBdt7tZt+WMhvZoc5gWXihOPoiBQ=
|
||||
github.com/gotd/td v0.125.0 h1:XGygdCC37887z4MK39tq6lgq82XvkDtdF7WaLptSS3g=
|
||||
github.com/gotd/td v0.125.0/go.mod h1:7BKKxCD1m3XXsryHXt5OzoufvhK5gRW5cgehYyUw8o0=
|
||||
github.com/gotd/td v0.132.0 h1:Iqm3S2b+8kDgA9237IDXRxj7sryUpvy+4Cr50/0tpx4=
|
||||
github.com/gotd/td v0.132.0/go.mod h1:4CDGYS+rDtOqotRheGaF9MS5g6jaUewvSXqBNJnx8SQ=
|
||||
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
|
||||
github.com/inconshreveable/go-update v0.0.0-20160112193335-8152e7eb6ccf h1:WfD7VjIE6z8dIvMsI4/s+1qr5EL+zoIGev1BQj1eoJ8=
|
||||
github.com/inconshreveable/go-update v0.0.0-20160112193335-8152e7eb6ccf/go.mod h1:hyb9oH7vZsitZCiBt0ZvifOrB+qc8PS5IiilCIb87rg=
|
||||
@@ -147,13 +146,11 @@ github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
||||
github.com/klauspost/compress v1.18.1 h1:bcSGx7UbpBqMChDtsF28Lw6v/G94LPrrbMbdC3JH2co=
|
||||
github.com/klauspost/compress v1.18.1/go.mod h1:ZQFFVG+MdnR0P+l6wpXgIL4NTtwiKIdBnrBd8Nrxr+0=
|
||||
github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
|
||||
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/klauspost/cpuid/v2 v2.2.11 h1:0OwqZRYI2rFrjS4kvkDnqJkKHdHaRnCm68/DY4OxRzU=
|
||||
github.com/klauspost/cpuid/v2 v2.2.11/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
@@ -162,24 +159,22 @@ 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.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
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.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
|
||||
github.com/lucasb-eyer/go-colorful v1.3.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/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
|
||||
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
||||
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
||||
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/minio/crc64nvme v1.0.2 h1:6uO1UxGAD+kwqWWp7mBFsi5gAse66C4NXO8cmcVculg=
|
||||
github.com/minio/crc64nvme v1.0.2/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg=
|
||||
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
|
||||
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||
github.com/minio/crc64nvme v1.1.1 h1:8dwx/Pz49suywbO+auHCBpCtlW1OfpcLN7wYgVR6wAI=
|
||||
github.com/minio/crc64nvme v1.1.1/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg=
|
||||
github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=
|
||||
github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM=
|
||||
github.com/minio/minio-go/v7 v7.0.92 h1:jpBFWyRS3p8P/9tsRc+NuvqoFi7qAmTCFPoRFmobbVw=
|
||||
github.com/minio/minio-go/v7 v7.0.92/go.mod h1:vTIc8DNcnAZIhyFsk8EB90AbPjj3j68aWIEQCiPj7d0=
|
||||
github.com/minio/minio-go/v7 v7.0.94 h1:1ZoksIKPyaSt64AVOyaQvhDOgVC3MfZsWM6mZXRUGtM=
|
||||
github.com/minio/minio-go/v7 v7.0.94/go.mod h1:71t2CqDt3ThzESgZUlU1rBN54mksGGlkLcFgguDnnAc=
|
||||
github.com/minio/minio-go/v7 v7.0.95 h1:ywOUPg+PebTMTzn9VDsoFJy32ZuARN9zhB+K3IYEvYU=
|
||||
github.com/minio/minio-go/v7 v7.0.95/go.mod h1:wOOX3uxS334vImCNRVyIDdXX9OsXDm89ToynKgqUKlo=
|
||||
github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=
|
||||
github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE=
|
||||
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
|
||||
@@ -190,28 +185,24 @@ github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELU
|
||||
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
||||
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
||||
github.com/ncruces/go-sqlite3 v0.26.1 h1:lBXmbmucH1Bsj57NUQR6T84UoMN7jnNImhF+ibEITJU=
|
||||
github.com/ncruces/go-sqlite3 v0.26.1/go.mod h1:XFTPtFIo1DmGCh+XVP8KGn9b/o2f+z0WZuT09x2N6eo=
|
||||
github.com/ncruces/go-sqlite3 v0.26.2 h1:5UkIBwdfMN2irpVI1dgi9TjTUlxNI06Rti1C8O7ZKVg=
|
||||
github.com/ncruces/go-sqlite3 v0.26.2/go.mod h1:XFTPtFIo1DmGCh+XVP8KGn9b/o2f+z0WZuT09x2N6eo=
|
||||
github.com/ncruces/go-sqlite3 v0.29.1 h1:NIi8AISWBToRHyoz01FXiTNvU147Tqdibgj2tFzJCqM=
|
||||
github.com/ncruces/go-sqlite3 v0.29.1/go.mod h1:PpccBNNhvjwUOwDQEn2gXQPFPTWdlromj0+fSkd5KSg=
|
||||
github.com/ncruces/go-sqlite3/gormlite v0.24.0 h1:81sHeq3CCdhjoqAB650n5wEdRlLO9VBvosArskcN3+c=
|
||||
github.com/ncruces/go-sqlite3/gormlite v0.24.0/go.mod h1:vXfVWdBfg7qOgqQqHpzUWl9LLswD0h+8mK4oouaV2oc=
|
||||
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/ncruces/julianday v1.0.0 h1:fH0OKwa7NWvniGQtxdJRxAgkBMolni2BjDHaWTxqt7M=
|
||||
github.com/ncruces/julianday v1.0.0/go.mod h1:Dusn2KvZrrovOMJuOt0TNXL6tB7U2E8kvza5fFc9G7g=
|
||||
github.com/nicksnyder/go-i18n/v2 v2.6.0 h1:C/m2NNWNiTB6SK4Ao8df5EWm3JETSTIGNXBpMJTxzxQ=
|
||||
github.com/nicksnyder/go-i18n/v2 v2.6.0/go.mod h1:88sRqr0C6OPyJn0/KRNaEz1uWorjxIKP7rUUcvycecE=
|
||||
github.com/ogen-go/ogen v1.14.0 h1:TU1Nj4z9UBsAfTkf+IhuNNp7igdFQKqkk9+6/y4XuWg=
|
||||
github.com/ogen-go/ogen v1.14.0/go.mod h1:Iw1vkqkx6SU7I9th5ceP+fVPJ6Wge4e3kAVzAxJEpPE=
|
||||
github.com/ogen-go/ogen v1.16.0 h1:fKHEYokW/QrMzVNXId74/6RObRIUs9T2oroGKtR25Iw=
|
||||
github.com/ogen-go/ogen v1.16.0/go.mod h1:s3nWiMzybSf8fhxckyO+wtto92+QHpEL8FmkPnhL3jI=
|
||||
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||
github.com/onsi/gomega v1.4.2/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
|
||||
github.com/onsi/gomega v1.36.2 h1:koNYke6TVk6ZmnyHrCXba/T/MoLBXFjeC1PtvYgw0A8=
|
||||
github.com/onsi/gomega v1.36.2/go.mod h1:DdwyADRjrc825LhMEkD76cHR5+pUnjhUN8GlHlRPHzY=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c h1:dAMKvw0MlJT1GshSTtih8C2gDs04w8dReiOGXrGLNoY=
|
||||
github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
|
||||
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
|
||||
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
@@ -222,7 +213,6 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/rhysd/go-github-selfupdate v1.2.3 h1:iaa+J202f+Nc+A8zi75uccC8Wg3omaM7HDeimXA22Ag=
|
||||
github.com/rhysd/go-github-selfupdate v1.2.3/go.mod h1:mp/N8zj6jFfBQy/XMYoWsmfzxazpPAODuqarmPDe2Rg=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
@@ -230,51 +220,46 @@ github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7
|
||||
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
|
||||
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/sagikazarmark/locafero v0.9.0 h1:GbgQGNtTrEmddYDSAH9QLRyfAHY12md+8YFTqyMTC9k=
|
||||
github.com/sagikazarmark/locafero v0.9.0/go.mod h1:UBUyz37V+EdMS3hDF3QWIiVr/2dPrx49OMO0Bn0hJqk=
|
||||
github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys=
|
||||
github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
|
||||
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
|
||||
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
|
||||
github.com/spf13/afero v1.14.0 h1:9tH6MapGnn/j0eb0yIXiLjERO8RB6xIVZRDCX7PtqWA=
|
||||
github.com/spf13/afero v1.14.0/go.mod h1:acJQ8t0ohCGuMN3O+Pv0V0hgMxNYDlvdk+VTfyZmbYo=
|
||||
github.com/spf13/cast v1.9.2 h1:SsGfm7M8QOFtEzumm7UZrZdLLquNdzFYfIbEXntcFbE=
|
||||
github.com/spf13/cast v1.9.2/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
|
||||
github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
|
||||
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
|
||||
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
|
||||
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/viper v1.20.1 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4=
|
||||
github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4=
|
||||
github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI=
|
||||
github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0=
|
||||
github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
|
||||
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
|
||||
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
|
||||
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
|
||||
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
|
||||
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
|
||||
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
|
||||
github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s=
|
||||
github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0=
|
||||
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
||||
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
|
||||
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
||||
github.com/tcnksm/go-gitconfig v0.1.2 h1:iiDhRitByXAEyjgBqsKi9QU4o2TNtv9kPP3RgPgXBPw=
|
||||
github.com/tcnksm/go-gitconfig v0.1.2/go.mod h1:/8EhP4H7oJZdIPyT+/UIsG87kTzrzM4UsLGSItWYCpE=
|
||||
github.com/tetratelabs/wazero v1.9.0 h1:IcZ56OuxrtaEz8UYNRHBrUa9bYeX9oVY93KspZZBf/I=
|
||||
github.com/tetratelabs/wazero v1.9.0/go.mod h1:TSbcXCfFP0L2FGkRPxHphadXPjo1T6W+CseNNY7EkjM=
|
||||
github.com/tinylib/msgp v1.3.0 h1:ULuf7GPooDaIlbyvgAxBV/FI7ynli6LZ1/nVUNu+0ww=
|
||||
github.com/tinylib/msgp v1.3.0/go.mod h1:ykjzy2wzgrlvpDCRc4LA8UXy6D8bzMSuAF3WD57Gok0=
|
||||
github.com/tinylib/msgp v1.4.0 h1:SYOeDRiydzOw9kSiwdYp9UcBgPFtLU2WDHaJXyHruf8=
|
||||
github.com/tinylib/msgp v1.4.0/go.mod h1:cvjFkb4RiC8qSBOPMGPSzSAx47nAsfhLVTCZZNuHv5o=
|
||||
github.com/ulikunitz/xz v0.5.9/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
|
||||
github.com/ulikunitz/xz v0.5.12 h1:37Nm15o69RwBkXM0J6A5OlE67RZTfzUxTj8fB3dfcsc=
|
||||
github.com/ulikunitz/xz v0.5.12/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
|
||||
github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY=
|
||||
github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
|
||||
go.opentelemetry.io/otel v1.36.0 h1:UumtzIklRBY6cI/lllNZlALOF5nNIzJVb16APdvgTXg=
|
||||
go.opentelemetry.io/otel v1.36.0/go.mod h1:/TcFMXYjyRNh8khOAO9ybYkqaDBb/70aVwkNML4pP8E=
|
||||
go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
|
||||
go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
|
||||
go.opentelemetry.io/otel/metric v1.36.0 h1:MoWPKVhQvJ+eeXWHFBOPoBOi20jh6Iq2CcCREuTYufE=
|
||||
go.opentelemetry.io/otel/metric v1.36.0/go.mod h1:zC7Ks+yeyJt4xig9DEw9kuUFe5C3zLbVjV2PzT6qzbs=
|
||||
go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
|
||||
go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
|
||||
go.opentelemetry.io/otel/trace v1.36.0 h1:ahxWNuqZjpdiFAyrIoQ4GIiAIhxAunQR6MUoKrsNd4w=
|
||||
go.opentelemetry.io/otel/trace v1.36.0/go.mod h1:gQ+OnDZzrybY4k4seLzPAWNwVBBVlF2szhehOBB/tGA=
|
||||
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
|
||||
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
|
||||
go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8=
|
||||
go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM=
|
||||
go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA=
|
||||
go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI=
|
||||
go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE=
|
||||
go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs=
|
||||
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
||||
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
@@ -283,47 +268,47 @@ go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
|
||||
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
|
||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
|
||||
golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
|
||||
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
|
||||
golang.org/x/exp v0.0.0-20250606033433-dcc06ee1d476 h1:bsqhLWFR6G6xiQcb+JoGqdKdRU6WzPWmK8E0jxTjzo4=
|
||||
golang.org/x/exp v0.0.0-20250606033433-dcc06ee1d476/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
|
||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
|
||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
|
||||
golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
|
||||
golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
|
||||
golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
|
||||
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
|
||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
|
||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
|
||||
golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
|
||||
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
|
||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
|
||||
golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
|
||||
golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
|
||||
golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/oauth2 v0.0.0-20181106182150-f42d05182288/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
|
||||
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
|
||||
golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY=
|
||||
golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
|
||||
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
|
||||
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
|
||||
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
|
||||
golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
|
||||
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
|
||||
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
|
||||
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
|
||||
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
|
||||
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
|
||||
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
|
||||
golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
|
||||
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
||||
google.golang.org/appengine v1.3.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||
@@ -338,20 +323,20 @@ gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gorm.io/gorm v1.30.0 h1:qbT5aPv1UH8gI99OsRlvDToLxW5zR7FzS9acZDOZcgs=
|
||||
gorm.io/gorm v1.30.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE=
|
||||
modernc.org/cc/v4 v4.26.1 h1:+X5NtzVBn0KgsBCBe+xkDC7twLb/jNVj9FPgiwSQO3s=
|
||||
modernc.org/cc/v4 v4.26.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||
modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU=
|
||||
modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE=
|
||||
modernc.org/fileutil v1.3.3 h1:3qaU+7f7xxTUmvU1pJTZiDLAIoJVdUSSauJNHg9yXoA=
|
||||
modernc.org/fileutil v1.3.3/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
|
||||
gorm.io/gorm v1.31.0 h1:0VlycGreVhK7RF/Bwt51Fk8v0xLiiiFdbGDPIZQ7mJY=
|
||||
gorm.io/gorm v1.31.0/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
||||
modernc.org/cc/v4 v4.26.5 h1:xM3bX7Mve6G8K8b+T11ReenJOT+BmVqQj0FY5T4+5Y4=
|
||||
modernc.org/cc/v4 v4.26.5/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||
modernc.org/ccgo/v4 v4.28.1 h1:wPKYn5EC/mYTqBO373jKjvX2n+3+aK7+sICCv4Fjy1A=
|
||||
modernc.org/ccgo/v4 v4.28.1/go.mod h1:uD+4RnfrVgE6ec9NGguUNdhqzNIeeomeXf6CL0GTE5Q=
|
||||
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
|
||||
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
|
||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||
modernc.org/libc v1.65.10 h1:ZwEk8+jhW7qBjHIT+wd0d9VjitRyQef9BnzlzGwMODc=
|
||||
modernc.org/libc v1.65.10/go.mod h1:StFvYpx7i/mXtBAfVOjaU0PWZOvIRoZSgXhrwXzr8Po=
|
||||
modernc.org/libc v1.66.1 h1:4uQsntXbVyAgrV+j6NhKvDiUypoJL48BWQx6sy9y8ok=
|
||||
modernc.org/libc v1.66.1/go.mod h1:AiZxInURfEJx516LqEaFcrC+X38rt9G7+8ojIXQKHbo=
|
||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||
modernc.org/libc v1.66.10 h1:yZkb3YeLx4oynyR+iUsXsybsX4Ubx7MQlSYEw4yj59A=
|
||||
modernc.org/libc v1.66.10/go.mod h1:8vGSEwvoUoltr4dlywvHqjtAqHBaw0j1jI7iFBTAr2I=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
@@ -360,8 +345,8 @@ modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
||||
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.38.0 h1:+4OrfPQ8pxHKuWG4md1JpR/EYAh3Md7TdejuuzE7EUI=
|
||||
modernc.org/sqlite v1.38.0/go.mod h1:1Bj+yES4SVvBZ4cBOpVZ6QgesMCKpJZDq0nxYzOpmNE=
|
||||
modernc.org/sqlite v1.39.1 h1:H+/wGFzuSCIEVCvXYVHX5RQglwhMOvtHSv+VtidL2r4=
|
||||
modernc.org/sqlite v1.39.1/go.mod h1:9fjQZ0mB1LLP0GYrp39oOJXx/I2sxEnZtzCmEQIKvGE=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
|
||||
2
main.go
2
main.go
@@ -8,6 +8,8 @@ import (
|
||||
"github.com/krau/SaveAny-Bot/cmd"
|
||||
)
|
||||
|
||||
//go:generate go run cmd/geni18n/main.go -dir ./common/i18n/locale -out common/i18n/i18nk/keys.go -pkg i18nk
|
||||
|
||||
func main() {
|
||||
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
|
||||
defer cancel()
|
||||
|
||||
130
parsers/js.go
Normal file
130
parsers/js.go
Normal file
@@ -0,0 +1,130 @@
|
||||
package parsers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"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 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", jsRegisterParser(vm))
|
||||
// Inject some utils to vm
|
||||
logger := log.FromContext(ctx).WithPrefix(fmt.Sprintf("[plugin|parser]/%s", e.Name()))
|
||||
vm.Set("console", jsConsole(logger))
|
||||
// http fetch funcs
|
||||
vm.Set("ghttp", jsGhttp(vm))
|
||||
|
||||
if _, err := vm.RunString(string(code)); err != nil {
|
||||
return fmt.Errorf("error loading plugin %s: %w", e.Name(), err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
151
parsers/js_api.go
Normal file
151
parsers/js_api.go
Normal file
@@ -0,0 +1,151 @@
|
||||
package parsers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/blang/semver"
|
||||
"github.com/charmbracelet/log"
|
||||
"github.com/dop251/goja"
|
||||
"github.com/krau/SaveAny-Bot/common/utils/netutil"
|
||||
)
|
||||
|
||||
func jsRegisterParser(vm *goja.Runtime) func(call goja.FunctionCall) goja.Value {
|
||||
return func(call goja.FunctionCall) goja.Value {
|
||||
jsObj := call.Argument(0)
|
||||
if jsObj == nil || goja.IsUndefined(jsObj) || goja.IsNull(jsObj) {
|
||||
panic("registerParser expects an object { canHandle, parse }")
|
||||
}
|
||||
|
||||
obj := jsObj.ToObject(vm)
|
||||
if obj == nil {
|
||||
panic("registerParser: cannot convert argument to object")
|
||||
}
|
||||
metaValue := obj.Get("metadata")
|
||||
if metaValue == nil || goja.IsUndefined(metaValue) {
|
||||
panic("parser must provide metadata")
|
||||
}
|
||||
var metadata PluginMeta
|
||||
if exported := metaValue.Export(); exported != nil {
|
||||
data, err := json.Marshal(exported)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("failed to marshal metadata to JSON: %v", err))
|
||||
}
|
||||
if err := json.Unmarshal(data, &metadata); err != nil {
|
||||
panic(fmt.Sprintf("failed to unmarshal JSON to PluginMeta: %v", err))
|
||||
}
|
||||
} else {
|
||||
panic("metadata cannot be null or undefined")
|
||||
}
|
||||
|
||||
pluginV := semver.MustParse(metadata.Version)
|
||||
if pluginV.LT(MinimumParserVersion) || pluginV.GT(LatestParserVersion) {
|
||||
panic(fmt.Sprintf("parser version %s is not supported, must be between %s and %s", metadata.Version, MinimumParserVersion, LatestParserVersion))
|
||||
}
|
||||
|
||||
handleFn := obj.Get("canHandle")
|
||||
parseFn := obj.Get("parse")
|
||||
if parseFn == nil || goja.IsUndefined(parseFn) {
|
||||
panic("parser must provide a parse function")
|
||||
}
|
||||
|
||||
parsers = append(parsers, newJSParser(vm, handleFn, parseFn, metadata))
|
||||
return goja.Undefined()
|
||||
}
|
||||
}
|
||||
|
||||
var jsConsole = func(logger *log.Logger) map[string]any {
|
||||
return map[string]any{
|
||||
"log": func(args ...any) {
|
||||
if len(args) == 0 {
|
||||
return
|
||||
}
|
||||
if len(args) > 1 {
|
||||
logger.Info(args[0], args[1:]...)
|
||||
} else {
|
||||
logger.Info(args[0])
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
var jsGhttp = func(vm *goja.Runtime) *goja.Object {
|
||||
ghttp := vm.NewObject()
|
||||
client := netutil.DefaultParserHTTPClient()
|
||||
ghttp.Set("get", func(call goja.FunctionCall) goja.Value {
|
||||
url := call.Argument(0).String()
|
||||
resp, err := client.Get(url)
|
||||
if err != nil {
|
||||
return vm.ToValue(map[string]any{
|
||||
"error": fmt.Sprintf("failed to fetch %s: %v", url, err),
|
||||
})
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return vm.ToValue(map[string]any{
|
||||
"error": fmt.Sprintf("failed to fetch %s: %s", url, resp.Status),
|
||||
"status": resp.StatusCode,
|
||||
})
|
||||
}
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return vm.ToValue(map[string]any{
|
||||
"error": fmt.Errorf("failed to read response body: %w", err).Error(),
|
||||
})
|
||||
}
|
||||
return vm.ToValue(string(body))
|
||||
})
|
||||
ghttp.Set("getJSON", func(call goja.FunctionCall) goja.Value {
|
||||
url := call.Argument(0).String()
|
||||
|
||||
resp, err := client.Get(url)
|
||||
if err != nil {
|
||||
return vm.ToValue(map[string]any{
|
||||
"error": fmt.Sprintf("failed to fetch %s: %v", url, err),
|
||||
})
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return vm.ToValue(map[string]any{
|
||||
"error": fmt.Sprintf("failed to fetch %s: %s", url, resp.Status),
|
||||
"status": resp.StatusCode,
|
||||
})
|
||||
}
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return vm.ToValue(map[string]any{
|
||||
"error": fmt.Errorf("failed to read response body: %w", err).Error(),
|
||||
})
|
||||
}
|
||||
var jsonData map[string]any
|
||||
if err := json.Unmarshal(body, &jsonData); err != nil {
|
||||
return vm.ToValue(map[string]any{
|
||||
"error": fmt.Errorf("failed to unmarshal JSON: %w", err).Error(),
|
||||
})
|
||||
}
|
||||
return vm.ToValue(map[string]any{
|
||||
"data": jsonData,
|
||||
})
|
||||
})
|
||||
ghttp.Set("head", func(call goja.FunctionCall) goja.Value {
|
||||
url := call.Argument(0).String()
|
||||
resp, err := client.Head(url)
|
||||
if err != nil {
|
||||
return vm.ToValue(map[string]any{
|
||||
"error": fmt.Sprintf("failed to fetch %s: %v", url, err),
|
||||
})
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
headers := make(map[string]string)
|
||||
for k, v := range resp.Header {
|
||||
headers[k] = v[0]
|
||||
}
|
||||
return vm.ToValue(map[string]any{
|
||||
"status": resp.StatusCode,
|
||||
"headers": headers,
|
||||
})
|
||||
})
|
||||
return ghttp
|
||||
}
|
||||
36
parsers/kemono/download.go
Normal file
36
parsers/kemono/download.go
Normal file
@@ -0,0 +1,36 @@
|
||||
package kemono
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type DownloadInfo struct {
|
||||
ServiceName string
|
||||
UserID string
|
||||
PostID string
|
||||
}
|
||||
|
||||
func extractDownloadInfoFromURL(u string) *DownloadInfo {
|
||||
if !strings.HasPrefix(u, "http://") && !strings.HasPrefix(u, "https://") {
|
||||
u = "https://" + u
|
||||
}
|
||||
url, err := url.Parse(u)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
parts := strings.Split(strings.Trim(url.Path, "/"), "/")
|
||||
if len(parts) == 3 {
|
||||
return &DownloadInfo{
|
||||
ServiceName: parts[0],
|
||||
UserID: parts[2],
|
||||
}
|
||||
} else if len(parts) == 5 && parts[3] == "post" {
|
||||
return &DownloadInfo{
|
||||
ServiceName: parts[0],
|
||||
UserID: parts[2],
|
||||
PostID: parts[4],
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
173
parsers/kemono/kemono.go
Normal file
173
parsers/kemono/kemono.go
Normal file
@@ -0,0 +1,173 @@
|
||||
package kemono
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"github.com/duke-git/lancet/v2/strutil"
|
||||
"github.com/krau/SaveAny-Bot/common/utils/netutil"
|
||||
"github.com/krau/SaveAny-Bot/pkg/parser"
|
||||
)
|
||||
|
||||
type KemonoParser struct{}
|
||||
|
||||
var (
|
||||
kemonoDomains = []string{
|
||||
"kemono.su",
|
||||
"kemono.cr",
|
||||
}
|
||||
ErrFailedToExtractInfo = errors.New("failed to extract download info from URL")
|
||||
)
|
||||
|
||||
const (
|
||||
kemonoApiBase = "https://kemono.cr/api/v1"
|
||||
)
|
||||
|
||||
func (k *KemonoParser) CanHandle(text string) bool {
|
||||
text = strings.TrimPrefix(text, "https://")
|
||||
text = strings.TrimPrefix(text, "http://")
|
||||
|
||||
var matchesDomain bool
|
||||
for _, domain := range kemonoDomains {
|
||||
if strings.Contains(text, domain) {
|
||||
matchesDomain = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !matchesDomain {
|
||||
return false
|
||||
}
|
||||
|
||||
var path string
|
||||
for _, domain := range kemonoDomains {
|
||||
if idx := strings.Index(text, domain); idx != -1 {
|
||||
remaining := text[idx+len(domain):]
|
||||
if len(remaining) > 0 && remaining[0] == '/' {
|
||||
path = remaining[1:]
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if path == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
parts := strings.Split(path, "/")
|
||||
// servicename/user/id (user profile page)
|
||||
// servicename/user/id/post/id (post page)
|
||||
return len(parts) == 3 || (len(parts) == 5 && parts[3] == "post")
|
||||
}
|
||||
|
||||
func (k *KemonoParser) Parse(ctx context.Context, u string) (*parser.Item, error) {
|
||||
info := extractDownloadInfoFromURL(u)
|
||||
if info == nil {
|
||||
return nil, ErrFailedToExtractInfo
|
||||
}
|
||||
if info.PostID != "" {
|
||||
return k.parseOne(ctx, info)
|
||||
}
|
||||
return k.parseUserPage(ctx, info)
|
||||
}
|
||||
|
||||
func (k *KemonoParser) parseOne(ctx context.Context, info *DownloadInfo) (*parser.Item, error) {
|
||||
client := netutil.DefaultParserHTTPClient()
|
||||
endpoint := fmt.Sprintf("%s/%s/user/%s/post/%s", kemonoApiBase, info.ServiceName, info.UserID, info.PostID)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request to Kemono API: %w", err)
|
||||
}
|
||||
req.Header.Set("Accept", "text/css")
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch Kemono API: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("failed to fetch Kemono API, status code: %d", resp.StatusCode)
|
||||
}
|
||||
var postInfo PostInfo
|
||||
if err := json.NewDecoder(resp.Body).Decode(&postInfo); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode Kemono API response: %w", err)
|
||||
}
|
||||
item := &parser.Item{
|
||||
Site: "kemono",
|
||||
Title: postInfo.Post.Title,
|
||||
URL: fmt.Sprintf("https://kemono.cr/%s/user/%s/post/%s", info.ServiceName, info.UserID, info.PostID),
|
||||
Author: postInfo.Post.User, // [TODO] request user profile
|
||||
Description: postInfo.Post.Content,
|
||||
Tags: func() []string {
|
||||
if postInfo.Post.Tags != nil {
|
||||
return *postInfo.Post.Tags
|
||||
}
|
||||
return nil
|
||||
}(),
|
||||
}
|
||||
resources := make([]parser.Resource, 0)
|
||||
for _, attachment := range postInfo.Attachments {
|
||||
if attachment.Server == nil || attachment.Path == nil || attachment.Name == nil {
|
||||
continue
|
||||
}
|
||||
var size int64
|
||||
fileUrl := fmt.Sprintf("%s/data%s", *attachment.Server, *attachment.Path)
|
||||
headReq, err := http.NewRequestWithContext(ctx, http.MethodHead, fileUrl, nil)
|
||||
if err == nil {
|
||||
resp, err := client.Do(headReq)
|
||||
if err == nil {
|
||||
size = resp.ContentLength
|
||||
resp.Body.Close()
|
||||
}
|
||||
}
|
||||
resources = append(resources, parser.Resource{
|
||||
URL: fmt.Sprintf("%s/data%s", *attachment.Server, *attachment.Path),
|
||||
Filename: *attachment.Name,
|
||||
Size: size,
|
||||
})
|
||||
}
|
||||
picCdnMap := make(map[string]string)
|
||||
for _, preview := range postInfo.Previews {
|
||||
if preview.Type == nil || *preview.Type != "thumbnail" {
|
||||
continue
|
||||
}
|
||||
picCdnMap[*preview.Path] = *preview.Server
|
||||
}
|
||||
for _, attachment := range postInfo.Post.Attachments {
|
||||
if !isImageExt(*attachment.Path) {
|
||||
continue
|
||||
}
|
||||
picUrl, err := url.JoinPath(picCdnMap[*attachment.Path], "data", *attachment.Path)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
var size int64
|
||||
headReq, err := http.NewRequestWithContext(ctx, http.MethodHead, picUrl, nil)
|
||||
if err == nil {
|
||||
resp, err := client.Do(headReq)
|
||||
if err == nil {
|
||||
size = resp.ContentLength
|
||||
resp.Body.Close()
|
||||
}
|
||||
}
|
||||
resources = append(resources, parser.Resource{
|
||||
URL: picUrl,
|
||||
Filename: *attachment.Name,
|
||||
Size: size,
|
||||
})
|
||||
}
|
||||
item.Resources = resources
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func (k *KemonoParser) parseUserPage(_ context.Context, _ *DownloadInfo) (*parser.Item, error) {
|
||||
return nil, errors.New("kemono user page not implemented")
|
||||
}
|
||||
|
||||
func isImageExt(attachmentPath string) bool {
|
||||
return strutil.HasSuffixAny(path.Ext(strings.Split(attachmentPath, "?")[0]), []string{".jpg", ".jpeg", ".png", ".webp"})
|
||||
}
|
||||
62
parsers/kemono/post_info.go
Normal file
62
parsers/kemono/post_info.go
Normal file
@@ -0,0 +1,62 @@
|
||||
// https://github.com/kemono-rs/kemono
|
||||
|
||||
package kemono
|
||||
|
||||
type PostInfo struct {
|
||||
Post Post `json:"post"`
|
||||
Attachments []AttachmentLike `json:"attachments"`
|
||||
Previews []AttachmentLike `json:"previews"`
|
||||
}
|
||||
|
||||
type AttachmentLike struct {
|
||||
Type *string `json:"type,omitempty"`
|
||||
Server *string `json:"server,omitempty"`
|
||||
Name *string `json:"name,omitempty"`
|
||||
Path *string `json:"path,omitempty"`
|
||||
}
|
||||
|
||||
type Post struct {
|
||||
ID string `json:"id"`
|
||||
User string `json:"user"`
|
||||
Service string `json:"service"`
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"`
|
||||
Embed Embed `json:"embed"`
|
||||
SharedFile bool `json:"shared_file"`
|
||||
Added *string `json:"added,omitempty"`
|
||||
Published string `json:"published"`
|
||||
Edited *string `json:"edited,omitempty"`
|
||||
File File `json:"file"`
|
||||
Attachments []AttachmentLike `json:"attachments"`
|
||||
Poll *Poll `json:"poll,omitempty"`
|
||||
Captions *string `json:"captions,omitempty"`
|
||||
Tags *[]string `json:"tags,omitempty"`
|
||||
Next *string `json:"next,omitempty"`
|
||||
Prev *string `json:"prev,omitempty"`
|
||||
}
|
||||
|
||||
type File struct {
|
||||
Name *string `json:"name,omitempty"`
|
||||
Path *string `json:"path,omitempty"`
|
||||
}
|
||||
|
||||
type Embed struct {
|
||||
URL *string `json:"url,omitempty"`
|
||||
Subject *string `json:"subject,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
}
|
||||
|
||||
type Poll struct {
|
||||
Title string `json:"title"`
|
||||
Choices []Choice `json:"choices"`
|
||||
ClosesAt *string `json:"closes_at,omitempty"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
AllowsMultiple bool `json:"allows_multiple"`
|
||||
TotalVotes int64 `json:"total_votes"`
|
||||
}
|
||||
|
||||
type Choice struct {
|
||||
Text string `json:"text"`
|
||||
Votes int64 `json:"votes"`
|
||||
}
|
||||
16
parsers/kemono/post_legacy.go
Normal file
16
parsers/kemono/post_legacy.go
Normal file
@@ -0,0 +1,16 @@
|
||||
package kemono
|
||||
|
||||
type PostLegacy struct {
|
||||
Props Props `json:"props"`
|
||||
Results []Result `json:"results"`
|
||||
}
|
||||
|
||||
type Props struct {
|
||||
Count uint `json:"count"`
|
||||
Limit uint `json:"limit"`
|
||||
}
|
||||
|
||||
type Result struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user