Compare commits
168 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7a2274baa0 | ||
|
|
69a3ed6f4e | ||
|
|
36f3dd83fc | ||
|
|
501b9d844a | ||
|
|
03cec7ec01 | ||
|
|
dc0debcd1c | ||
|
|
4b136bd41e | ||
|
|
d703f11ea0 | ||
|
|
3ce9926967 | ||
|
|
80146176f0 | ||
|
|
14ba2afdf8 | ||
|
|
f4d427a1cb | ||
|
|
f84c83a7e2 | ||
|
|
cb6540c017 | ||
|
|
e7bab27543 | ||
|
|
f693bd6103 | ||
|
|
75f52569a0 | ||
|
|
c795f957a9 | ||
|
|
3b85911e3d | ||
|
|
336309fad0 | ||
|
|
394cdff865 | ||
|
|
40cb3dad9d | ||
|
|
2979628cf7 | ||
|
|
c82c2462bf | ||
|
|
88128ecac2 | ||
|
|
758564d436 | ||
|
|
f5e33472eb | ||
|
|
4df2c5a06d | ||
|
|
eb6f8675a4 | ||
|
|
473a5b9413 | ||
|
|
6c2abe3025 | ||
|
|
e7e5b9f434 | ||
|
|
d4d39d1c07 | ||
|
|
73b5f1b18e | ||
|
|
837700bf63 | ||
|
|
53e6d7cc54 | ||
|
|
4206d1fe96 | ||
|
|
6566dbbf96 | ||
|
|
44c0c784d7 | ||
|
|
8ebf96444d | ||
|
|
900823cdb9 | ||
|
|
280745cae3 | ||
|
|
e85d3c9441 | ||
|
|
9d3a3a8dcd | ||
|
|
19535d0438 | ||
|
|
693e20b066 | ||
|
|
56ea1d6f36 | ||
|
|
95522d03f9 | ||
|
|
2bc290b57d | ||
|
|
c7c458f147 | ||
|
|
481427683e | ||
|
|
c798c7ae99 | ||
|
|
0422c1ac3e | ||
|
|
f0445fe26f | ||
|
|
a3628be024 | ||
|
|
a9c56892c3 | ||
|
|
015539c009 | ||
|
|
71844deab1 | ||
|
|
55fed6389e | ||
|
|
8ce5c2e007 | ||
|
|
6ecfbd8385 | ||
|
|
6c2bfd72cd | ||
|
|
8ea5be5b90 | ||
|
|
7f483056e0 | ||
|
|
a6f88d7f75 | ||
|
|
b757df0b5e | ||
|
|
b017046c8b | ||
|
|
a474fdf6ae | ||
|
|
729e688748 | ||
|
|
9ea4857cd9 | ||
|
|
8bf7bc0e85 | ||
|
|
26e344a6f6 | ||
|
|
8f0744077e | ||
|
|
ed99a37831 | ||
|
|
488d709d85 | ||
|
|
66454b082a | ||
|
|
70e83e62d9 | ||
|
|
d2ddb9193a | ||
|
|
5f78db90c7 | ||
|
|
c3a4702e79 | ||
|
|
e731cfee9e | ||
|
|
75de86fe97 | ||
|
|
6d4e97b4bb | ||
|
|
239d5ad562 | ||
|
|
e76f191922 | ||
|
|
a619ed2f22 | ||
|
|
838dfc35a1 | ||
|
|
6ecee6d561 | ||
|
|
c1827f93a9 | ||
|
|
aaf3f7c35f | ||
|
|
02fbea4eb0 | ||
|
|
bf9aef6eb7 | ||
|
|
230c07fd55 | ||
|
|
18de349dc3 | ||
|
|
cef1a5c194 | ||
|
|
99f8f0cb27 | ||
|
|
789c14134c | ||
|
|
5bb3b595aa | ||
|
|
609289c16a | ||
|
|
c8c348a182 | ||
|
|
725acd0199 | ||
|
|
166c27c70f | ||
|
|
3bdef20e85 | ||
|
|
50fba3f910 | ||
|
|
87d3f14392 | ||
|
|
30452c8d46 | ||
|
|
300f7723af | ||
|
|
491ba55f1e | ||
|
|
32519b8c08 | ||
|
|
7ffd9891a0 | ||
|
|
347a60f1f7 | ||
|
|
da69fe1354 | ||
|
|
746ca026ba | ||
|
|
a8c64675e5 | ||
|
|
3918f6eee2 | ||
|
|
8d44b43c82 | ||
|
|
f14c4367f8 | ||
|
|
3e3a320672 | ||
|
|
19efab0665 | ||
|
|
635f00ac71 | ||
|
|
2d2becccf6 | ||
|
|
ed0837a89b | ||
|
|
65fee89e14 | ||
|
|
8e180006f0 | ||
|
|
721c9666eb | ||
|
|
6f35401181 | ||
|
|
72ae2ce079 | ||
|
|
495ad3ea5c | ||
|
|
3def9df4b4 | ||
|
|
790a32d297 | ||
|
|
f7779224ef | ||
|
|
7d899ae088 | ||
|
|
7e67bdb7e2 | ||
|
|
0071780ff4 | ||
|
|
0a95431468 | ||
|
|
34525c5b11 | ||
|
|
6ac6d79fb6 | ||
|
|
f21a82ad43 | ||
|
|
73f6647f8d | ||
|
|
6fbb4609f9 | ||
|
|
802c908384 | ||
|
|
5d403056d0 | ||
|
|
8e2dd37155 | ||
|
|
9c7ed833fd | ||
|
|
f9d601bd8a | ||
|
|
152f473131 | ||
|
|
7015081a84 | ||
|
|
be6444cf96 | ||
|
|
98ba7c50e7 | ||
|
|
0c31d908cc | ||
|
|
9e776b22fb | ||
|
|
d6f8603656 | ||
|
|
9c42bee662 | ||
|
|
b96340dd46 | ||
|
|
a5ba01e219 | ||
|
|
d00e907735 | ||
|
|
418f9bd2bc | ||
|
|
28b4585dba | ||
|
|
d2669f0c99 | ||
|
|
c9921926e3 | ||
|
|
d7cd2ede01 | ||
|
|
ed21b65c98 | ||
|
|
8975589c43 | ||
|
|
27dca2e343 | ||
|
|
5c8261c34a | ||
|
|
cbc2dc82d8 | ||
|
|
09a7c5597d | ||
|
|
f73f18e90d |
11
.dockerignore
Normal file
11
.dockerignore
Normal file
@@ -0,0 +1,11 @@
|
||||
*.md
|
||||
.git
|
||||
.github/
|
||||
.gitignore
|
||||
.vscode/
|
||||
downloads/
|
||||
data/
|
||||
cache/
|
||||
docs/
|
||||
config.example.toml
|
||||
docker-compose.*
|
||||
5
.github/FUNDING.yml
vendored
Normal file
5
.github/FUNDING.yml
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
custom: [
|
||||
"https://afdian.com/a/unvapp"
|
||||
]
|
||||
39
.github/ISSUE_TEMPLATE/bug.yml
vendored
Normal file
39
.github/ISSUE_TEMPLATE/bug.yml
vendored
Normal file
@@ -0,0 +1,39 @@
|
||||
name: "👾 报告 bug"
|
||||
description: "报告 bug"
|
||||
labels:
|
||||
- "bug"
|
||||
assignees:
|
||||
- krau
|
||||
body:
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: "👾 问题描述"
|
||||
description: "What happened?"
|
||||
placeholder: "When called ... happens ..."
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: "⚡️ 预期行为"
|
||||
description: "What was expected?"
|
||||
placeholder: "It should be ..."
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: "📄 配置文件"
|
||||
description: "Please provide your config file"
|
||||
placeholder: "请自行隐去密钥信息"
|
||||
render: toml
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: "🔍 日志"
|
||||
description: "Please provide logs"
|
||||
placeholder: "可删除隐私信息"
|
||||
render: shell
|
||||
validations:
|
||||
required: true
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
## Thank you for contributing to the project :slightly_smiling_face:
|
||||
8
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
8
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
blank_issues_enabled: true
|
||||
contact_links:
|
||||
- name: 💬 不知道如何正确使用?
|
||||
url: https://github.com/krau/SaveAny-Bot/discussions
|
||||
about: "前往讨论区提问"
|
||||
- name: 📄 文档
|
||||
url: https://sabot.unv.app
|
||||
about: "查看文档"
|
||||
33
.github/ISSUE_TEMPLATE/feature.yml
vendored
Normal file
33
.github/ISSUE_TEMPLATE/feature.yml
vendored
Normal file
@@ -0,0 +1,33 @@
|
||||
name: "⭐️ 功能请求"
|
||||
description: "功能请求"
|
||||
labels:
|
||||
- "enhancement"
|
||||
assignees:
|
||||
- krau
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
# 请详细描述你想要的功能
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: "⭐️ Feature description"
|
||||
description: "What new feature you want to see?"
|
||||
placeholder: "Add ... in order to ..."
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: "🌈 Your view"
|
||||
description: "How do you see this feature will be used and/or implemented?"
|
||||
placeholder: "It should be like ..."
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: "🧐 Code example"
|
||||
description: "You can provide code (or pseudocode) example"
|
||||
placeholder: "Cool code that will work ..."
|
||||
render: Go
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
## Thank you for contributing to the project :slightly_smiling_face:
|
||||
39
.github/workflows/build-docker.yml
vendored
39
.github/workflows/build-docker.yml
vendored
@@ -20,6 +20,17 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Extract metadata for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=sha
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
@@ -33,28 +44,20 @@ jobs:
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
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
|
||||
|
||||
- name: Build and push Docker image
|
||||
id: build-and-push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
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=${{ fromJson(toJSON(github.event.repository.pushed_at)) }}
|
||||
|
||||
11
.github/workflows/build-release.yml
vendored
11
.github/workflows/build-release.yml
vendored
@@ -1,3 +1,5 @@
|
||||
name: Build Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
@@ -36,6 +38,9 @@ jobs:
|
||||
matrix:
|
||||
goos: [linux, darwin, windows]
|
||||
goarch: [amd64, arm64]
|
||||
exclude:
|
||||
- goos: windows
|
||||
goarch: arm64
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -58,9 +63,9 @@ jobs:
|
||||
README.md
|
||||
ldflags: >-
|
||||
-s -w
|
||||
-X "github.com/krau/SaveAny-Bot/common.Version=${{ env.VERSION }}"
|
||||
-X "github.com/krau/SaveAny-Bot/common.BuildTime=${{ format(github.event.repository.updated_at, 'yyyy-MM-dd HH:mm:ss') }}"
|
||||
-X "github.com/krau/SaveAny-Bot/common.GitCommit=${{ github.sha }}"
|
||||
-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 }}"
|
||||
binary_name: saveany-bot
|
||||
env:
|
||||
VERSION: ${{ env.VERSION }}
|
||||
|
||||
36
.github/workflows/docs.yml
vendored
Normal file
36
.github/workflows/docs.yml
vendored
Normal file
@@ -0,0 +1,36 @@
|
||||
name: Deploy Docs
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- "docs/**"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-22.04
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: true # Fetch Hugo themes (true OR recursive)
|
||||
fetch-depth: 0 # Fetch all history for .GitInfo and .Lastmod
|
||||
|
||||
- name: Setup Hugo
|
||||
uses: peaceiris/actions-hugo@v3
|
||||
with:
|
||||
hugo-version: '0.147.8'
|
||||
extended: true
|
||||
|
||||
- name: Build
|
||||
run: hugo --minify --destination public --source docs
|
||||
|
||||
- name: Deploy
|
||||
uses: peaceiris/actions-gh-pages@v3
|
||||
if: github.ref == 'refs/heads/main'
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
publish_dir: ./docs/public
|
||||
publish_branch: gh-pages
|
||||
17
.github/workflows/update-contributors.yml
vendored
Normal file
17
.github/workflows/update-contributors.yml
vendored
Normal file
@@ -0,0 +1,17 @@
|
||||
name: Update Contributors
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
contrib-readme-job:
|
||||
runs-on: ubuntu-latest
|
||||
name: A job to automate contrib in readme
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Contribute List
|
||||
uses: akhilmhdh/contributors-readme-action@v2.3.10
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -3,6 +3,8 @@ logs/
|
||||
tmp/
|
||||
data/
|
||||
downloads/
|
||||
cache/
|
||||
session.*
|
||||
cache.db
|
||||
cache.db
|
||||
.vscode/
|
||||
temp/
|
||||
.hugo_build.lock
|
||||
15
.vscode/launch.json
vendored
15
.vscode/launch.json
vendored
@@ -1,15 +0,0 @@
|
||||
{
|
||||
// 使用 IntelliSense 了解相关属性。
|
||||
// 悬停以查看现有属性的描述。
|
||||
// 欲了解更多信息,请访问: https://go.microsoft.com/fwlink/?linkid=830387
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Launch Package",
|
||||
"type": "go",
|
||||
"request": "launch",
|
||||
"mode": "auto",
|
||||
"program": "main.go",
|
||||
}
|
||||
]
|
||||
}
|
||||
22
Dockerfile
22
Dockerfile
@@ -1,13 +1,25 @@
|
||||
FROM golang:alpine AS builder
|
||||
|
||||
ARG VERSION="dev"
|
||||
ARG GitCommit="Unknown"
|
||||
ARG BuildTime="Unknown"
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY go.* ./
|
||||
RUN go mod download
|
||||
COPY go.mod go.sum ./
|
||||
RUN --mount=type=cache,target=/go/pkg/mod \
|
||||
go mod download
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -o saveany-bot .
|
||||
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}" \
|
||||
-o saveany-bot .
|
||||
|
||||
FROM alpine:latest
|
||||
|
||||
@@ -15,4 +27,4 @@ WORKDIR /app
|
||||
|
||||
COPY --from=builder /app/saveany-bot .
|
||||
|
||||
CMD ["./saveany-bot"]
|
||||
ENTRYPOINT ["/app/saveany-bot"]
|
||||
|
||||
143
README.md
143
README.md
@@ -1,108 +1,83 @@
|
||||
<div align="center">
|
||||
|
||||
# <img src="docs/logo.jpg" width="45" align="center"> Save Any Bot
|
||||
# <img src="docs/static/logo.png" width="45" align="center"> Save Any Bot
|
||||
|
||||
**简体中文** | [English](README_EN.md)
|
||||
**简体中文** | [English](https://sabot.unv.app/en/)
|
||||
|
||||
把 Telegram 的文件保存到各类存储端.
|
||||
|
||||
> _就像 PikPak Bot 一样_
|
||||
|
||||
</div
|
||||
|
||||
Demo Video:
|
||||
|
||||
<div align="center">
|
||||
|
||||
[SaveAny-Bot 演示视频 | The Demo of SaveAny-Bot.webm](https://github.com/user-attachments/assets/a0de2453-a4d1-4a12-81fb-9d84856dce09)
|
||||
把 Telegram 上的文件转存到多种存储端.
|
||||
|
||||
</div>
|
||||
|
||||
## 部署
|
||||
|
||||
### 从二进制文件部署
|
||||
请参考 [部署文档](https://sabot.unv.app/deployment/installation/)
|
||||
|
||||
在 [Release](https://github.com/krau/SaveAny-Bot/releases) 页面下载对应平台的二进制文件.
|
||||
## Features
|
||||
|
||||
在解压后目录新建 `config.toml` 文件, 参考 [config.toml.example](https://github.com/krau/SaveAny-Bot/blob/main/config.example.toml) 编辑配置文件.
|
||||
- 支持文档/视频/图片/贴纸… 甚至还有 Telegraph
|
||||
- 破解禁止保存的文件
|
||||
- 批量下载
|
||||
- 流式传输
|
||||
- 多用户
|
||||
- 基于存储规则的自动整理
|
||||
- 支持多种存储端:
|
||||
- Alist
|
||||
- Minio (S3 兼容)
|
||||
- WebDAV
|
||||
- Telegram (重传回指定聊天)
|
||||
- 本地磁盘
|
||||
|
||||
运行:
|
||||
## Sponsors
|
||||
|
||||
```bash
|
||||
chmod +x saveany-bot
|
||||
./saveany-bot
|
||||
```
|
||||
本项目受到 [YxVM](https://yxvm.com/) 与 [NodeSupport](https://github.com/NodeSeekDev/NodeSupport) 的支持.
|
||||
|
||||
#### 添加为 systemd 服务
|
||||
如果这个项目对你有帮助, 你可以考虑通过以下方式赞助我:
|
||||
|
||||
创建文件 `/etc/systemd/system/saveany-bot.service` 并写入以下内容:
|
||||
- [爱发电](https://afdian.com/a/unvapp)
|
||||
|
||||
```
|
||||
[Unit]
|
||||
Description=SaveAnyBot
|
||||
After=systemd-user-sessions.service
|
||||
## Contributors
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory=/yourpath/
|
||||
ExecStart=/yourpath/saveany-bot
|
||||
Restart=on-failure
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
设为开机启动并启动服务:
|
||||
|
||||
```bash
|
||||
systemctl enable --now saveany-bot
|
||||
```
|
||||
|
||||
### 使用 Docker 部署
|
||||
|
||||
#### Docker Compose
|
||||
|
||||
下载 [docker-compose.yml](https://github.com/krau/SaveAny-Bot/blob/main/docker-compose.yml) 文件, 在同目录下新建 `config.toml` 文件, 参考 [config.toml.example](https://github.com/krau/SaveAny-Bot/blob/main/config.example.toml) 编辑配置文件.
|
||||
|
||||
启动:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
#### Docker
|
||||
|
||||
```shell
|
||||
docker run -d --name saveany-bot \
|
||||
-v /path/to/config.toml:/app/config.toml \
|
||||
-v /path/to/downloads:/app/downloads \
|
||||
ghcr.io/krau/saveany-bot:latest
|
||||
```
|
||||
|
||||
## 更新
|
||||
|
||||
使用 `upgrade` 或 `up` 升级到最新版
|
||||
|
||||
```bash
|
||||
./saveany-bot upgrade
|
||||
```
|
||||
|
||||
如果是 Docker 部署, 使用以下命令更新:
|
||||
|
||||
```bash
|
||||
docker pull ghcr.io/krau/saveany-bot:latest
|
||||
docker restart saveany-bot
|
||||
```
|
||||
|
||||
## 使用
|
||||
|
||||
向 Bot 发送(转发)文件, 或发送公开频道的消息链接, 按照提示操作.
|
||||
|
||||
---
|
||||
<!-- readme: contributors -start -->
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="center">
|
||||
<a href="https://github.com/krau">
|
||||
<img src="https://avatars.githubusercontent.com/u/71133316?v=4" width="100;" alt="krau"/>
|
||||
<br />
|
||||
<sub><b>Krau</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"/>
|
||||
<br />
|
||||
<sub><b>Simon Twilight</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://github.com/ysicing">
|
||||
<img src="https://avatars.githubusercontent.com/u/8605565?v=4" width="100;" alt="ysicing"/>
|
||||
<br />
|
||||
<sub><b>缘生</b></sub>
|
||||
</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"/>
|
||||
<br />
|
||||
<sub><b>安和</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tbody>
|
||||
</table>
|
||||
<!-- readme: contributors -end -->
|
||||
|
||||
## Thanks
|
||||
|
||||
- [gotd](https://github.com/gotd/td)
|
||||
- [TG-FileStreamBot](https://github.com/EverythingSuckz/TG-FileStreamBot)
|
||||
- [gotgproto](https://github.com/celestix/gotgproto)
|
||||
- [tdl](https://github.com/iyear/tdl)
|
||||
- All the dependencies
|
||||
|
||||
108
README_EN.md
108
README_EN.md
@@ -1,108 +0,0 @@
|
||||
<div align="center">
|
||||
|
||||
# <img src="docs/logo.jpg" width="45" align="center"> Save Any Bot
|
||||
|
||||
[简体中文](README.md) | **English**
|
||||
|
||||
Save Telegram files to various storage endpoints.
|
||||
|
||||
> _Just like PikPak Bot_
|
||||
|
||||
</div>
|
||||
|
||||
Demo Video:
|
||||
|
||||
<div align="center">
|
||||
|
||||
[SaveAny-Bot Demo Video.webm](https://github.com/user-attachments/assets/a0de2453-a4d1-4a12-81fb-9d84856dce09)
|
||||
|
||||
</div>
|
||||
|
||||
## Deployment
|
||||
|
||||
### Deploy from Binary
|
||||
|
||||
Download the binary file for your platform from the [Release](https://github.com/krau/SaveAny-Bot/releases) page.
|
||||
|
||||
Create a `config.toml` file in the extracted directory, refer to [config.toml.example](https://github.com/krau/SaveAny-Bot/blob/main/config.example.toml) for configuration.
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
chmod +x saveany-bot
|
||||
./saveany-bot
|
||||
```
|
||||
|
||||
#### Add as systemd Service
|
||||
|
||||
Create file `/etc/systemd/system/saveany-bot.service` and write the following content:
|
||||
|
||||
```
|
||||
[Unit]
|
||||
Description=SaveAnyBot
|
||||
After=systemd-user-sessions.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory=/yourpath/
|
||||
ExecStart=/yourpath/saveany-bot
|
||||
Restart=on-failure
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
Enable auto-start and start the service:
|
||||
|
||||
```bash
|
||||
systemctl enable --now saveany-bot
|
||||
```
|
||||
|
||||
### Deploy with Docker
|
||||
|
||||
#### Docker Compose
|
||||
|
||||
Download [docker-compose.yml](https://github.com/krau/SaveAny-Bot/blob/main/docker-compose.yml) file and create a `config.toml` file in the same directory, refer to [config.toml.example](https://github.com/krau/SaveAny-Bot/blob/main/config.example.toml) for configuration.
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
#### Docker
|
||||
|
||||
```shell
|
||||
docker run -d --name saveany-bot \
|
||||
-v /path/to/config.toml:/app/config.toml \
|
||||
-v /path/to/downloads:/app/downloads \
|
||||
ghcr.io/krau/saveany-bot:latest
|
||||
```
|
||||
|
||||
## Update
|
||||
|
||||
Use `upgrade` or `up` command to upgrade to the latest version:
|
||||
|
||||
```bash
|
||||
./saveany-bot upgrade
|
||||
```
|
||||
|
||||
If deployed with Docker, use the following commands to update:
|
||||
|
||||
```bash
|
||||
docker pull ghcr.io/krau/saveany-bot:latest
|
||||
docker restart saveany-bot
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
Send (forward) files to the Bot and follow the prompts.
|
||||
|
||||
---
|
||||
|
||||
## Thanks
|
||||
|
||||
- [gotd](https://github.com/gotd/td)
|
||||
- [TG-FileStreamBot](https://github.com/EverythingSuckz/TG-FileStreamBot)
|
||||
- [gotgproto](https://github.com/celestix/gotgproto)
|
||||
- All the dependencies
|
||||
@@ -1,26 +0,0 @@
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/krau/SaveAny-Bot/bot"
|
||||
"github.com/krau/SaveAny-Bot/common"
|
||||
"github.com/krau/SaveAny-Bot/config"
|
||||
"github.com/krau/SaveAny-Bot/dao"
|
||||
"github.com/krau/SaveAny-Bot/logger"
|
||||
"github.com/krau/SaveAny-Bot/storage"
|
||||
)
|
||||
|
||||
func InitAll() {
|
||||
if err := config.Init(); err != nil {
|
||||
fmt.Println("加载配置文件失败: ", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
logger.InitLogger()
|
||||
logger.L.Info("正在启动 SaveAny-Bot...")
|
||||
storage.LoadStorages()
|
||||
common.Init()
|
||||
dao.Init()
|
||||
bot.Init()
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
package bot
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/celestix/gotgproto/dispatcher"
|
||||
"github.com/celestix/gotgproto/ext"
|
||||
"github.com/gotd/td/tg"
|
||||
"github.com/krau/SaveAny-Bot/dao"
|
||||
"github.com/krau/SaveAny-Bot/logger"
|
||||
"github.com/krau/SaveAny-Bot/storage"
|
||||
"github.com/krau/SaveAny-Bot/types"
|
||||
)
|
||||
|
||||
var (
|
||||
linkRegexString = `t.me/.*/\d+`
|
||||
linkRegex = regexp.MustCompile(linkRegexString)
|
||||
)
|
||||
|
||||
func handleLinkMessage(ctx *ext.Context, update *ext.Update) error {
|
||||
logger.L.Trace("Got link message")
|
||||
link := linkRegex.FindString(update.EffectiveMessage.Text)
|
||||
if link == "" {
|
||||
return dispatcher.ContinueGroups
|
||||
}
|
||||
strSlice := strings.Split(link, "/")
|
||||
if len(strSlice) < 3 {
|
||||
return dispatcher.ContinueGroups
|
||||
}
|
||||
messageID, err := strconv.Atoi(strSlice[2])
|
||||
if err != nil {
|
||||
logger.L.Errorf("解析消息 ID 失败: %s", err)
|
||||
ctx.Reply(update, ext.ReplyTextString("无法解析消息 ID"), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
chatUsername := strSlice[1]
|
||||
linkChat, err := ctx.ResolveUsername(chatUsername)
|
||||
if err != nil {
|
||||
logger.L.Errorf("解析 Chat ID 失败: %s", err)
|
||||
ctx.Reply(update, ext.ReplyTextString("无法解析 Chat ID"), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
if linkChat == nil {
|
||||
logger.L.Errorf("无法找到聊天: %s", chatUsername)
|
||||
ctx.Reply(update, ext.ReplyTextString("无法找到聊天"), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
user, err := dao.GetUserByChatID(update.GetUserChat().GetID())
|
||||
if err != nil {
|
||||
logger.L.Errorf("获取用户失败: %s", err)
|
||||
ctx.Reply(update, ext.ReplyTextString("获取用户失败"), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
storages := storage.GetUserStorages(user.ChatID)
|
||||
|
||||
if len(storages) == 0 {
|
||||
ctx.Reply(update, ext.ReplyTextString("无可用的存储"), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
replied, err := ctx.Reply(update, ext.ReplyTextString("正在获取文件..."), nil)
|
||||
if err != nil {
|
||||
logger.L.Errorf("回复失败: %s", err)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
|
||||
file, err := FileFromMessage(ctx, linkChat.GetID(), messageID, "")
|
||||
if err != nil {
|
||||
logger.L.Errorf("获取文件失败: %s", err)
|
||||
ctx.Reply(update, ext.ReplyTextString("获取文件失败: "+err.Error()), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
// TODO: Better file name
|
||||
if file.FileName == "" {
|
||||
logger.L.Warnf("文件名为空,使用生成的名称")
|
||||
file.FileName = fmt.Sprintf("%d_%d_%s", linkChat.GetID(), messageID, file.Hash())
|
||||
}
|
||||
|
||||
receivedFile := &types.ReceivedFile{
|
||||
Processing: false,
|
||||
FileName: file.FileName,
|
||||
ChatID: linkChat.GetID(),
|
||||
MessageID: messageID,
|
||||
ReplyMessageID: replied.ID,
|
||||
ReplyChatID: update.GetUserChat().GetID(),
|
||||
}
|
||||
if err := dao.SaveReceivedFile(receivedFile); err != nil {
|
||||
logger.L.Errorf("保存接收的文件失败: %s", err)
|
||||
ctx.EditMessage(update.EffectiveChat().GetID(), &tg.MessagesEditMessageRequest{
|
||||
Message: "无法保存文件: " + err.Error(),
|
||||
ID: replied.ID,
|
||||
})
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
if !user.Silent || user.DefaultStorage == "" {
|
||||
return ProvideSelectMessage(ctx, update, file, linkChat.GetID(), messageID, replied.ID)
|
||||
}
|
||||
return HandleSilentAddTask(ctx, update, user, &types.Task{
|
||||
Ctx: ctx,
|
||||
Status: types.Pending,
|
||||
File: file,
|
||||
StorageName: user.DefaultStorage,
|
||||
FileChatID: linkChat.GetID(),
|
||||
FileMessageID: messageID,
|
||||
ReplyMessageID: replied.ID,
|
||||
ReplyChatID: update.GetUserChat().GetID(),
|
||||
})
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
package bot
|
||||
|
||||
import (
|
||||
"sync"
|
||||
)
|
||||
|
||||
type ConversationType string
|
||||
|
||||
type ConversationState struct {
|
||||
sync.Mutex
|
||||
conversationType ConversationType
|
||||
InConversation bool
|
||||
data map[ConversationType]map[string]interface{}
|
||||
}
|
||||
|
||||
func (c *ConversationState) Reset() {
|
||||
c.Lock()
|
||||
defer c.Unlock()
|
||||
c.InConversation = false
|
||||
c.conversationType = ""
|
||||
c.data = make(map[ConversationType]map[string]interface{})
|
||||
}
|
||||
|
||||
func (c *ConversationState) SetConversationType(t ConversationType) {
|
||||
c.Lock()
|
||||
defer c.Unlock()
|
||||
c.conversationType = t
|
||||
}
|
||||
|
||||
func (c *ConversationState) GetData(key string) interface{} {
|
||||
if c.data == nil || c.data[c.conversationType] == nil {
|
||||
return nil
|
||||
}
|
||||
return c.data[c.conversationType][key]
|
||||
}
|
||||
|
||||
func (c *ConversationState) SetData(key string, value interface{}) {
|
||||
c.Lock()
|
||||
defer c.Unlock()
|
||||
if c.data == nil {
|
||||
c.data = make(map[ConversationType]map[string]interface{})
|
||||
}
|
||||
if c.data[c.conversationType] == nil {
|
||||
c.data[c.conversationType] = make(map[string]interface{})
|
||||
}
|
||||
c.data[c.conversationType][key] = value
|
||||
}
|
||||
|
||||
// TODO: Implement conversation handling
|
||||
// var userConversationState = make(map[int64]*ConversationState)
|
||||
|
||||
// func handleConversation(ctx *ext.Context, update *ext.Update) error {
|
||||
// userID := update.EffectiveUser().GetID()
|
||||
// state, ok := userConversationState[userID]
|
||||
// if !ok {
|
||||
// return dispatcher.ContinueGroups
|
||||
// }
|
||||
// if update.EffectiveMessage.Text == "/cancel" {
|
||||
// state.Reset()
|
||||
// ctx.Reply(update, ext.ReplyTextString("已取消"), nil)
|
||||
// return dispatcher.EndGroups
|
||||
// }
|
||||
// if !state.InConversation {
|
||||
// return dispatcher.ContinueGroups
|
||||
// }
|
||||
// return handleConversationState(ctx, update, state)
|
||||
// }
|
||||
|
||||
// func handleConversationState(ctx *ext.Context, update *ext.Update, state *ConversationState) error {
|
||||
// switch state.conversationType {
|
||||
// default:
|
||||
// logger.L.Errorf("Unknown conversation type: %s", state.conversationType)
|
||||
// }
|
||||
// return dispatcher.EndGroups
|
||||
// }
|
||||
437
bot/handlers.go
437
bot/handlers.go
@@ -1,437 +0,0 @@
|
||||
package bot
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"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/celestix/gotgproto/dispatcher"
|
||||
"github.com/celestix/gotgproto/dispatcher/handlers"
|
||||
"github.com/celestix/gotgproto/dispatcher/handlers/filters"
|
||||
"github.com/celestix/gotgproto/ext"
|
||||
"github.com/krau/SaveAny-Bot/config"
|
||||
"github.com/krau/SaveAny-Bot/dao"
|
||||
"github.com/krau/SaveAny-Bot/logger"
|
||||
"github.com/krau/SaveAny-Bot/queue"
|
||||
"github.com/krau/SaveAny-Bot/storage"
|
||||
"github.com/krau/SaveAny-Bot/types"
|
||||
)
|
||||
|
||||
func RegisterHandlers(dispatcher dispatcher.Dispatcher) {
|
||||
dispatcher.AddHandler(handlers.NewMessage(filters.Message.All, checkPermission))
|
||||
dispatcher.AddHandler(handlers.NewCommand("start", start))
|
||||
dispatcher.AddHandler(handlers.NewCommand("help", help))
|
||||
dispatcher.AddHandler(handlers.NewCommand("silent", silent))
|
||||
dispatcher.AddHandler(handlers.NewCommand("storage", storageCmd))
|
||||
dispatcher.AddHandler(handlers.NewCommand("save", saveCmd))
|
||||
linkRegexFilter, err := filters.Message.Regex(linkRegexString)
|
||||
if err != nil {
|
||||
logger.L.Panicf("创建正则表达式过滤器失败: %s", err)
|
||||
}
|
||||
dispatcher.AddHandler(handlers.NewMessage(linkRegexFilter, handleLinkMessage))
|
||||
dispatcher.AddHandler(handlers.NewCallbackQuery(filters.CallbackQuery.Prefix("add"), AddToQueue))
|
||||
dispatcher.AddHandler(handlers.NewCallbackQuery(filters.CallbackQuery.Prefix("set_default"), setDefaultStorage))
|
||||
dispatcher.AddHandler(handlers.NewMessage(filters.Message.Media, handleFileMessage))
|
||||
}
|
||||
|
||||
const noPermissionText string = `
|
||||
您不在白名单中, 无法使用此 Bot.
|
||||
您可以部署自己的实例: https://github.com/krau/SaveAny-Bot
|
||||
`
|
||||
|
||||
func checkPermission(ctx *ext.Context, update *ext.Update) error {
|
||||
userID := update.GetUserChat().GetID()
|
||||
if !slice.Contain(config.Cfg.GetUsersID(), userID) {
|
||||
ctx.Reply(update, ext.ReplyTextString(noPermissionText), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
return dispatcher.ContinueGroups
|
||||
}
|
||||
|
||||
func start(ctx *ext.Context, update *ext.Update) error {
|
||||
if err := dao.CreateUser(update.GetUserChat().GetID()); err != nil {
|
||||
logger.L.Errorf("创建用户失败: %s", err)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
return help(ctx, update)
|
||||
}
|
||||
|
||||
const helpText string = `
|
||||
Save Any Bot - 转存你的 Telegram 文件
|
||||
命令:
|
||||
/start - 开始使用
|
||||
/help - 显示帮助
|
||||
/silent - 开关静默模式
|
||||
/storage - 设置默认存储位置
|
||||
/save [自定义文件名] - 保存文件
|
||||
|
||||
静默模式: 开启后 Bot 直接保存到收到的文件到默认位置, 不再询问
|
||||
|
||||
默认存储位置: 在静默模式下保存到的位置
|
||||
|
||||
向 Bot 发送(转发)文件, 或发送一个公开频道的消息链接以保存文件
|
||||
`
|
||||
|
||||
func help(ctx *ext.Context, update *ext.Update) error {
|
||||
ctx.Reply(update, ext.ReplyTextString(helpText), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
|
||||
func silent(ctx *ext.Context, update *ext.Update) error {
|
||||
user, err := dao.GetUserByChatID(update.GetUserChat().GetID())
|
||||
if err != nil {
|
||||
logger.L.Errorf("获取用户失败: %s", err)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
user.Silent = !user.Silent
|
||||
if err := dao.UpdateUser(user); err != nil {
|
||||
logger.L.Errorf("更新用户失败: %s", err)
|
||||
ctx.Reply(update, ext.ReplyTextString("更新用户失败"), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
ctx.Reply(update, ext.ReplyTextString(fmt.Sprintf("已%s静默模式", map[bool]string{true: "开启", false: "关闭"}[user.Silent])), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
|
||||
func saveCmd(ctx *ext.Context, update *ext.Update) error {
|
||||
res, ok := update.EffectiveMessage.GetReplyTo()
|
||||
if !ok || res == nil {
|
||||
ctx.Reply(update, ext.ReplyTextString("请回复要保存的文件"), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
replyHeader, ok := res.(*tg.MessageReplyHeader)
|
||||
if !ok {
|
||||
ctx.Reply(update, ext.ReplyTextString("请回复要保存的文件"), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
replyToMsgID, ok := replyHeader.GetReplyToMsgID()
|
||||
if !ok {
|
||||
ctx.Reply(update, ext.ReplyTextString("请回复要保存的文件"), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
|
||||
user, err := dao.GetUserByChatID(update.GetUserChat().GetID())
|
||||
if err != nil {
|
||||
logger.L.Errorf("获取用户失败: %s", err)
|
||||
ctx.Reply(update, ext.ReplyTextString("获取用户失败"), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
|
||||
storages := storage.GetUserStorages(user.ChatID)
|
||||
|
||||
if len(storages) == 0 {
|
||||
ctx.Reply(update, ext.ReplyTextString("无可用的存储"), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
|
||||
msg, err := GetTGMessage(ctx, update.EffectiveChat().GetID(), replyToMsgID)
|
||||
if err != nil {
|
||||
logger.L.Errorf("获取消息失败: %s", err)
|
||||
ctx.Reply(update, ext.ReplyTextString("无法获取消息"), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
|
||||
supported, _ := supportedMediaFilter(msg)
|
||||
if !supported {
|
||||
ctx.Reply(update, ext.ReplyTextString("不支持的消息类型或消息中没有文件"), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
|
||||
replied, err := ctx.Reply(update, ext.ReplyTextString("正在获取文件信息..."), nil)
|
||||
if err != nil {
|
||||
logger.L.Errorf("回复失败: %s", err)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
|
||||
cmdText := update.EffectiveMessage.Text
|
||||
customFileName := strings.TrimSpace(strings.TrimPrefix(cmdText, "/save"))
|
||||
|
||||
file, err := FileFromMessage(ctx, update.EffectiveChat().GetID(), msg.ID, customFileName)
|
||||
if err != nil {
|
||||
logger.L.Errorf("获取文件失败: %s", err)
|
||||
ctx.EditMessage(update.EffectiveChat().GetID(), &tg.MessagesEditMessageRequest{
|
||||
Message: fmt.Sprintf("获取文件失败: %s", err),
|
||||
ID: replied.ID,
|
||||
})
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
|
||||
// TODO: better file name
|
||||
if file.FileName == "" {
|
||||
file.FileName = fmt.Sprintf("%d_%d_%s", update.EffectiveChat().GetID(), replyToMsgID, file.Hash())
|
||||
}
|
||||
receivedFile := &types.ReceivedFile{
|
||||
Processing: false,
|
||||
FileName: file.FileName,
|
||||
ChatID: update.EffectiveChat().GetID(),
|
||||
MessageID: replyToMsgID,
|
||||
ReplyMessageID: replied.ID,
|
||||
ReplyChatID: update.GetUserChat().GetID(),
|
||||
}
|
||||
|
||||
if err := dao.SaveReceivedFile(receivedFile); err != nil {
|
||||
logger.L.Errorf("保存接收的文件失败: %s", err)
|
||||
if _, err := ctx.EditMessage(update.EffectiveChat().GetID(), &tg.MessagesEditMessageRequest{
|
||||
Message: fmt.Sprintf("保存接收的文件失败: %s", err),
|
||||
ID: replied.ID,
|
||||
}); err != nil {
|
||||
logger.L.Errorf("编辑消息失败: %s", err)
|
||||
}
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
if !user.Silent || user.DefaultStorage == "" {
|
||||
return ProvideSelectMessage(ctx, update, file, update.EffectiveChat().GetID(), msg.ID, replied.ID)
|
||||
}
|
||||
return HandleSilentAddTask(ctx, update, user, &types.Task{
|
||||
Ctx: ctx,
|
||||
Status: types.Pending,
|
||||
File: file,
|
||||
StorageName: user.DefaultStorage,
|
||||
FileChatID: update.EffectiveChat().GetID(),
|
||||
ReplyMessageID: replied.ID,
|
||||
ReplyChatID: update.GetUserChat().GetID(),
|
||||
FileMessageID: msg.ID,
|
||||
UserID: user.ChatID,
|
||||
})
|
||||
}
|
||||
|
||||
func storageCmd(ctx *ext.Context, update *ext.Update) error {
|
||||
user, err := dao.GetUserByChatID(update.GetUserChat().GetID())
|
||||
if err != nil {
|
||||
logger.L.Errorf("获取用户失败: %s", err)
|
||||
ctx.Reply(update, ext.ReplyTextString("获取用户失败"), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
storages := storage.GetUserStorages(user.ChatID)
|
||||
if len(storages) == 0 {
|
||||
ctx.Reply(update, ext.ReplyTextString("无可用的存储"), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
|
||||
ctx.Reply(update, ext.ReplyTextString("请选择要设为默认的存储位置"), &ext.ReplyOpts{
|
||||
Markup: getSetDefaultStorageMarkup(user.ChatID, storages),
|
||||
})
|
||||
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
|
||||
func setDefaultStorage(ctx *ext.Context, update *ext.Update) error {
|
||||
args := strings.Split(string(update.CallbackQuery.Data), " ")
|
||||
userID, _ := strconv.Atoi(args[1])
|
||||
storageNameHash := args[2]
|
||||
if userID != int(update.CallbackQuery.GetUserID()) {
|
||||
ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{
|
||||
QueryID: update.CallbackQuery.QueryID,
|
||||
Alert: true,
|
||||
Message: "你没有权限",
|
||||
CacheTime: 5,
|
||||
})
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
storageName := storageHashName[storageNameHash]
|
||||
selectedStorage, err := storage.GetStorageByName(storageName)
|
||||
|
||||
if err != nil {
|
||||
logger.L.Errorf("获取指定存储失败: %s", err)
|
||||
ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{
|
||||
QueryID: update.CallbackQuery.QueryID,
|
||||
Alert: true,
|
||||
Message: "获取指定存储失败",
|
||||
CacheTime: 5,
|
||||
})
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
user, err := dao.GetUserByChatID(int64(userID))
|
||||
if err != nil {
|
||||
logger.L.Errorf("Failed to get user: %s", err)
|
||||
ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{
|
||||
QueryID: update.CallbackQuery.QueryID,
|
||||
Alert: true,
|
||||
Message: "获取用户失败",
|
||||
CacheTime: 5,
|
||||
})
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
user.DefaultStorage = storageName
|
||||
if err := dao.UpdateUser(user); err != nil {
|
||||
logger.L.Errorf("Failed to update user: %s", err)
|
||||
ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{
|
||||
QueryID: update.CallbackQuery.QueryID,
|
||||
Alert: true,
|
||||
Message: "更新用户失败",
|
||||
CacheTime: 5,
|
||||
})
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
ctx.EditMessage(update.EffectiveChat().GetID(), &tg.MessagesEditMessageRequest{
|
||||
Message: fmt.Sprintf("已将 %s (%s) 设为默认存储位置", selectedStorage.Name(), selectedStorage.Type()),
|
||||
ID: update.CallbackQuery.GetMsgID(),
|
||||
})
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
|
||||
func handleFileMessage(ctx *ext.Context, update *ext.Update) error {
|
||||
logger.L.Trace("Got media: ", update.EffectiveMessage.Media.TypeName())
|
||||
supported, err := supportedMediaFilter(update.EffectiveMessage.Message)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !supported {
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
|
||||
user, err := dao.GetUserByChatID(update.GetUserChat().GetID())
|
||||
if err != nil {
|
||||
logger.L.Errorf("获取用户失败: %s", err)
|
||||
ctx.Reply(update, ext.ReplyTextString("获取用户失败"), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
storages := storage.GetUserStorages(user.ChatID)
|
||||
if len(storages) == 0 {
|
||||
ctx.Reply(update, ext.ReplyTextString("无可用的存储"), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
|
||||
msg, err := ctx.Reply(update, ext.ReplyTextString("正在获取文件信息..."), nil)
|
||||
if err != nil {
|
||||
logger.L.Errorf("回复失败: %s", err)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
media := update.EffectiveMessage.Media
|
||||
file, err := FileFromMedia(media, "")
|
||||
if err != nil {
|
||||
logger.L.Errorf("获取文件失败: %s", err)
|
||||
ctx.Reply(update, ext.ReplyTextString(fmt.Sprintf("获取文件失败: %s", err)), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
if file.FileName == "" {
|
||||
file.FileName = fmt.Sprintf("%d_%d_%s", update.EffectiveChat().GetID(), update.EffectiveMessage.ID, file.Hash())
|
||||
}
|
||||
|
||||
if err := dao.SaveReceivedFile(&types.ReceivedFile{
|
||||
Processing: false,
|
||||
FileName: file.FileName,
|
||||
ChatID: update.EffectiveChat().GetID(),
|
||||
MessageID: update.EffectiveMessage.ID,
|
||||
ReplyMessageID: msg.ID,
|
||||
ReplyChatID: update.GetUserChat().GetID(),
|
||||
}); err != nil {
|
||||
logger.L.Errorf("添加接收的文件失败: %s", err)
|
||||
if _, err := ctx.EditMessage(update.EffectiveChat().GetID(), &tg.MessagesEditMessageRequest{
|
||||
Message: fmt.Sprintf("添加接收的文件失败: %s", err),
|
||||
ID: msg.ID,
|
||||
}); err != nil {
|
||||
logger.L.Errorf("编辑消息失败: %s", err)
|
||||
}
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
|
||||
if !user.Silent || user.DefaultStorage == "" {
|
||||
return ProvideSelectMessage(ctx, update, file, update.EffectiveChat().GetID(), update.EffectiveMessage.ID, msg.ID)
|
||||
}
|
||||
return HandleSilentAddTask(ctx, update, user, &types.Task{
|
||||
Ctx: ctx,
|
||||
Status: types.Pending,
|
||||
File: file,
|
||||
StorageName: user.DefaultStorage,
|
||||
FileChatID: update.EffectiveChat().GetID(),
|
||||
ReplyMessageID: msg.ID,
|
||||
ReplyChatID: update.GetUserChat().GetID(),
|
||||
FileMessageID: update.EffectiveMessage.ID,
|
||||
UserID: user.ChatID,
|
||||
})
|
||||
}
|
||||
|
||||
func AddToQueue(ctx *ext.Context, update *ext.Update) error {
|
||||
if !slice.Contain(config.Cfg.GetUsersID(), update.CallbackQuery.UserID) {
|
||||
ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{
|
||||
QueryID: update.CallbackQuery.QueryID,
|
||||
Alert: true,
|
||||
Message: "你没有权限",
|
||||
CacheTime: 5,
|
||||
})
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
args := strings.Split(string(update.CallbackQuery.Data), " ")
|
||||
fileChatID, _ := strconv.Atoi(args[1])
|
||||
fileMessageID, _ := strconv.Atoi(args[2])
|
||||
storageNameHash := args[3]
|
||||
storageName := storageHashName[storageNameHash]
|
||||
if storageName == "" {
|
||||
logger.L.Errorf("未知存储位置哈希: %d", storageNameHash)
|
||||
ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{
|
||||
QueryID: update.CallbackQuery.QueryID,
|
||||
Alert: true,
|
||||
Message: "未知存储位置",
|
||||
CacheTime: 5,
|
||||
})
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
logger.L.Tracef("Got add to queue: chatID: %d, messageID: %d, storage: %s", fileChatID, fileMessageID, storageName)
|
||||
record, err := dao.GetReceivedFileByChatAndMessageID(int64(fileChatID), fileMessageID)
|
||||
if err != nil {
|
||||
logger.L.Errorf("获取记录失败: %s", err)
|
||||
ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{
|
||||
QueryID: update.CallbackQuery.QueryID,
|
||||
Alert: true,
|
||||
Message: "查询记录失败",
|
||||
CacheTime: 5,
|
||||
})
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
if update.CallbackQuery.MsgID != record.ReplyMessageID {
|
||||
record.ReplyMessageID = update.CallbackQuery.MsgID
|
||||
if err := dao.SaveReceivedFile(record); err != nil {
|
||||
logger.L.Errorf("更新接收的文件失败: %s", err)
|
||||
}
|
||||
}
|
||||
file, err := FileFromMessage(ctx, record.ChatID, record.MessageID, record.FileName)
|
||||
if err != nil {
|
||||
logger.L.Errorf("获取消息中的文件失败: %s", err)
|
||||
ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{
|
||||
QueryID: update.CallbackQuery.QueryID,
|
||||
Alert: true,
|
||||
Message: fmt.Sprintf("获取消息中的文件失败: %s", err),
|
||||
CacheTime: 5,
|
||||
})
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
|
||||
queue.AddTask(types.Task{
|
||||
Ctx: ctx,
|
||||
Status: types.Pending,
|
||||
File: file,
|
||||
StorageName: storageName,
|
||||
FileChatID: record.ChatID,
|
||||
ReplyMessageID: record.ReplyMessageID,
|
||||
FileMessageID: record.MessageID,
|
||||
ReplyChatID: record.ReplyChatID,
|
||||
UserID: update.EffectiveUser().GetID(),
|
||||
})
|
||||
|
||||
entityBuilder := entity.Builder{}
|
||||
var entities []tg.MessageEntityClass
|
||||
text := fmt.Sprintf("已添加到任务队列\n文件名: %s\n当前排队任务数: %d", record.FileName, queue.Len())
|
||||
if err := styling.Perform(&entityBuilder,
|
||||
styling.Plain("已添加到任务队列\n文件名: "),
|
||||
styling.Code(record.FileName),
|
||||
styling.Plain("\n当前排队任务数: "),
|
||||
styling.Bold(strconv.Itoa(queue.Len())),
|
||||
); err != nil {
|
||||
logger.L.Errorf("Failed to build entity: %s", err)
|
||||
} else {
|
||||
text, entities = entityBuilder.Complete()
|
||||
}
|
||||
|
||||
ctx.EditMessage(update.EffectiveChat().GetID(), &tg.MessagesEditMessageRequest{
|
||||
Message: text,
|
||||
Entities: entities,
|
||||
ID: record.ReplyMessageID,
|
||||
})
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
244
bot/utils.go
244
bot/utils.go
@@ -1,244 +0,0 @@
|
||||
package bot
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/celestix/gotgproto/dispatcher"
|
||||
"github.com/celestix/gotgproto/ext"
|
||||
"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"
|
||||
"github.com/krau/SaveAny-Bot/dao"
|
||||
"github.com/krau/SaveAny-Bot/logger"
|
||||
"github.com/krau/SaveAny-Bot/queue"
|
||||
"github.com/krau/SaveAny-Bot/storage"
|
||||
"github.com/krau/SaveAny-Bot/types"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrEmptyDocument = errors.New("document is empty")
|
||||
ErrEmptyPhoto = errors.New("photo is empty")
|
||||
ErrEmptyPhotoSize = errors.New("photo size is empty")
|
||||
ErrEmptyPhotoSizes = errors.New("photo size slice is empty")
|
||||
ErrNoStorages = errors.New("no available storage")
|
||||
)
|
||||
|
||||
func supportedMediaFilter(m *tg.Message) (bool, error) {
|
||||
if not := m.Media == nil; not {
|
||||
return false, dispatcher.EndGroups
|
||||
}
|
||||
switch m.Media.(type) {
|
||||
case *tg.MessageMediaDocument:
|
||||
return true, nil
|
||||
case *tg.MessageMediaPhoto:
|
||||
return true, nil
|
||||
default:
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
|
||||
// for callback data
|
||||
var storageHashName = map[string]string{}
|
||||
|
||||
func getSelectStorageMarkup(userChatID int64, fileChatID, fileMessageID int) (*tg.ReplyInlineMarkup, error) {
|
||||
user, err := dao.GetUserByChatID(userChatID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
storages := storage.GetUserStorages(user.ChatID)
|
||||
|
||||
buttons := make([]tg.KeyboardButtonClass, 0)
|
||||
for _, storage := range storages {
|
||||
nameHash := common.HashString(storage.Name())
|
||||
storageHashName[nameHash] = storage.Name()
|
||||
buttons = append(buttons, &tg.KeyboardButtonCallback{
|
||||
Text: storage.Name(),
|
||||
Data: []byte(fmt.Sprintf("add %d %d %s", fileChatID, fileMessageID, nameHash)),
|
||||
})
|
||||
}
|
||||
markup := &tg.ReplyInlineMarkup{}
|
||||
for i := 0; i < len(buttons); i += 3 {
|
||||
row := tg.KeyboardButtonRow{}
|
||||
row.Buttons = buttons[i:min(i+3, len(buttons))]
|
||||
markup.Rows = append(markup.Rows, row)
|
||||
}
|
||||
return markup, nil
|
||||
}
|
||||
|
||||
func getSetDefaultStorageMarkup(userChatID int64, storages []storage.Storage) *tg.ReplyInlineMarkup {
|
||||
buttons := make([]tg.KeyboardButtonClass, 0)
|
||||
for _, storage := range storages {
|
||||
nameHash := common.HashString(storage.Name())
|
||||
storageHashName[nameHash] = storage.Name()
|
||||
buttons = append(buttons, &tg.KeyboardButtonCallback{
|
||||
Text: storage.Name(),
|
||||
Data: []byte(fmt.Sprintf("set_default %d %s", userChatID, nameHash)),
|
||||
})
|
||||
}
|
||||
markup := &tg.ReplyInlineMarkup{}
|
||||
for i := 0; i < len(buttons); i += 3 {
|
||||
row := tg.KeyboardButtonRow{}
|
||||
row.Buttons = buttons[i:min(i+3, len(buttons))]
|
||||
markup.Rows = append(markup.Rows, row)
|
||||
}
|
||||
return markup
|
||||
|
||||
}
|
||||
|
||||
func FileFromMedia(media tg.MessageMediaClass, customFileName string) (*types.File, error) {
|
||||
switch media := media.(type) {
|
||||
case *tg.MessageMediaDocument:
|
||||
document, ok := media.Document.AsNotEmpty()
|
||||
if !ok {
|
||||
return nil, ErrEmptyDocument
|
||||
}
|
||||
if customFileName != "" {
|
||||
return &types.File{
|
||||
Location: document.AsInputDocumentFileLocation(),
|
||||
FileSize: document.Size,
|
||||
FileName: customFileName,
|
||||
}, nil
|
||||
}
|
||||
fileName := ""
|
||||
for _, attribute := range document.Attributes {
|
||||
if name, ok := attribute.(*tg.DocumentAttributeFilename); ok {
|
||||
fileName = name.GetFileName()
|
||||
break
|
||||
}
|
||||
}
|
||||
return &types.File{
|
||||
Location: document.AsInputDocumentFileLocation(),
|
||||
FileSize: document.Size,
|
||||
FileName: fileName,
|
||||
}, nil
|
||||
case *tg.MessageMediaPhoto:
|
||||
photo, ok := media.Photo.AsNotEmpty()
|
||||
if !ok {
|
||||
return nil, ErrEmptyPhoto
|
||||
}
|
||||
sizes := photo.Sizes
|
||||
if len(sizes) == 0 {
|
||||
return nil, ErrEmptyPhotoSizes
|
||||
}
|
||||
photoSize := sizes[len(sizes)-1]
|
||||
size, ok := photoSize.AsNotEmpty()
|
||||
if !ok {
|
||||
return nil, ErrEmptyPhotoSize
|
||||
}
|
||||
location := new(tg.InputPhotoFileLocation)
|
||||
location.ID = photo.GetID()
|
||||
location.AccessHash = photo.GetAccessHash()
|
||||
location.FileReference = photo.GetFileReference()
|
||||
location.ThumbSize = size.GetType()
|
||||
fileName := customFileName
|
||||
if fileName == "" {
|
||||
fileName = fmt.Sprintf("photo_%s_%d.jpg", time.Now().Format("2006-01-02_15-04-05"), photo.GetID())
|
||||
}
|
||||
return &types.File{
|
||||
Location: location,
|
||||
FileSize: 0,
|
||||
FileName: fileName,
|
||||
}, nil
|
||||
|
||||
}
|
||||
return nil, fmt.Errorf("unexpected type %T", media)
|
||||
}
|
||||
|
||||
func FileFromMessage(ctx *ext.Context, chatID int64, messageID int, customFileName string) (*types.File, error) {
|
||||
key := fmt.Sprintf("file:%d:%d", chatID, messageID)
|
||||
logger.L.Debugf("Getting file: %s", key)
|
||||
var cachedFile types.File
|
||||
err := common.Cache.Get(key, &cachedFile)
|
||||
if err == nil {
|
||||
return &cachedFile, nil
|
||||
}
|
||||
message, err := GetTGMessage(ctx, chatID, messageID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
file, err := FileFromMedia(message.Media, customFileName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := common.Cache.Set(key, file, 3600); err != nil {
|
||||
logger.L.Errorf("Failed to cache file: %s", err)
|
||||
}
|
||||
return file, nil
|
||||
}
|
||||
|
||||
func GetTGMessage(ctx *ext.Context, chatId int64, messageID int) (*tg.Message, error) {
|
||||
logger.L.Debugf("Fetching message: %d", messageID)
|
||||
messages, err := ctx.GetMessages(chatId, []tg.InputMessageClass{&tg.InputMessageID{ID: messageID}})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(messages) == 0 {
|
||||
return nil, errors.New("no messages found")
|
||||
}
|
||||
msg := messages[0]
|
||||
tgMessage, ok := msg.(*tg.Message)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unexpected message type: %T", msg)
|
||||
}
|
||||
return tgMessage, nil
|
||||
}
|
||||
|
||||
func ProvideSelectMessage(ctx *ext.Context, update *ext.Update, file *types.File, chatID int64, fileMsgID, toEditMsgID int) error {
|
||||
entityBuilder := entity.Builder{}
|
||||
var entities []tg.MessageEntityClass
|
||||
text := fmt.Sprintf("文件名: %s\n请选择存储位置", file.FileName)
|
||||
if err := styling.Perform(&entityBuilder,
|
||||
styling.Plain("文件名: "),
|
||||
styling.Code(file.FileName),
|
||||
styling.Plain("\n请选择存储位置"),
|
||||
); err != nil {
|
||||
logger.L.Errorf("Failed to build entity: %s", err)
|
||||
} else {
|
||||
text, entities = entityBuilder.Complete()
|
||||
}
|
||||
markup, err := getSelectStorageMarkup(update.EffectiveUser().GetID(), int(chatID), fileMsgID)
|
||||
if errors.Is(err, ErrNoStorages) {
|
||||
logger.L.Errorf("Failed to get select storage markup: %s", err)
|
||||
ctx.EditMessage(update.EffectiveChat().GetID(), &tg.MessagesEditMessageRequest{
|
||||
Message: "无可用存储",
|
||||
ID: toEditMsgID,
|
||||
})
|
||||
return dispatcher.EndGroups
|
||||
} else if err != nil {
|
||||
logger.L.Errorf("Failed to get select storage markup: %s", err)
|
||||
ctx.EditMessage(update.EffectiveChat().GetID(), &tg.MessagesEditMessageRequest{
|
||||
Message: "无法获取存储",
|
||||
ID: toEditMsgID,
|
||||
})
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
_, err = ctx.EditMessage(update.EffectiveChat().GetID(), &tg.MessagesEditMessageRequest{
|
||||
Message: text,
|
||||
Entities: entities,
|
||||
ReplyMarkup: markup,
|
||||
ID: toEditMsgID,
|
||||
})
|
||||
if err != nil {
|
||||
logger.L.Errorf("Failed to reply: %s", err)
|
||||
}
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
|
||||
func HandleSilentAddTask(ctx *ext.Context, update *ext.Update, user *types.User, task *types.Task) error {
|
||||
if user.DefaultStorage == "" {
|
||||
ctx.EditMessage(update.EffectiveChat().GetID(), &tg.MessagesEditMessageRequest{
|
||||
Message: "请先使用 /storage 设置默认存储位置",
|
||||
ID: task.ReplyMessageID,
|
||||
})
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
queue.AddTask(*task)
|
||||
ctx.EditMessage(update.EffectiveChat().GetID(), &tg.MessagesEditMessageRequest{
|
||||
Message: fmt.Sprintf("已添加到队列: %s\n当前排队任务数: %d", task.FileName(), queue.Len()),
|
||||
ID: task.ReplyMessageID,
|
||||
})
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
@@ -2,34 +2,25 @@ package bot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/url"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/celestix/gotgproto"
|
||||
"github.com/celestix/gotgproto/dispatcher"
|
||||
"github.com/celestix/gotgproto/ext"
|
||||
"github.com/celestix/gotgproto/sessionMaker"
|
||||
"github.com/glebarez/sqlite"
|
||||
"github.com/charmbracelet/log"
|
||||
"github.com/gotd/td/telegram/dcs"
|
||||
"github.com/gotd/td/tg"
|
||||
"github.com/krau/SaveAny-Bot/client/bot/handlers"
|
||||
"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/logger"
|
||||
"github.com/ncruces/go-sqlite3/gormlite"
|
||||
"golang.org/x/net/proxy"
|
||||
)
|
||||
|
||||
var Client *gotgproto.Client
|
||||
|
||||
func newProxyDialer(proxyUrl string) (proxy.Dialer, error) {
|
||||
url, err := url.Parse(proxyUrl)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return proxy.FromURL(url, proxy.Direct)
|
||||
}
|
||||
|
||||
func Init() {
|
||||
logger.L.Info("初始化 Telegram 客户端...")
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
func Init(ctx context.Context) {
|
||||
log.FromContext(ctx).Info("初始化 Bot...")
|
||||
resultChan := make(chan struct {
|
||||
client *gotgproto.Client
|
||||
err error
|
||||
@@ -37,7 +28,7 @@ func Init() {
|
||||
go func() {
|
||||
var resolver dcs.Resolver
|
||||
if config.Cfg.Telegram.Proxy.Enable && config.Cfg.Telegram.Proxy.URL != "" {
|
||||
dialer, err := newProxyDialer(config.Cfg.Telegram.Proxy.URL)
|
||||
dialer, err := netutil.NewProxyDialer(config.Cfg.Telegram.Proxy.URL)
|
||||
if err != nil {
|
||||
resultChan <- struct {
|
||||
client *gotgproto.Client
|
||||
@@ -51,14 +42,22 @@ func Init() {
|
||||
} else {
|
||||
resolver = dcs.DefaultResolver()
|
||||
}
|
||||
client, err := gotgproto.NewClient(config.Cfg.Telegram.AppID,
|
||||
client, err := gotgproto.NewClient(
|
||||
config.Cfg.Telegram.AppID,
|
||||
config.Cfg.Telegram.AppHash,
|
||||
gotgproto.ClientTypeBot(config.Cfg.Telegram.Token),
|
||||
&gotgproto.ClientOpts{
|
||||
Session: sessionMaker.SqlSession(sqlite.Open("data/session.db")),
|
||||
Session: sessionMaker.SqlSession(gormlite.Open(config.Cfg.DB.Session)),
|
||||
DisableCopyright: true,
|
||||
Middlewares: FloodWaitMiddleware(),
|
||||
Middlewares: middleware.NewDefaultMiddlewares(ctx, 5*time.Minute),
|
||||
Resolver: resolver,
|
||||
Context: ctx,
|
||||
MaxRetries: config.Cfg.Telegram.RpcRetry,
|
||||
AutoFetchReply: true,
|
||||
ErrorHandler: func(ctx *ext.Context, u *ext.Update, s string) error {
|
||||
log.FromContext(ctx).Errorf("Unhandled error: %s", s)
|
||||
return dispatcher.EndGroups
|
||||
},
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
@@ -68,6 +67,9 @@ func Init() {
|
||||
}{nil, err}
|
||||
return
|
||||
}
|
||||
client.API().BotsSetBotCommands(ctx, &tg.BotsSetBotCommandsRequest{
|
||||
Scope: &tg.BotCommandScopeDefault{},
|
||||
})
|
||||
_, err = client.API().BotsSetBotCommands(ctx, &tg.BotsSetBotCommandsRequest{
|
||||
Scope: &tg.BotCommandScopeDefault{},
|
||||
Commands: []tg.BotCommand{
|
||||
@@ -76,6 +78,8 @@ func Init() {
|
||||
{Command: "silent", Description: "开启/关闭静默模式"},
|
||||
{Command: "storage", Description: "设置默认存储端"},
|
||||
{Command: "save", Description: "保存所回复的文件"},
|
||||
{Command: "dir", Description: "管理存储文件夹"},
|
||||
{Command: "rule", Description: "管理规则"},
|
||||
},
|
||||
})
|
||||
resultChan <- struct {
|
||||
@@ -86,15 +90,12 @@ func Init() {
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
logger.L.Fatal("初始化客户端失败: 超时")
|
||||
os.Exit(1)
|
||||
log.FromContext(ctx).Errorf("已取消 Bot 初始化: %s", ctx.Err())
|
||||
case result := <-resultChan:
|
||||
if result.err != nil {
|
||||
logger.L.Fatalf("初始化客户端失败: %s", result.err)
|
||||
os.Exit(1)
|
||||
log.FromContext(ctx).Fatalf("初始化 Bot 失败: %s", result.err)
|
||||
}
|
||||
Client = result.client
|
||||
RegisterHandlers(Client.Dispatcher)
|
||||
logger.L.Info("客户端初始化完成")
|
||||
handlers.Register(result.client.Dispatcher)
|
||||
log.FromContext(ctx).Info("Bot 初始化完成")
|
||||
}
|
||||
}
|
||||
80
client/bot/handlers/add_task.go
Normal file
80
client/bot/handlers/add_task.go
Normal file
@@ -0,0 +1,80 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"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/database"
|
||||
"github.com/krau/SaveAny-Bot/pkg/enums/tasktype"
|
||||
"github.com/krau/SaveAny-Bot/pkg/tcbdata"
|
||||
"github.com/krau/SaveAny-Bot/storage"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func handleAddCallback(ctx *ext.Context, update *ext.Update) error {
|
||||
dataid := strings.Split(string(update.CallbackQuery.Data), " ")[1]
|
||||
data, err := shortcut.GetCallbackDataWithAnswer[tcbdata.Add](ctx, update, dataid)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
queryID := update.CallbackQuery.GetQueryID()
|
||||
msgID := update.CallbackQuery.GetMsgID()
|
||||
userID := update.CallbackQuery.GetUserID()
|
||||
|
||||
selectedStorage, err := storage.GetStorageByUserIDAndName(ctx, userID, data.SelectedStorName)
|
||||
if err != nil {
|
||||
log.FromContext(ctx).Errorf("Failed to get storage: %s", err)
|
||||
ctx.AnswerCallback(msgelem.AlertCallbackAnswer(queryID, "存储获取失败: "+err.Error()))
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
dirs, err := database.GetDirsByUserChatIDAndStorageName(ctx, userID, data.SelectedStorName)
|
||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return fmt.Errorf("获取用户目录失败: %w", err)
|
||||
}
|
||||
|
||||
if !data.SettedDir && len(dirs) != 0 {
|
||||
// ask for directory selection
|
||||
markup, err := msgelem.BuildSetDirKeyboard(dirs, dataid)
|
||||
if err != nil {
|
||||
log.FromContext(ctx).Errorf("Failed to build directory keyboard: %s", err)
|
||||
ctx.AnswerCallback(msgelem.AlertCallbackAnswer(queryID, "目录键盘构建失败: "+err.Error()))
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
|
||||
ID: update.CallbackQuery.GetMsgID(),
|
||||
Message: "请选择要存储到的目录",
|
||||
ReplyMarkup: markup,
|
||||
})
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
|
||||
dirPath := ""
|
||||
if data.DirID != 0 {
|
||||
dir, err := database.GetDirByID(ctx, data.DirID)
|
||||
if err != nil {
|
||||
ctx.AnswerCallback(msgelem.AlertCallbackAnswer(queryID, "获取目录失败: "+err.Error()))
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
dirPath = dir.Path
|
||||
}
|
||||
|
||||
switch data.TaskType {
|
||||
case tasktype.TaskTypeTgfiles:
|
||||
if data.AsBatch {
|
||||
return shortcut.CreateAndAddBatchTGFileTaskWithEdit(ctx, userID, selectedStorage, dirPath, data.Files, msgID)
|
||||
}
|
||||
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)
|
||||
default:
|
||||
log.FromContext(ctx).Errorf("Unsupported task type: %s", data.TaskType)
|
||||
}
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
28
client/bot/handlers/cancel_task.go
Normal file
28
client/bot/handlers/cancel_task.go
Normal file
@@ -0,0 +1,28 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"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/core"
|
||||
)
|
||||
|
||||
func handleCancelCallback(ctx *ext.Context, update *ext.Update) error {
|
||||
taskid := strings.Split(string(update.CallbackQuery.Data), " ")[1]
|
||||
if err := core.CancelTask(ctx, taskid); err != nil {
|
||||
log.FromContext(ctx).Errorf("error cancelling task %s: %v", taskid, err)
|
||||
ctx.AnswerCallback(msgelem.AlertCallbackAnswer(update.CallbackQuery.GetQueryID(), "取消任务失败: "+err.Error()))
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
|
||||
ctx.EditMessage(update.CallbackQuery.GetUserID(), &tg.MessagesEditMessageRequest{
|
||||
ID: update.CallbackQuery.GetMsgID(),
|
||||
Message: "正在取消任务...",
|
||||
})
|
||||
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
74
client/bot/handlers/dir.go
Normal file
74
client/bot/handlers/dir.go
Normal file
@@ -0,0 +1,74 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/celestix/gotgproto/dispatcher"
|
||||
"github.com/celestix/gotgproto/ext"
|
||||
"github.com/charmbracelet/log"
|
||||
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/msgelem"
|
||||
"github.com/krau/SaveAny-Bot/database"
|
||||
"github.com/krau/SaveAny-Bot/storage"
|
||||
)
|
||||
|
||||
func handleDirCmd(ctx *ext.Context, update *ext.Update) error {
|
||||
logger := log.FromContext(ctx)
|
||||
args := strings.Split(update.EffectiveMessage.Text, " ")
|
||||
userChatID := update.GetUserChat().GetID()
|
||||
dirs, err := database.GetUserDirsByChatID(ctx, userChatID)
|
||||
if err != nil {
|
||||
logger.Errorf("获取用户文件夹失败: %s", err)
|
||||
ctx.Reply(update, ext.ReplyTextString("获取用户文件夹失败"), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
if len(args) < 2 {
|
||||
ctx.Reply(update, ext.ReplyTextStyledTextArray(msgelem.BuildDirHelpStyling(dirs)), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
user, err := database.GetUserByChatID(ctx, update.GetUserChat().GetID())
|
||||
if err != nil {
|
||||
logger.Errorf("获取用户失败: %s", err)
|
||||
ctx.Reply(update, ext.ReplyTextString("获取用户失败"), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
switch args[1] {
|
||||
case "add":
|
||||
// /dir add local1 path/to/dir
|
||||
if len(args) < 4 {
|
||||
ctx.Reply(update, ext.ReplyTextStyledTextArray(msgelem.BuildDirHelpStyling(dirs)), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
if _, err := storage.GetStorageByUserIDAndName(ctx, user.ChatID, args[2]); err != nil {
|
||||
ctx.Reply(update, ext.ReplyTextString(err.Error()), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
|
||||
if err := database.CreateDirForUser(ctx, user.ID, args[2], args[3]); err != nil {
|
||||
logger.Errorf("创建文件夹失败: %s", err)
|
||||
ctx.Reply(update, ext.ReplyTextString("创建文件夹失败"), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
ctx.Reply(update, ext.ReplyTextString("文件夹添加成功"), nil)
|
||||
case "del":
|
||||
// /dir del 3
|
||||
if len(args) < 3 {
|
||||
ctx.Reply(update, ext.ReplyTextStyledTextArray(msgelem.BuildDirHelpStyling(dirs)), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
dirID, err := strconv.Atoi(args[2])
|
||||
if err != nil {
|
||||
ctx.Reply(update, ext.ReplyTextString("文件夹ID无效"), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
if err := database.DeleteDirByID(ctx, uint(dirID)); err != nil {
|
||||
logger.Errorf("删除文件夹失败: %s", err)
|
||||
ctx.Reply(update, ext.ReplyTextString("删除文件夹失败"), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
ctx.Reply(update, ext.ReplyTextString("文件夹删除成功"), nil)
|
||||
default:
|
||||
ctx.Reply(update, ext.ReplyTextString("未知操作"), nil)
|
||||
}
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
31
client/bot/handlers/help.go
Normal file
31
client/bot/handlers/help.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/celestix/gotgproto/dispatcher"
|
||||
"github.com/celestix/gotgproto/ext"
|
||||
"github.com/krau/SaveAny-Bot/pkg/consts"
|
||||
)
|
||||
|
||||
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
|
||||
if len(shortHash) > 7 {
|
||||
shortHash = shortHash[:7]
|
||||
}
|
||||
ctx.Reply(update, ext.ReplyTextString(fmt.Sprintf(helpText, consts.Version, shortHash)), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
62
client/bot/handlers/link.go
Normal file
62
client/bot/handlers/link.go
Normal file
@@ -0,0 +1,62 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/celestix/gotgproto/dispatcher"
|
||||
"github.com/celestix/gotgproto/ext"
|
||||
"github.com/charmbracelet/log"
|
||||
"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/pkg/tcbdata"
|
||||
"github.com/krau/SaveAny-Bot/storage"
|
||||
)
|
||||
|
||||
func handleMessageLink(ctx *ext.Context, update *ext.Update) error {
|
||||
replied, files, editReplied, err := shortcut.GetFilesFromUpdateLinkMessageWithReplyEdit(ctx, update)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
logger := log.FromContext(ctx)
|
||||
userId := update.GetUserChat().GetID()
|
||||
stors := storage.GetUserStorages(ctx, userId)
|
||||
if len(files) == 1 {
|
||||
req, err := msgelem.BuildAddOneSelectStorageMessage(ctx, stors, files[0], replied.ID)
|
||||
if err != nil {
|
||||
logger.Errorf("构建存储选择消息失败: %s", err)
|
||||
editReplied("构建存储选择消息失败: "+err.Error(), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
ctx.EditMessage(update.EffectiveChat().GetID(), req)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
markup, err := msgelem.BuildAddSelectStorageKeyboard(stors, tcbdata.Add{
|
||||
Files: files,
|
||||
})
|
||||
if err != nil {
|
||||
logger.Errorf("构建存储选择键盘失败: %s", err)
|
||||
editReplied("构建存储选择键盘失败: "+err.Error(), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
editReplied(fmt.Sprintf("找到 %d 个文件, 请选择存储位置", len(files)), markup)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
|
||||
func handleSilentSaveLink(ctx *ext.Context, update *ext.Update) error {
|
||||
logger := log.FromContext(ctx)
|
||||
stor := storage.FromContext(ctx)
|
||||
if stor == nil {
|
||||
logger.Warn("Context storage is nil")
|
||||
ctx.Reply(update, ext.ReplyTextString("未找到存储"), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
replied, files, _, err := shortcut.GetFilesFromUpdateLinkMessageWithReplyEdit(ctx, update)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
userId := update.GetUserChat().GetID()
|
||||
if len(files) == 1 {
|
||||
return shortcut.CreateAndAddTGFileTaskWithEdit(ctx, userId, stor, "", files[0], replied.ID)
|
||||
}
|
||||
return shortcut.CreateAndAddBatchTGFileTaskWithEdit(ctx, userId, stor, "", files, replied.ID)
|
||||
}
|
||||
159
client/bot/handlers/media.go
Normal file
159
client/bot/handlers/media.go
Normal file
@@ -0,0 +1,159 @@
|
||||
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/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)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
userId := update.GetUserChat().GetID()
|
||||
stors := storage.GetUserStorages(ctx, userId)
|
||||
req, err := msgelem.BuildAddOneSelectStorageMessage(ctx, stors, file, msg.ID)
|
||||
if err != nil {
|
||||
logger.Errorf("构建存储选择消息失败: %s", err)
|
||||
ctx.Reply(update, ext.ReplyTextString("构建存储选择消息失败: "+err.Error()), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
ctx.EditMessage(update.EffectiveChat().GetID(), req)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
|
||||
func handleSilentSaveMedia(ctx *ext.Context, update *ext.Update) error {
|
||||
logger := log.FromContext(ctx)
|
||||
stor := storage.FromContext(ctx)
|
||||
if stor == nil {
|
||||
logger.Warn("Context storage is nil")
|
||||
ctx.Reply(update, ext.ReplyTextString("未找到存储"), nil)
|
||||
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)
|
||||
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,
|
||||
})
|
||||
}
|
||||
49
client/bot/handlers/middleware.go
Normal file
49
client/bot/handlers/middleware.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"github.com/celestix/gotgproto/dispatcher"
|
||||
"github.com/celestix/gotgproto/ext"
|
||||
"github.com/duke-git/lancet/v2/slice"
|
||||
"github.com/krau/SaveAny-Bot/config"
|
||||
"github.com/krau/SaveAny-Bot/database"
|
||||
"github.com/krau/SaveAny-Bot/storage"
|
||||
)
|
||||
|
||||
func checkPermission(ctx *ext.Context, update *ext.Update) error {
|
||||
userID := update.GetUserChat().GetID()
|
||||
if !slice.Contain(config.Cfg.GetUsersID(), userID) {
|
||||
const noPermissionText string = `
|
||||
您不在白名单中, 无法使用此 Bot.
|
||||
您可以部署自己的实例: https://github.com/krau/SaveAny-Bot
|
||||
`
|
||||
ctx.Reply(update, ext.ReplyTextString(noPermissionText), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
|
||||
return dispatcher.ContinueGroups
|
||||
}
|
||||
|
||||
func handleSilentMode(next func(*ext.Context, *ext.Update) error, handler func(*ext.Context, *ext.Update) error) func(*ext.Context, *ext.Update) error {
|
||||
return func(ctx *ext.Context, update *ext.Update) error {
|
||||
userID := update.GetUserChat().GetID()
|
||||
user, err := database.GetUserByChatID(ctx, userID)
|
||||
if err != nil {
|
||||
ctx.Reply(update, ext.ReplyTextString("获取用户信息失败: "+err.Error()), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
if !user.Silent {
|
||||
return next(ctx, update)
|
||||
}
|
||||
if user.DefaultStorage == "" {
|
||||
ctx.Reply(update, ext.ReplyTextString("您已开启静默模式, 但未设置默认存储端, 请先使用 /storage 设置"), nil)
|
||||
return next(ctx, update)
|
||||
}
|
||||
stor, err := storage.GetStorageByUserIDAndName(ctx, userID, user.DefaultStorage)
|
||||
if err != nil {
|
||||
ctx.Reply(update, ext.ReplyTextString("获取默认存储失败: "+err.Error()), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
ctx.Context = storage.WithContext(ctx.Context, stor)
|
||||
return handler(ctx, update)
|
||||
}
|
||||
}
|
||||
41
client/bot/handlers/register.go
Normal file
41
client/bot/handlers/register.go
Normal file
@@ -0,0 +1,41 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"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/krau/SaveAny-Bot/client/bot/handlers/utils/re"
|
||||
"github.com/krau/SaveAny-Bot/pkg/tcbdata"
|
||||
)
|
||||
|
||||
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
|
||||
}))
|
||||
disp.AddHandler(handlers.NewMessage(filters.Message.ChatType(filters.ChatTypeChat), func(ctx *ext.Context, u *ext.Update) error {
|
||||
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)))
|
||||
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.NewMessage(filters.Message.Media, handleSilentMode(handleMediaMessage, handleSilentSaveMedia)))
|
||||
}
|
||||
101
client/bot/handlers/rule.go
Normal file
101
client/bot/handlers/rule.go
Normal file
@@ -0,0 +1,101 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/celestix/gotgproto/dispatcher"
|
||||
"github.com/celestix/gotgproto/ext"
|
||||
"github.com/charmbracelet/log"
|
||||
"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"
|
||||
)
|
||||
|
||||
func handleRuleCmd(ctx *ext.Context, update *ext.Update) error {
|
||||
logger := log.FromContext(ctx)
|
||||
args := strings.Split(update.EffectiveMessage.Text, " ")
|
||||
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 len(args) < 2 {
|
||||
ctx.Reply(update, ext.ReplyTextStyledTextArray(msgelem.BuildRuleHelpStyling(user.ApplyRule, user.Rules)), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
switch args[1] {
|
||||
case "switch":
|
||||
// /rule switch
|
||||
applyRule := !user.ApplyRule
|
||||
if err := database.UpdateUserApplyRule(ctx, user.ChatID, applyRule); err != nil {
|
||||
logger.Errorf("更新用户失败: %s", err)
|
||||
ctx.Reply(update, ext.ReplyTextString("更新用户失败"), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
ctx.Reply(update, ext.ReplyTextString(fmt.Sprintf("已%s规则模式", map[bool]string{true: "启用", false: "禁用"}[applyRule])), nil)
|
||||
case "add":
|
||||
// /rule add <type> <data> <storage> <dirpath>
|
||||
if len(args) < 6 {
|
||||
ctx.Reply(update, ext.ReplyTextStyledTextArray(msgelem.BuildRuleHelpStyling(user.ApplyRule, user.Rules)), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
ruleTypeArg := args[2]
|
||||
ruleType, err := func() (rule.RuleType, error) {
|
||||
for _, t := range rule.Values() {
|
||||
if strings.EqualFold(t.String(), ruleTypeArg) {
|
||||
return t, nil
|
||||
}
|
||||
}
|
||||
return rule.RuleType(""), fmt.Errorf("无效的规则类型: %s\n可用: %v", ruleTypeArg, slice.Join(rule.Values(), ", "))
|
||||
}()
|
||||
if err != nil {
|
||||
ctx.Reply(update, ext.ReplyTextString(err.Error()), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
|
||||
ruleData := args[3]
|
||||
storageName := args[4]
|
||||
dirPath := args[5]
|
||||
|
||||
rd := &database.Rule{
|
||||
Type: ruleType.String(),
|
||||
Data: ruleData,
|
||||
StorageName: storageName,
|
||||
DirPath: dirPath,
|
||||
UserID: user.ID,
|
||||
}
|
||||
if err := database.CreateRule(ctx, rd); err != nil {
|
||||
logger.Errorf("创建规则失败: %s", err)
|
||||
ctx.Reply(update, ext.ReplyTextString("创建规则失败"), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
ctx.Reply(update, ext.ReplyTextString("创建规则成功"), nil)
|
||||
case "del":
|
||||
// /rule del <id>
|
||||
if len(args) < 3 {
|
||||
ctx.Reply(update, ext.ReplyTextString("请提供规则ID"), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
ruleID := args[2]
|
||||
id, err := strconv.Atoi(ruleID)
|
||||
if err != nil {
|
||||
ctx.Reply(update, ext.ReplyTextString("无效的规则ID"), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
if err := database.DeleteRule(ctx, uint(id)); err != nil {
|
||||
logger.Errorf("删除规则失败: %s", err)
|
||||
ctx.Reply(update, ext.ReplyTextString("删除规则失败"), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
ctx.Reply(update, ext.ReplyTextString("删除规则成功"), nil)
|
||||
default:
|
||||
ctx.Reply(update, ext.ReplyTextStyledTextArray(msgelem.BuildRuleHelpStyling(user.ApplyRule, user.Rules)), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
198
client/bot/handlers/save.go
Normal file
198
client/bot/handlers/save.go
Normal file
@@ -0,0 +1,198 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"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/utils/strutil"
|
||||
"github.com/krau/SaveAny-Bot/common/utils/tgutil"
|
||||
"github.com/krau/SaveAny-Bot/pkg/tcbdata"
|
||||
"github.com/krau/SaveAny-Bot/pkg/tfile"
|
||||
|
||||
"github.com/krau/SaveAny-Bot/storage"
|
||||
)
|
||||
|
||||
func handleSaveCmd(ctx *ext.Context, update *ext.Update) error {
|
||||
logger := log.FromContext(ctx)
|
||||
args := strings.Split(string(update.EffectiveMessage.Text), " ")
|
||||
if len(args) >= 3 {
|
||||
return handleBatchSave(ctx, update, args[1:])
|
||||
}
|
||||
replyTo := update.EffectiveMessage.ReplyToMessage
|
||||
if replyTo == nil || replyTo.Message == nil {
|
||||
ctx.Reply(update, ext.ReplyTextString(msgelem.SaveHelpText), 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)
|
||||
}
|
||||
msg, file, err := shortcut.GetFileFromMessageWithReply(ctx, update, replyTo.Message, option)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
userId := update.GetUserChat().GetID()
|
||||
stors := storage.GetUserStorages(ctx, userId)
|
||||
req, err := msgelem.BuildAddOneSelectStorageMessage(ctx, stors, file, msg.ID)
|
||||
if err != nil {
|
||||
logger.Errorf("构建存储选择消息失败: %s", err)
|
||||
ctx.Reply(update, ext.ReplyTextString("构建存储选择消息失败: "+err.Error()), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
ctx.EditMessage(update.EffectiveChat().GetID(), req)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
|
||||
func handleSilentSaveReplied(ctx *ext.Context, update *ext.Update) error {
|
||||
args := strings.Split(string(update.EffectiveMessage.Text), " ")
|
||||
if len(args) >= 3 {
|
||||
return handleBatchSave(ctx, update, args[1:])
|
||||
}
|
||||
logger := log.FromContext(ctx)
|
||||
stor := storage.FromContext(ctx)
|
||||
if stor == nil {
|
||||
logger.Warn("Context storage is nil")
|
||||
ctx.Reply(update, ext.ReplyTextString("未找到存储"), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
replyTo := update.EffectiveMessage.ReplyToMessage
|
||||
if replyTo == nil || replyTo.Message == nil {
|
||||
ctx.Reply(update, ext.ReplyTextString(msgelem.SaveHelpText), 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)
|
||||
}
|
||||
msg, file, err := shortcut.GetFileFromMessageWithReply(ctx, update, replyTo.Message, option)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return shortcut.CreateAndAddTGFileTaskWithEdit(ctx, update.GetUserChat().GetID(), stor, "", file, msg.GetID())
|
||||
}
|
||||
|
||||
func handleBatchSave(ctx *ext.Context, update *ext.Update, args []string) error {
|
||||
chatArg := args[0]
|
||||
msgIdRangeArg := args[1]
|
||||
var filterStr string
|
||||
var filter *regexp.Regexp
|
||||
if len(args) > 2 {
|
||||
filterStr = args[2]
|
||||
var err error
|
||||
filter, err = regexp.Compile(filterStr)
|
||||
if err != nil {
|
||||
ctx.Reply(update, ext.ReplyTextString("无效的正则表达式: "+err.Error()), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
}
|
||||
startID, endID, err := strutil.ParseIntStrRange(msgIdRangeArg, "-")
|
||||
if err != nil {
|
||||
ctx.Reply(update, ext.ReplyTextString("无效的消息ID范围: "+err.Error()), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
chatID, err := tgutil.ParseChatID(ctx, chatArg)
|
||||
if err != nil {
|
||||
ctx.Reply(update, ext.ReplyTextString("无效的ID或用户名: "+err.Error()), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
|
||||
replied, err := ctx.Reply(update, ext.ReplyTextString("正在获取消息..."), nil)
|
||||
if err != nil {
|
||||
log.FromContext(ctx).Errorf("回复失败: %s", err)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
|
||||
// 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)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
if len(msgs) == 0 {
|
||||
ctx.Reply(update, ext.ReplyTextString("没有找到指定范围内的消息"), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
files := make([]tfile.TGFileMessage, 0, len(msgs))
|
||||
sb := strings.Builder{}
|
||||
for _, msg := range msgs {
|
||||
if msg == nil {
|
||||
continue
|
||||
}
|
||||
media, ok := msg.GetMedia()
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
supported := mediautil.IsSupported(media)
|
||||
if !supported {
|
||||
continue
|
||||
}
|
||||
file, err := tfile.FromMediaMessage(media, ctx.Raw, msg, tfile.WithNameIfEmpty(tgutil.GenFileNameFromMessage(*msg)))
|
||||
if err != nil {
|
||||
log.FromContext(ctx).Errorf("获取文件失败: %s", err)
|
||||
continue
|
||||
}
|
||||
if filter != nil {
|
||||
sb.Reset()
|
||||
sb.WriteString(msg.GetMessage())
|
||||
sb.WriteString(" ")
|
||||
fn, _ := functions.GetMediaFileNameWithId(media)
|
||||
sb.WriteString(fn)
|
||||
log.FromContext(ctx).Debugf("正在检查消息内容: %s", sb.String())
|
||||
if !filter.MatchString(sb.String()) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
files = append(files, file)
|
||||
}
|
||||
if len(files) == 0 {
|
||||
ctx.Reply(update, ext.ReplyTextString("没有找到指定范围内的可保存消息"), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
stor := storage.FromContext(ctx)
|
||||
if stor == nil {
|
||||
// not in silent mode
|
||||
stors := storage.GetUserStorages(ctx, update.GetUserChat().GetID())
|
||||
markup, err := msgelem.BuildAddSelectStorageKeyboard(stors, tcbdata.Add{
|
||||
Files: files,
|
||||
})
|
||||
if err != nil {
|
||||
log.FromContext(ctx).Errorf("构建存储选择键盘失败: %s", err)
|
||||
ctx.EditMessage(update.EffectiveChat().GetID(), &tg.MessagesEditMessageRequest{
|
||||
ID: replied.ID,
|
||||
Message: "构建存储选择键盘失败: " + err.Error(),
|
||||
})
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
ctx.EditMessage(update.EffectiveChat().GetID(), &tg.MessagesEditMessageRequest{
|
||||
ID: replied.ID,
|
||||
Message: fmt.Sprintf("找到 %d 个文件, 请选择存储位置", len(files)),
|
||||
ReplyMarkup: markup,
|
||||
})
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
return shortcut.CreateAndAddBatchTGFileTaskWithEdit(ctx, update.GetUserChat().GetID(), stor, "", files, replied.ID)
|
||||
|
||||
}
|
||||
104
client/bot/handlers/silent.go
Normal file
104
client/bot/handlers/silent.go
Normal file
@@ -0,0 +1,104 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/celestix/gotgproto/dispatcher"
|
||||
"github.com/celestix/gotgproto/ext"
|
||||
"github.com/gotd/td/tg"
|
||||
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/msgelem"
|
||||
"github.com/krau/SaveAny-Bot/common/cache"
|
||||
"github.com/krau/SaveAny-Bot/database"
|
||||
"github.com/krau/SaveAny-Bot/pkg/tcbdata"
|
||||
"github.com/krau/SaveAny-Bot/storage"
|
||||
)
|
||||
|
||||
func handleSilentCmd(ctx *ext.Context, update *ext.Update) error {
|
||||
user, err := database.GetUserByChatID(ctx, update.GetUserChat().GetID())
|
||||
if err != nil {
|
||||
ctx.Reply(update, ext.ReplyTextString("获取用户信息失败: "+err.Error()), nil)
|
||||
return nil
|
||||
}
|
||||
if !user.Silent && user.DefaultStorage == "" {
|
||||
ctx.Reply(update, ext.ReplyTextString("请先使用 /storage 设置默认存储位置"), nil)
|
||||
return nil
|
||||
}
|
||||
user.Silent = !user.Silent
|
||||
if err := database.UpdateUser(ctx, user); err != nil {
|
||||
ctx.Reply(update, ext.ReplyTextString("更新用户信息失败: "+err.Error()), nil)
|
||||
return nil
|
||||
}
|
||||
responseText := "已" + map[bool]string{true: "开启", false: "关闭"}[user.Silent] + "静默模式"
|
||||
ctx.Reply(update, ext.ReplyTextString(responseText), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
|
||||
func handleSetDefaultCallback(ctx *ext.Context, update *ext.Update) error {
|
||||
dataid := strings.Split(string(update.CallbackQuery.Data), " ")[1]
|
||||
data, ok := cache.Get[tcbdata.SetDefaultStorage](dataid)
|
||||
if !ok {
|
||||
ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{
|
||||
QueryID: update.CallbackQuery.GetQueryID(),
|
||||
Alert: true,
|
||||
Message: "数据已过期",
|
||||
CacheTime: 5,
|
||||
})
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
userID := update.CallbackQuery.GetUserID()
|
||||
|
||||
storageName := data.StorageName
|
||||
selectedStorage, err := storage.GetStorageByUserIDAndName(ctx, userID, storageName)
|
||||
if err != nil {
|
||||
ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{
|
||||
QueryID: update.CallbackQuery.GetQueryID(),
|
||||
Alert: true,
|
||||
Message: "存储获取失败: " + err.Error(),
|
||||
CacheTime: 5,
|
||||
})
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
user, err := database.GetUserByChatID(ctx, userID)
|
||||
if err != nil {
|
||||
ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{
|
||||
QueryID: update.CallbackQuery.GetQueryID(),
|
||||
Alert: true,
|
||||
Message: "获取用户信息失败: " + err.Error(),
|
||||
CacheTime: 5,
|
||||
})
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
user.DefaultStorage = selectedStorage.Name()
|
||||
if err := database.UpdateUser(ctx, user); err != nil {
|
||||
ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{
|
||||
QueryID: update.CallbackQuery.GetQueryID(),
|
||||
Alert: true,
|
||||
Message: "更新用户信息失败: " + err.Error(),
|
||||
CacheTime: 5,
|
||||
})
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
|
||||
ID: update.CallbackQuery.GetMsgID(),
|
||||
Message: "已将默认存储位置设置为: " + selectedStorage.Name(),
|
||||
})
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
|
||||
func handleStorageCmd(ctx *ext.Context, update *ext.Update) error {
|
||||
userID := update.GetUserChat().GetID()
|
||||
storages := storage.GetUserStorages(ctx, userID)
|
||||
if len(storages) == 0 {
|
||||
ctx.Reply(update, ext.ReplyTextString("无可用的存储"), nil)
|
||||
return nil
|
||||
}
|
||||
markup, err := msgelem.BuildSetDefaultStorageMarkup(ctx, userID, storages)
|
||||
if err != nil {
|
||||
ctx.Reply(update, ext.ReplyTextString("获取存储失败: "+err.Error()), nil)
|
||||
return nil
|
||||
}
|
||||
ctx.Reply(update, ext.ReplyTextString("请选择要设为默认的存储位置"), &ext.ReplyOpts{
|
||||
Markup: markup,
|
||||
})
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
76
client/bot/handlers/telegraph.go
Normal file
76
client/bot/handlers/telegraph.go
Normal file
@@ -0,0 +1,76 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/celestix/gotgproto/dispatcher"
|
||||
"github.com/celestix/gotgproto/ext"
|
||||
"github.com/charmbracelet/log"
|
||||
"github.com/gotd/td/telegram/message/entity"
|
||||
"github.com/gotd/td/telegram/message/styling"
|
||||
"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/pkg/enums/tasktype"
|
||||
"github.com/krau/SaveAny-Bot/pkg/tcbdata"
|
||||
"github.com/krau/SaveAny-Bot/storage"
|
||||
)
|
||||
|
||||
func handleTelegraphUrlMessage(ctx *ext.Context, update *ext.Update) error {
|
||||
logger := log.FromContext(ctx)
|
||||
|
||||
msg, result, err := shortcut.GetTphPicsFromMessageWithReply(ctx, update)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
userID := update.GetUserChat().GetID()
|
||||
stors := storage.GetUserStorages(ctx, userID)
|
||||
markup, err := msgelem.BuildAddSelectStorageKeyboard(stors, tcbdata.Add{
|
||||
TaskType: tasktype.TaskTypeTphpics,
|
||||
TphPageNode: result.Page,
|
||||
TphDirPath: result.TphDir,
|
||||
TphPics: result.Pics,
|
||||
})
|
||||
if err != nil {
|
||||
logger.Errorf("构建存储选择键盘失败: %s", err)
|
||||
ctx.Reply(update, ext.ReplyTextString("构建存储选择键盘失败: "+err.Error()), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
|
||||
eb := entity.Builder{}
|
||||
if err := styling.Perform(&eb,
|
||||
styling.Plain("标题: "),
|
||||
styling.Code(result.Page.Title),
|
||||
styling.Plain("\n图片数量: "),
|
||||
styling.Code(fmt.Sprintf("%d", len(result.Pics))),
|
||||
styling.Plain("\n请选择存储位置"),
|
||||
); err != nil {
|
||||
log.FromContext(ctx).Errorf("Failed to build entity: %s", err)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
text, entities := eb.Complete()
|
||||
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
|
||||
Message: text,
|
||||
ID: msg.ID,
|
||||
ReplyMarkup: markup,
|
||||
Entities: entities,
|
||||
})
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
|
||||
func handleSilentSaveTelegraph(ctx *ext.Context, update *ext.Update) error {
|
||||
logger := log.FromContext(ctx)
|
||||
stor := storage.FromContext(ctx)
|
||||
if stor == nil {
|
||||
logger.Warn("Context storage is nil")
|
||||
ctx.Reply(update, ext.ReplyTextString("未找到存储"), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
msg, result, err := shortcut.GetTphPicsFromMessageWithReply(ctx, update)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
userID := update.GetUserChat().GetID()
|
||||
return shortcut.CreateAndAddTphTaskWithEdit(ctx, userID, result.Page, result.TphDir, result.Pics, stor, msg.ID)
|
||||
|
||||
}
|
||||
12
client/bot/handlers/utils/mediautil/media.go
Normal file
12
client/bot/handlers/utils/mediautil/media.go
Normal file
@@ -0,0 +1,12 @@
|
||||
package mediautil
|
||||
|
||||
import "github.com/gotd/td/tg"
|
||||
|
||||
func IsSupported(media tg.MessageMediaClass) bool {
|
||||
switch media.(type) {
|
||||
case *tg.MessageMediaDocument, *tg.MessageMediaPhoto:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
12
client/bot/handlers/utils/msgelem/callback.go
Normal file
12
client/bot/handlers/utils/msgelem/callback.go
Normal file
@@ -0,0 +1,12 @@
|
||||
package msgelem
|
||||
|
||||
import "github.com/gotd/td/tg"
|
||||
|
||||
func AlertCallbackAnswer(queryID int64, text string) *tg.MessagesSetBotCallbackAnswerRequest {
|
||||
return &tg.MessagesSetBotCallbackAnswerRequest{
|
||||
QueryID: queryID,
|
||||
Alert: true,
|
||||
Message: text,
|
||||
CacheTime: 5,
|
||||
}
|
||||
}
|
||||
36
client/bot/handlers/utils/msgelem/dir.go
Normal file
36
client/bot/handlers/utils/msgelem/dir.go
Normal file
@@ -0,0 +1,36 @@
|
||||
package msgelem
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/gotd/td/telegram/message/styling"
|
||||
"github.com/krau/SaveAny-Bot/database"
|
||||
)
|
||||
|
||||
func BuildDirHelpStyling(dirs []database.Dir) []styling.StyledTextOption {
|
||||
return []styling.StyledTextOption{
|
||||
styling.Bold("使用方法: /dir <操作> <参数...>"),
|
||||
styling.Plain("\n\n可用操作:\n"),
|
||||
styling.Code("add"),
|
||||
styling.Plain(" <存储名> <路径> - 添加路径\n"),
|
||||
styling.Code("del"),
|
||||
styling.Plain(" <路径ID> - 删除路径\n"),
|
||||
styling.Plain("\n添加路径示例:\n"),
|
||||
styling.Code("/dir add local1 path/to/dir"),
|
||||
styling.Plain("\n\n删除路径示例:\n"),
|
||||
styling.Code("/dir del 3"),
|
||||
styling.Plain("\n\n当前已添加的路径:\n"),
|
||||
styling.Blockquote(func() string {
|
||||
var sb strings.Builder
|
||||
for _, dir := range dirs {
|
||||
sb.WriteString(fmt.Sprintf("%d: ", dir.ID))
|
||||
sb.WriteString(dir.StorageName)
|
||||
sb.WriteString(" - ")
|
||||
sb.WriteString(dir.Path)
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
return sb.String()
|
||||
}(), true),
|
||||
}
|
||||
}
|
||||
32
client/bot/handlers/utils/msgelem/rule.go
Normal file
32
client/bot/handlers/utils/msgelem/rule.go
Normal file
@@ -0,0 +1,32 @@
|
||||
package msgelem
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/gotd/td/telegram/message/styling"
|
||||
"github.com/krau/SaveAny-Bot/database"
|
||||
)
|
||||
|
||||
func BuildRuleHelpStyling(enabled bool, rules []database.Rule) []styling.StyledTextOption {
|
||||
return []styling.StyledTextOption{
|
||||
styling.Bold("使用方法: /rule <操作> <参数...>"),
|
||||
styling.Bold(fmt.Sprintf("\n当前已%s规则模式", map[bool]string{true: "启用", false: "禁用"}[enabled])),
|
||||
styling.Plain("\n\n可用操作:\n"),
|
||||
styling.Code("switch"),
|
||||
styling.Plain(" - 开关规则模式\n"),
|
||||
styling.Code("add"),
|
||||
styling.Plain(" <类型> <数据> <存储名> <路径> - 添加规则\n"),
|
||||
styling.Code("del"),
|
||||
styling.Plain(" <规则ID> - 删除规则\n"),
|
||||
styling.Plain("\n当前已添加的规则:\n"),
|
||||
styling.Blockquote(func() string {
|
||||
var sb strings.Builder
|
||||
for _, rule := range rules {
|
||||
ruleText := fmt.Sprintf("%s %s %s %s", rule.Type, rule.Data, rule.StorageName, rule.DirPath)
|
||||
sb.WriteString(fmt.Sprintf("%d: %s\n", rule.ID, ruleText))
|
||||
}
|
||||
return sb.String()
|
||||
}(), true),
|
||||
}
|
||||
}
|
||||
15
client/bot/handlers/utils/msgelem/save.go
Normal file
15
client/bot/handlers/utils/msgelem/save.go
Normal file
@@ -0,0 +1,15 @@
|
||||
package msgelem
|
||||
|
||||
const (
|
||||
SaveHelpText = `
|
||||
使用方法:
|
||||
|
||||
1. 使用该命令回复要保存的文件, 可选文件名参数.
|
||||
示例:
|
||||
/save custom_file_name.mp4
|
||||
|
||||
2. 设置默认存储后, 发送 /save <频道ID/用户名> <消息ID范围> 来批量保存文件. 遵从存储规则, 若未匹配到任何规则则使用默认存储.
|
||||
示例:
|
||||
/save @moreacg 114-514
|
||||
`
|
||||
)
|
||||
161
client/bot/handlers/utils/msgelem/storage.go
Normal file
161
client/bot/handlers/utils/msgelem/storage.go
Normal file
@@ -0,0 +1,161 @@
|
||||
package msgelem
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/charmbracelet/log"
|
||||
"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/cache"
|
||||
"github.com/krau/SaveAny-Bot/database"
|
||||
"github.com/krau/SaveAny-Bot/pkg/enums/tasktype"
|
||||
"github.com/krau/SaveAny-Bot/pkg/tcbdata"
|
||||
"github.com/krau/SaveAny-Bot/pkg/tfile"
|
||||
"github.com/krau/SaveAny-Bot/storage"
|
||||
"github.com/rs/xid"
|
||||
)
|
||||
|
||||
func BuildAddSelectStorageKeyboard(stors []storage.Storage, adddata tcbdata.Add) (*tg.ReplyInlineMarkup, error) {
|
||||
taskType := adddata.TaskType
|
||||
if taskType == "" {
|
||||
if len(adddata.Files) > 0 {
|
||||
taskType = tasktype.TaskTypeTgfiles
|
||||
} else if adddata.TphPageNode != nil {
|
||||
taskType = tasktype.TaskTypeTphpics
|
||||
} else {
|
||||
return nil, fmt.Errorf("unknown task type: %s", taskType)
|
||||
}
|
||||
}
|
||||
|
||||
buttons := make([]tg.KeyboardButtonClass, 0)
|
||||
for _, storage := range stors {
|
||||
data := tcbdata.Add{
|
||||
TaskType: taskType,
|
||||
SelectedStorName: storage.Name(),
|
||||
|
||||
Files: adddata.Files,
|
||||
AsBatch: len(adddata.Files) > 1,
|
||||
|
||||
TphPageNode: adddata.TphPageNode,
|
||||
TphPics: adddata.TphPics,
|
||||
TphDirPath: adddata.TphDirPath,
|
||||
}
|
||||
dataid := xid.New().String()
|
||||
err := cache.Set(dataid, data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
buttons = append(buttons, &tg.KeyboardButtonCallback{
|
||||
Text: storage.Name(),
|
||||
Data: fmt.Appendf(nil, "%s %s", tcbdata.TypeAdd, dataid),
|
||||
})
|
||||
}
|
||||
markup := &tg.ReplyInlineMarkup{}
|
||||
for i := 0; i < len(buttons); i += 3 {
|
||||
row := tg.KeyboardButtonRow{}
|
||||
row.Buttons = buttons[i:min(i+3, len(buttons))]
|
||||
markup.Rows = append(markup.Rows, row)
|
||||
}
|
||||
return markup, nil
|
||||
}
|
||||
|
||||
func BuildAddOneSelectStorageMessage(ctx context.Context, stors []storage.Storage, file tfile.TGFileMessage, msgId int) (*tg.MessagesEditMessageRequest, error) {
|
||||
eb := entity.Builder{}
|
||||
var entities []tg.MessageEntityClass
|
||||
text := fmt.Sprintf("文件名: %s\n请选择存储位置", file.Name())
|
||||
if err := styling.Perform(&eb,
|
||||
styling.Plain("文件名: "),
|
||||
styling.Code(file.Name()),
|
||||
styling.Plain("\n请选择存储位置"),
|
||||
); err != nil {
|
||||
log.FromContext(ctx).Errorf("Failed to build entity: %s", err)
|
||||
} else {
|
||||
text, entities = eb.Complete()
|
||||
}
|
||||
markup, err := BuildAddSelectStorageKeyboard(stors, tcbdata.Add{
|
||||
TaskType: tasktype.TaskTypeTgfiles,
|
||||
Files: []tfile.TGFileMessage{file},
|
||||
AsBatch: false,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to build storage keyboard: %w", err)
|
||||
}
|
||||
return &tg.MessagesEditMessageRequest{
|
||||
Message: text,
|
||||
Entities: entities,
|
||||
ReplyMarkup: markup,
|
||||
ID: msgId,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func BuildSetDefaultStorageMarkup(ctx context.Context, userID int64, stors []storage.Storage) (*tg.ReplyInlineMarkup, error) {
|
||||
buttons := make([]tg.KeyboardButtonClass, 0)
|
||||
for _, storage := range stors {
|
||||
data := tcbdata.SetDefaultStorage{
|
||||
StorageName: storage.Name(),
|
||||
}
|
||||
dataid := xid.New().String()
|
||||
err := cache.Set(dataid, data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
buttons = append(buttons, &tg.KeyboardButtonCallback{
|
||||
Text: storage.Name(),
|
||||
Data: fmt.Appendf(nil, "%s %s", tcbdata.TypeSetDefault, dataid),
|
||||
})
|
||||
}
|
||||
markup := &tg.ReplyInlineMarkup{}
|
||||
for i := 0; i < len(buttons); i += 3 {
|
||||
row := tg.KeyboardButtonRow{}
|
||||
row.Buttons = buttons[i:min(i+3, len(buttons))]
|
||||
markup.Rows = append(markup.Rows, row)
|
||||
}
|
||||
return markup, nil
|
||||
}
|
||||
|
||||
func BuildSetDirKeyboard(dirs []database.Dir, dataid string) (*tg.ReplyInlineMarkup, error) {
|
||||
data, ok := cache.Get[tcbdata.Add](dataid)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("failed to get data from cache: %s", dataid)
|
||||
}
|
||||
if data.DirID != 0 || data.SettedDir {
|
||||
log.Warnf("Data already has a directory set: %d, %t", data.DirID, data.SettedDir)
|
||||
return nil, fmt.Errorf("data already has a directory set")
|
||||
}
|
||||
buttons := make([]tg.KeyboardButtonClass, 0)
|
||||
for _, dir := range dirs {
|
||||
dirDataId := xid.New().String()
|
||||
dirData := data
|
||||
dirData.DirID = dir.ID
|
||||
dirData.SettedDir = true
|
||||
err := cache.Set(dirDataId, dirData)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to set directory data in cache: %w", err)
|
||||
}
|
||||
buttons = append(buttons, &tg.KeyboardButtonCallback{
|
||||
Text: dir.Path,
|
||||
Data: fmt.Appendf(nil, "%s %s", tcbdata.TypeAdd, dirDataId),
|
||||
})
|
||||
}
|
||||
dirDefaultDataId := xid.New().String()
|
||||
dirDefaultData := data
|
||||
dirDefaultData.DirID = 0
|
||||
dirDefaultData.SettedDir = true
|
||||
err := cache.Set(dirDefaultDataId, dirDefaultData)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to set default directory data in cache: %w", err)
|
||||
}
|
||||
buttons = append(buttons, &tg.KeyboardButtonCallback{
|
||||
Text: "默认",
|
||||
Data: fmt.Appendf(nil, "%s %s", tcbdata.TypeAdd, dirDefaultDataId),
|
||||
})
|
||||
markup := &tg.ReplyInlineMarkup{}
|
||||
for i := 0; i < len(buttons); i += 3 {
|
||||
row := tg.KeyboardButtonRow{}
|
||||
row.Buttons = buttons[i:min(i+3, len(buttons))]
|
||||
markup.Rows = append(markup.Rows, row)
|
||||
}
|
||||
return markup, nil
|
||||
}
|
||||
33
client/bot/handlers/utils/msgelem/task.go
Normal file
33
client/bot/handlers/utils/msgelem/task.go
Normal file
@@ -0,0 +1,33 @@
|
||||
package msgelem
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
"github.com/charmbracelet/log"
|
||||
"github.com/gotd/td/telegram/message/entity"
|
||||
"github.com/gotd/td/telegram/message/styling"
|
||||
"github.com/gotd/td/tg"
|
||||
)
|
||||
|
||||
func BuildTaskAddedEntities(
|
||||
ctx context.Context,
|
||||
filename string,
|
||||
queueLength int,
|
||||
) (string, []tg.MessageEntityClass) {
|
||||
entityBuilder := entity.Builder{}
|
||||
var entities []tg.MessageEntityClass
|
||||
text := fmt.Sprintf("已添加到任务队列\n文件名: %s\n当前排队任务数: %d", filename, queueLength)
|
||||
if err := styling.Perform(&entityBuilder,
|
||||
styling.Plain("已添加到任务队列\n文件名: "),
|
||||
styling.Code(filename),
|
||||
styling.Plain("\n当前排队任务数: "),
|
||||
styling.Bold(strconv.Itoa(queueLength)),
|
||||
); err != nil {
|
||||
log.FromContext(ctx).Errorf("Failed to build entity: %s", err)
|
||||
} else {
|
||||
text, entities = entityBuilder.Complete()
|
||||
}
|
||||
return text, entities
|
||||
}
|
||||
10
client/bot/handlers/utils/re/regexp.go
Normal file
10
client/bot/handlers/utils/re/regexp.go
Normal file
@@ -0,0 +1,10 @@
|
||||
package re
|
||||
|
||||
import "regexp"
|
||||
|
||||
var (
|
||||
TgMessageLinkRegexString = `https?://t\.me/(?:c/\d+|[A-Za-z0-9_]+)/\d+(?:/\d+)?(?:\?[^\s#]*[A-Za-z0-9_])?\b`
|
||||
TgMessageLinkRegexp = regexp.MustCompile(TgMessageLinkRegexString)
|
||||
TelegraphUrlRegexString = `https://telegra.ph/.*`
|
||||
TelegraphUrlRegexp = regexp.MustCompile(TelegraphUrlRegexString)
|
||||
)
|
||||
112
client/bot/handlers/utils/ruleutil/rule.go
Normal file
112
client/bot/handlers/utils/ruleutil/rule.go
Normal file
@@ -0,0 +1,112 @@
|
||||
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"
|
||||
)
|
||||
|
||||
type ruleInput struct {
|
||||
File tfile.TGFileMessage
|
||||
}
|
||||
|
||||
type ruleInputOption func(*ruleInput)
|
||||
|
||||
func NewInput(file tfile.TGFileMessage, opts ...ruleInputOption) *ruleInput {
|
||||
input := &ruleInput{
|
||||
File: file,
|
||||
}
|
||||
for _, opt := range opts {
|
||||
opt(input)
|
||||
}
|
||||
return input
|
||||
}
|
||||
|
||||
type matchedStorName string
|
||||
|
||||
func (m matchedStorName) String() string {
|
||||
return string(m)
|
||||
}
|
||||
|
||||
// can we use this storage name directly?
|
||||
func (m matchedStorName) IsUsable() bool {
|
||||
return m != "" && m != consts.RuleStorNameChosen
|
||||
}
|
||||
|
||||
type MatchedDirPath string
|
||||
|
||||
func (m MatchedDirPath) String() string {
|
||||
return string(m)
|
||||
}
|
||||
|
||||
func (m MatchedDirPath) NeedNewForAlbum() bool {
|
||||
return m != "" && m == consts.RuleDirPathNewForAlbum
|
||||
}
|
||||
|
||||
func ApplyRule(ctx context.Context, rules []database.Rule, inputs *ruleInput) (matchedStorageName matchedStorName, dirPath MatchedDirPath) {
|
||||
if inputs == nil || len(rules) == 0 {
|
||||
return "", ""
|
||||
}
|
||||
logger := log.FromContext(ctx)
|
||||
for _, ur := range rules {
|
||||
switch ur.Type {
|
||||
case ruleenum.FileNameRegex.String():
|
||||
ru, err := rule.NewRuleFileNameRegex(ur.StorageName, ur.DirPath, ur.Data)
|
||||
if err != nil {
|
||||
logger.Errorf("Failed to create rule: %s", err)
|
||||
continue
|
||||
}
|
||||
ok, err := ru.Match(inputs.File)
|
||||
if err != nil {
|
||||
logger.Errorf("Failed to match rule: %s", err)
|
||||
continue
|
||||
}
|
||||
if ok {
|
||||
dirPath = MatchedDirPath(ru.StoragePath())
|
||||
matchedStorageName = matchedStorName(ru.StorageName())
|
||||
}
|
||||
case ruleenum.MessageRegex.String():
|
||||
ru, err := rule.NewRuleMessageRegex(ur.StorageName, ur.DirPath, ur.Data)
|
||||
if err != nil {
|
||||
logger.Errorf("Failed to create rule: %s", err)
|
||||
continue
|
||||
}
|
||||
ok, err := ru.Match(inputs.File.Message().GetMessage())
|
||||
if err != nil {
|
||||
logger.Errorf("Failed to match rule: %s", err)
|
||||
continue
|
||||
}
|
||||
if ok {
|
||||
dirPath = MatchedDirPath(ru.StoragePath())
|
||||
matchedStorageName = matchedStorName(ru.StorageName())
|
||||
}
|
||||
case ruleenum.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
|
||||
}
|
||||
225
client/bot/handlers/utils/shortcut/message.go
Normal file
225
client/bot/handlers/utils/shortcut/message.go
Normal file
@@ -0,0 +1,225 @@
|
||||
// Some shortcuts for duplicate code in handlers, they should return dispatcher errors
|
||||
package shortcut
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/celestix/gotgproto/dispatcher"
|
||||
"github.com/celestix/gotgproto/ext"
|
||||
"github.com/celestix/gotgproto/types"
|
||||
"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/re"
|
||||
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/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,
|
||||
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
|
||||
}
|
||||
|
||||
replied, err = ctx.Reply(update, ext.ReplyTextString("正在获取文件信息..."), nil)
|
||||
if err != nil {
|
||||
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...)
|
||||
if err != nil {
|
||||
logger.Errorf("Failed to get file from media: %s", err)
|
||||
ctx.Reply(update, ext.ReplyTextString("获取文件失败: "+err.Error()), nil)
|
||||
return nil, nil, dispatcher.EndGroups
|
||||
}
|
||||
return replied, file, nil
|
||||
}
|
||||
|
||||
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)
|
||||
if len(msgLinks) == 0 {
|
||||
logger.Warn("no matched message links but called handleMessageLink")
|
||||
return nil, nil, nil, dispatcher.EndGroups
|
||||
}
|
||||
replied, err = ctx.Reply(update, ext.ReplyTextString("正在获取消息..."), nil)
|
||||
if err != nil {
|
||||
logger.Errorf("failed to reply: %s", err)
|
||||
return nil, nil, nil, dispatcher.EndGroups
|
||||
}
|
||||
editReplied = func(text string, markup tg.ReplyMarkupClass) {
|
||||
if _, err := ctx.EditMessage(update.EffectiveChat().GetID(), &tg.MessagesEditMessageRequest{
|
||||
ID: replied.ID,
|
||||
Message: text,
|
||||
ReplyMarkup: markup,
|
||||
}); err != nil {
|
||||
logger.Errorf("failed to edit message: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
files = make([]tfile.TGFileMessage, 0, len(msgLinks))
|
||||
addFile := func(client tfile.DlerClient, msg *tg.Message) {
|
||||
if msg == nil || msg.Media == nil {
|
||||
logger.Warn("message is nil, skipping")
|
||||
return
|
||||
}
|
||||
media, ok := msg.GetMedia()
|
||||
if !ok {
|
||||
logger.Debugf("message %d has no media", msg.GetID())
|
||||
return
|
||||
}
|
||||
file, err := tfile.FromMediaMessage(media, client, msg, tfile.WithNameIfEmpty(tgutil.GenFileNameFromMessage(*msg)))
|
||||
if err != nil {
|
||||
logger.Errorf("failed to create file from media: %s", err)
|
||||
return
|
||||
}
|
||||
files = append(files, file)
|
||||
}
|
||||
|
||||
tctx := ctx
|
||||
if config.Cfg.Telegram.Userbot.Enable {
|
||||
tctx = uc.GetCtx()
|
||||
}
|
||||
|
||||
for _, link := range msgLinks {
|
||||
linkUrl, err := url.Parse(link)
|
||||
if err != nil {
|
||||
logger.Errorf("failed to parse message link %s: %s", link, err)
|
||||
continue
|
||||
}
|
||||
chatId, msgId, err := tgutil.ParseMessageLink(tctx, link)
|
||||
if err != nil {
|
||||
logger.Errorf("failed to parse message link %s: %s", link, err)
|
||||
continue
|
||||
}
|
||||
msg, err := tgutil.GetMessageByID(tctx, chatId, msgId)
|
||||
if err != nil {
|
||||
logger.Errorf("failed to get message by ID: %s", err)
|
||||
continue
|
||||
}
|
||||
groupID, isGroup := msg.GetGroupedID()
|
||||
if isGroup && groupID != 0 && !linkUrl.Query().Has("single") {
|
||||
gmsgs, err := tgutil.GetGroupedMessages(ctx, chatId, msg)
|
||||
if err != nil {
|
||||
logger.Errorf("failed to get grouped messages: %s", err)
|
||||
} else {
|
||||
for _, gmsg := range gmsgs {
|
||||
addFile(tctx.Raw, gmsg)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
addFile(tctx.Raw, msg)
|
||||
}
|
||||
}
|
||||
if len(files) == 0 {
|
||||
editReplied("没有找到可保存的文件", nil)
|
||||
return nil, nil, nil, dispatcher.EndGroups
|
||||
}
|
||||
return replied, files, editReplied, nil
|
||||
}
|
||||
|
||||
func GetCallbackDataWithAnswer[DataType any](ctx *ext.Context, update *ext.Update, dataid string) (DataType, error) {
|
||||
data, ok := cache.Get[DataType](dataid)
|
||||
if !ok {
|
||||
log.FromContext(ctx).Warnf("Invalid data ID: %s", dataid)
|
||||
queryID := update.CallbackQuery.GetQueryID()
|
||||
ctx.AnswerCallback(msgelem.AlertCallbackAnswer(queryID, "数据已过期或无效"))
|
||||
var zero DataType
|
||||
return zero, dispatcher.EndGroups
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
type TelegraphResult struct {
|
||||
Pics []string `json:"pics"` // image urls
|
||||
TphDir string `json:"tph_dir"` // telegraph path, unescaped
|
||||
Page *telegraph.Page `json:"page"` // telegraph page node
|
||||
}
|
||||
|
||||
// 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
|
||||
if tphurl == "" {
|
||||
logger.Warnf("No telegraph url found but called handleTelegraph")
|
||||
return nil, nil, dispatcher.ContinueGroups
|
||||
}
|
||||
pagepath := strings.Split(tphurl, "/")[len(strings.Split(tphurl, "/"))-1]
|
||||
tphdir, err := url.PathUnescape(pagepath)
|
||||
if err != nil {
|
||||
logger.Errorf("Failed to unescape telegraph path: %s", err)
|
||||
ctx.Reply(update, ext.ReplyTextString("解析 telegraph 路径失败: "+err.Error()), nil)
|
||||
return nil, nil, dispatcher.EndGroups
|
||||
}
|
||||
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
|
||||
}
|
||||
page, err := tphutil.DefaultClient().GetPage(ctx, pagepath)
|
||||
if err != nil {
|
||||
logger.Errorf("Failed to get telegraph page: %s", err)
|
||||
ctx.Reply(update, ext.ReplyTextString("获取 telegraph 页面失败: "+err.Error()), nil)
|
||||
return nil, nil, dispatcher.EndGroups
|
||||
}
|
||||
imgs := make([]string, 0)
|
||||
for _, elem := range page.Content {
|
||||
var node telegraph.NodeElement
|
||||
data, err := json.Marshal(elem)
|
||||
if err != nil {
|
||||
logger.Errorf("Failed to marshal element: %s", err)
|
||||
continue
|
||||
}
|
||||
err = json.Unmarshal(data, &node)
|
||||
if err != nil {
|
||||
logger.Errorf("Failed to unmarshal element: %s", err)
|
||||
continue
|
||||
}
|
||||
|
||||
if len(node.Children) != 0 {
|
||||
for _, child := range node.Children {
|
||||
imgs = append(imgs, tphutil.GetNodeImages(child)...)
|
||||
}
|
||||
}
|
||||
if node.Tag == "img" {
|
||||
if src, ok := node.Attrs["src"]; ok {
|
||||
imgs = append(imgs, src)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(imgs) == 0 {
|
||||
logger.Warn("No images found in telegraph page")
|
||||
ctx.Reply(update, ext.ReplyTextString("在 telegraph 页面中未找到图片"), nil)
|
||||
return nil, nil, dispatcher.EndGroups
|
||||
}
|
||||
return msg, &TelegraphResult{
|
||||
Pics: imgs,
|
||||
TphDir: tphdir,
|
||||
Page: page,
|
||||
}, nil
|
||||
}
|
||||
200
client/bot/handlers/utils/shortcut/tftask.go
Normal file
200
client/bot/handlers/utils/shortcut/tftask.go
Normal file
@@ -0,0 +1,200 @@
|
||||
package shortcut
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path"
|
||||
"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/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/database"
|
||||
"github.com/krau/SaveAny-Bot/pkg/tfile"
|
||||
"github.com/krau/SaveAny-Bot/storage"
|
||||
"github.com/rs/xid"
|
||||
)
|
||||
|
||||
// 创建一个 tftask.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)
|
||||
if err != nil {
|
||||
logger.Errorf("Failed to get user by chat ID: %s", err)
|
||||
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
|
||||
ID: trackMsgID,
|
||||
Message: "获取用户失败: " + err.Error(),
|
||||
})
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
if user.ApplyRule && user.Rules != nil {
|
||||
matchedStorageName, matchedDirPath := ruleutil.ApplyRule(ctx, user.Rules, ruleutil.NewInput(file))
|
||||
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)
|
||||
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
|
||||
ID: trackMsgID,
|
||||
Message: "获取存储失败: " + err.Error(),
|
||||
})
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
storagePath := stor.JoinStoragePath(path.Join(dirPath, file.Name()))
|
||||
injectCtx := tgutil.ExtWithContext(ctx.Context, ctx)
|
||||
taskid := xid.New().String()
|
||||
task, err := tftask.NewTGFileTask(taskid, injectCtx, file, stor, storagePath,
|
||||
tftask.NewProgressTrack(
|
||||
trackMsgID,
|
||||
userID))
|
||||
if err != nil {
|
||||
logger.Errorf("create task failed: %s", err)
|
||||
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
|
||||
ID: trackMsgID,
|
||||
Message: "创建任务失败: " + err.Error(),
|
||||
})
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
if err := core.AddTask(injectCtx, task); err != nil {
|
||||
logger.Errorf("add task failed: %s", err)
|
||||
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
|
||||
ID: trackMsgID,
|
||||
Message: "添加任务失败: " + err.Error(),
|
||||
})
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
text, entities := msgelem.BuildTaskAddedEntities(ctx, file.Name(), core.GetLength(injectCtx))
|
||||
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
|
||||
ID: trackMsgID,
|
||||
Message: text,
|
||||
Entities: entities,
|
||||
})
|
||||
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
|
||||
// 创建一个 batchtftask.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)
|
||||
if err != nil {
|
||||
logger.Errorf("Failed to get user by chat ID: %s", err)
|
||||
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
|
||||
ID: trackMsgID,
|
||||
Message: "获取用户失败: " + err.Error(),
|
||||
})
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
|
||||
useRule := user.ApplyRule && user.Rules != nil
|
||||
|
||||
applyRule := func(file tfile.TGFileMessage) (string, ruleutil.MatchedDirPath) {
|
||||
if !useRule {
|
||||
return stor.Name(), ruleutil.MatchedDirPath(dirPath)
|
||||
}
|
||||
storName, dirP := ruleutil.ApplyRule(ctx, user.Rules, ruleutil.NewInput(file))
|
||||
|
||||
storname := storName.String()
|
||||
if !storName.IsUsable() {
|
||||
storname = stor.Name()
|
||||
}
|
||||
return storname, dirP
|
||||
}
|
||||
|
||||
elems := make([]batchtftask.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
|
||||
if storName != stor.Name() && storName != "" {
|
||||
fileStor, err = storage.GetStorageByUserIDAndName(ctx, user.ChatID, storName)
|
||||
if err != nil {
|
||||
logger.Errorf("Failed to get storage by user ID and name: %s", err)
|
||||
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
|
||||
ID: trackMsgID,
|
||||
Message: "获取存储失败: " + err.Error(),
|
||||
})
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
}
|
||||
if !dirPath.NeedNewForAlbum() {
|
||||
storPath := fileStor.JoinStoragePath(path.Join(dirPath.String(), 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(),
|
||||
})
|
||||
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,
|
||||
})
|
||||
}
|
||||
}
|
||||
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 := batchtftask.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)
|
||||
if err := core.AddTask(injectCtx, task); err != nil {
|
||||
logger.Errorf("Failed to add batch task: %s", err)
|
||||
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
|
||||
ID: trackMsgID,
|
||||
Message: "批量任务添加失败: " + err.Error(),
|
||||
})
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
|
||||
ID: trackMsgID,
|
||||
Message: fmt.Sprintf("已添加批量任务, 共 %d 个文件", len(files)),
|
||||
ReplyMarkup: nil,
|
||||
})
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
50
client/bot/handlers/utils/shortcut/tphtask.go
Normal file
50
client/bot/handlers/utils/shortcut/tphtask.go
Normal file
@@ -0,0 +1,50 @@
|
||||
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/common/utils/tphutil"
|
||||
"github.com/krau/SaveAny-Bot/core"
|
||||
"github.com/krau/SaveAny-Bot/core/tphtask"
|
||||
"github.com/krau/SaveAny-Bot/pkg/telegraph"
|
||||
"github.com/krau/SaveAny-Bot/storage"
|
||||
"github.com/rs/xid"
|
||||
)
|
||||
|
||||
func CreateAndAddTphTaskWithEdit(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,
|
||||
tphpage.Path,
|
||||
pics,
|
||||
stor,
|
||||
stor.JoinStoragePath(dirPath),
|
||||
tphutil.DefaultClient(),
|
||||
tphtask.NewProgress(trackMsgID, 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: trackMsgID,
|
||||
Message: "任务添加失败: " + err.Error(),
|
||||
})
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
text, entities := msgelem.BuildTaskAddedEntities(ctx, tphpage.Title, core.GetLength(ctx))
|
||||
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
|
||||
ID: trackMsgID,
|
||||
Message: text,
|
||||
Entities: entities,
|
||||
})
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
30
client/middleware/default.go
Normal file
30
client/middleware/default.go
Normal file
@@ -0,0 +1,30 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/cenkalti/backoff/v4"
|
||||
"github.com/gotd/contrib/middleware/floodwait"
|
||||
"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(config.Cfg.Telegram.RpcRetry),
|
||||
floodwait.NewSimpleWaiter(),
|
||||
}
|
||||
}
|
||||
|
||||
func newBackoff(timeout time.Duration) backoff.BackOff {
|
||||
b := backoff.NewExponentialBackOff()
|
||||
b.Multiplier = 1.1
|
||||
b.MaxElapsedTime = timeout
|
||||
b.MaxInterval = 10 * time.Second
|
||||
return b
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package bot
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"time"
|
||||
@@ -9,8 +9,8 @@ import (
|
||||
"golang.org/x/time/rate"
|
||||
)
|
||||
|
||||
func FloodWaitMiddleware() []telegram.Middleware {
|
||||
waiter := floodwait.NewSimpleWaiter().WithMaxRetries(5)
|
||||
func NewFloodWaitMiddlewares(maxRetries uint) []telegram.Middleware {
|
||||
waiter := floodwait.NewSimpleWaiter().WithMaxRetries(maxRetries)
|
||||
ratelimiter := ratelimit.New(rate.Every(time.Millisecond*100), 5)
|
||||
return []telegram.Middleware{
|
||||
waiter,
|
||||
61
client/middleware/recovery/recovery.go
Normal file
61
client/middleware/recovery/recovery.go
Normal file
@@ -0,0 +1,61 @@
|
||||
package recovery
|
||||
|
||||
import (
|
||||
"context"
|
||||
"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"
|
||||
"github.com/gotd/td/tgerr"
|
||||
)
|
||||
|
||||
type recovery struct {
|
||||
ctx context.Context
|
||||
backoff backoff.BackOff
|
||||
}
|
||||
|
||||
func New(ctx context.Context, backoff backoff.BackOff) telegram.Middleware {
|
||||
return &recovery{
|
||||
ctx: ctx,
|
||||
backoff: backoff,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *recovery) Handle(next tg.Invoker) telegram.InvokeFunc {
|
||||
return func(ctx context.Context, input bin.Encoder, output bin.Decoder) error {
|
||||
|
||||
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 backoff.Permanent(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}, r.backoff, func(err error, duration time.Duration) {
|
||||
log.FromContext(ctx).Debug("Wait for connection recovery", "error", err, "duration", duration)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (r *recovery) shouldRecover(ctx context.Context, err error) bool {
|
||||
// context in recovery is used to stop recovery process by external os signal, otherwise we will wait till max retries when user press ctrl+c
|
||||
select {
|
||||
case <-r.ctx.Done():
|
||||
return false
|
||||
case <-ctx.Done():
|
||||
return false
|
||||
default:
|
||||
}
|
||||
|
||||
// we try recover when encountered any error that is not telegram business error
|
||||
_, ok := tgerr.As(err)
|
||||
|
||||
return !ok
|
||||
}
|
||||
56
client/middleware/retry/retry.go
Normal file
56
client/middleware/retry/retry.go
Normal file
@@ -0,0 +1,56 @@
|
||||
package retry
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/charmbracelet/log"
|
||||
"github.com/gotd/td/bin"
|
||||
"github.com/gotd/td/telegram"
|
||||
"github.com/gotd/td/tg"
|
||||
"github.com/gotd/td/tgerr"
|
||||
)
|
||||
|
||||
var internalErrors = []string{
|
||||
"Timedout", // #373
|
||||
"No workers running",
|
||||
"RPC_CALL_FAIL",
|
||||
"RPC_MCGET_FAIL",
|
||||
"WORKER_BUSY_TOO_LONG_RETRY", // #462
|
||||
"memory limit exit", // #504
|
||||
}
|
||||
|
||||
type retry struct {
|
||||
max int
|
||||
errors []string
|
||||
}
|
||||
|
||||
func (r retry) Handle(next tg.Invoker) telegram.InvokeFunc {
|
||||
return func(ctx context.Context, input bin.Encoder, output bin.Decoder) error {
|
||||
retries := 0
|
||||
|
||||
for retries < r.max {
|
||||
if err := next.Invoke(ctx, input, output); err != nil {
|
||||
if tgerr.Is(err, r.errors...) {
|
||||
log.FromContext(ctx).Debug("retry middleware", "retries", retries, "error", err)
|
||||
retries++
|
||||
continue
|
||||
}
|
||||
// retry middleware skip
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf("retry limit reached after %d attempts", r.max)
|
||||
}
|
||||
}
|
||||
|
||||
// New returns middleware that retries request if it fails with one of provided errors.
|
||||
func New(max int, errors ...string) telegram.Middleware {
|
||||
return retry{
|
||||
max: max,
|
||||
errors: append(errors, internalErrors...), // #373
|
||||
}
|
||||
}
|
||||
80
client/user/auth_terminal.go
Normal file
80
client/user/auth_terminal.go
Normal file
@@ -0,0 +1,80 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/celestix/gotgproto"
|
||||
"github.com/charmbracelet/huh"
|
||||
"github.com/charmbracelet/log"
|
||||
"github.com/fatih/color"
|
||||
)
|
||||
|
||||
type terminalAuthConversator struct{}
|
||||
|
||||
func (t *terminalAuthConversator) AskPhoneNumber() (string, error) {
|
||||
phone := ""
|
||||
err := huh.NewInput().Title("Your Phone Number").
|
||||
Placeholder("+44 123456").
|
||||
Prompt("> ").
|
||||
Value(&phone).
|
||||
WithTheme(huh.ThemeCatppuccin()).
|
||||
Run()
|
||||
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
log.Info("Sending code to your phone number...")
|
||||
|
||||
return strings.TrimSpace(phone), nil
|
||||
}
|
||||
|
||||
func (t *terminalAuthConversator) AskCode() (string, error) {
|
||||
code := ""
|
||||
err := huh.NewInput().Title("Your Code").
|
||||
Placeholder("123456").
|
||||
Value(&code).
|
||||
Prompt("> ").
|
||||
WithTheme(huh.ThemeCatppuccin()).
|
||||
Run()
|
||||
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return strings.TrimSpace(code), nil
|
||||
}
|
||||
|
||||
func (t *terminalAuthConversator) AskPassword() (string, error) {
|
||||
pwd := ""
|
||||
|
||||
err := huh.NewInput().Title("Your 2FA Password").
|
||||
EchoMode(huh.EchoModePassword).
|
||||
Value(&pwd).
|
||||
Prompt("> ").
|
||||
WithTheme(huh.ThemeCatppuccin()).
|
||||
Run()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return strings.TrimSpace(pwd), nil
|
||||
}
|
||||
|
||||
func (t *terminalAuthConversator) AuthStatus(authStatus gotgproto.AuthStatus) {
|
||||
switch authStatus.Event {
|
||||
case gotgproto.AuthStatusPhoneRetrial:
|
||||
color.Red("The phone number you just entered seems to be incorrect,")
|
||||
color.Red("Attempts Left: %d", authStatus.AttemptsLeft)
|
||||
color.Red("Please try again....")
|
||||
case gotgproto.AuthStatusPasswordRetrial:
|
||||
color.Red("The 2FA password you just entered seems to be incorrect,")
|
||||
color.Red("Attempts Left: %d", authStatus.AttemptsLeft)
|
||||
color.Red("Please try again....")
|
||||
case gotgproto.AuthStatusPhoneCodeRetrial:
|
||||
color.Red("The OTP you just entered seems to be incorrect,")
|
||||
color.Red("Attempts Left: %d", authStatus.AttemptsLeft)
|
||||
color.Red("Please try again....")
|
||||
default:
|
||||
}
|
||||
}
|
||||
112
client/user/userclient.go
Normal file
112
client/user/userclient.go
Normal file
@@ -0,0 +1,112 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/celestix/gotgproto"
|
||||
"github.com/celestix/gotgproto/dispatcher"
|
||||
"github.com/celestix/gotgproto/ext"
|
||||
"github.com/celestix/gotgproto/sessionMaker"
|
||||
"github.com/charmbracelet/log"
|
||||
"github.com/gotd/td/telegram/dcs"
|
||||
"github.com/krau/SaveAny-Bot/client/middleware"
|
||||
"github.com/krau/SaveAny-Bot/common/utils/netutil"
|
||||
"github.com/krau/SaveAny-Bot/config"
|
||||
"github.com/ncruces/go-sqlite3/gormlite"
|
||||
"golang.org/x/net/proxy"
|
||||
)
|
||||
|
||||
var uc *gotgproto.Client
|
||||
var ectx *ext.Context
|
||||
|
||||
func GetCtx() *ext.Context {
|
||||
if uc == nil {
|
||||
panic("User client is not initialized, please call Login first")
|
||||
}
|
||||
if ectx != nil {
|
||||
return ectx
|
||||
}
|
||||
ectx = uc.CreateContext()
|
||||
return ectx
|
||||
}
|
||||
|
||||
func GetClient() *gotgproto.Client {
|
||||
if uc == nil {
|
||||
panic("User client is not initialized, please call Login first")
|
||||
}
|
||||
return uc
|
||||
}
|
||||
|
||||
func Login(ctx context.Context) (*gotgproto.Client, error) {
|
||||
log.FromContext(ctx).Debug("Logging in user client")
|
||||
if uc != nil {
|
||||
return uc, nil
|
||||
}
|
||||
res := make(chan struct {
|
||||
client *gotgproto.Client
|
||||
err 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 err != nil {
|
||||
res <- struct {
|
||||
client *gotgproto.Client
|
||||
err error
|
||||
}{nil, err}
|
||||
return
|
||||
}
|
||||
resolver = dcs.Plain(dcs.PlainOptions{
|
||||
Dial: dialer.(proxy.ContextDialer).DialContext,
|
||||
})
|
||||
} else {
|
||||
resolver = dcs.DefaultResolver()
|
||||
}
|
||||
tclient, err := gotgproto.NewClient(
|
||||
config.Cfg.Telegram.AppID,
|
||||
config.Cfg.Telegram.AppHash,
|
||||
gotgproto.ClientTypePhone(""),
|
||||
&gotgproto.ClientOpts{
|
||||
Session: sessionMaker.SqlSession(gormlite.Open(config.Cfg.Telegram.Userbot.Session)),
|
||||
AuthConversator: &terminalAuthConversator{},
|
||||
Context: ctx,
|
||||
DisableCopyright: true,
|
||||
Resolver: resolver,
|
||||
MaxRetries: config.Cfg.Telegram.RpcRetry,
|
||||
AutoFetchReply: true,
|
||||
Middlewares: middleware.NewDefaultMiddlewares(ctx, 5*time.Minute),
|
||||
ErrorHandler: func(ctx *ext.Context, u *ext.Update, s string) error {
|
||||
log.FromContext(ctx).Errorf("Unhandled error: %s", s)
|
||||
return dispatcher.EndGroups
|
||||
},
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
res <- struct {
|
||||
client *gotgproto.Client
|
||||
err error
|
||||
}{nil, err}
|
||||
}
|
||||
res <- struct {
|
||||
client *gotgproto.Client
|
||||
err error
|
||||
}(struct {
|
||||
client *gotgproto.Client
|
||||
err error
|
||||
}{tclient, nil})
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case r := <-res:
|
||||
if r.err != nil {
|
||||
return nil, r.err
|
||||
}
|
||||
uc = r.client
|
||||
log.FromContext(ctx).Infof("User client logged in successfully: %s", uc.Self.FirstName+" "+uc.Self.LastName)
|
||||
return uc, nil
|
||||
}
|
||||
}
|
||||
84
cmd/geni18n/main.go
Normal file
84
cmd/geni18n/main.go
Normal file
@@ -0,0 +1,84 @@
|
||||
// cmd/geni18n/main.go
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func main() {
|
||||
dir := flag.String("dir", "./common/i18n/locale", "Locales directory path")
|
||||
out := flag.String("out", "common/i18n/i18nk/keys.go", "Output file path")
|
||||
pkg := flag.String("pkg", "i18nk", "Package name for generated file")
|
||||
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") {
|
||||
return nil
|
||||
}
|
||||
f, err := os.Open(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{}{}
|
||||
}
|
||||
}
|
||||
return s.Err()
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error walking directory: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
var list []string
|
||||
for k := range keys {
|
||||
list = append(list, k)
|
||||
}
|
||||
sort.Strings(list)
|
||||
|
||||
f, err := os.Create(*out)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error creating output file: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
w := bufio.NewWriter(f)
|
||||
fmt.Fprintf(w, "// Code generated by cmd/gen_i18n. DO NOT EDIT.\n")
|
||||
fmt.Fprintf(w, "package %s\n\n", *pkg)
|
||||
fmt.Fprintf(w, "const (\n")
|
||||
for _, key := range list {
|
||||
name := toPascal(key)
|
||||
fmt.Fprintf(w, "\t%s = %q\n", name, key)
|
||||
}
|
||||
fmt.Fprintf(w, ")\n")
|
||||
w.Flush()
|
||||
}
|
||||
|
||||
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:]
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, "")
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
@@ -12,8 +13,8 @@ var rootCmd = &cobra.Command{
|
||||
Run: Run,
|
||||
}
|
||||
|
||||
func Execute() {
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
func Execute(ctx context.Context) {
|
||||
if err := rootCmd.ExecuteContext(ctx); err != nil {
|
||||
fmt.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
95
cmd/run.go
95
cmd/run.go
@@ -1,51 +1,100 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/krau/SaveAny-Bot/bootstrap"
|
||||
"slices"
|
||||
|
||||
"github.com/charmbracelet/log"
|
||||
"github.com/krau/SaveAny-Bot/client/bot"
|
||||
userclient "github.com/krau/SaveAny-Bot/client/user"
|
||||
"github.com/krau/SaveAny-Bot/common/cache"
|
||||
"github.com/krau/SaveAny-Bot/common/i18n"
|
||||
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
|
||||
"github.com/krau/SaveAny-Bot/common/utils/fsutil"
|
||||
"github.com/krau/SaveAny-Bot/config"
|
||||
"github.com/krau/SaveAny-Bot/core"
|
||||
"github.com/krau/SaveAny-Bot/logger"
|
||||
"github.com/krau/SaveAny-Bot/database"
|
||||
"github.com/krau/SaveAny-Bot/storage"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func Run(_ *cobra.Command, _ []string) {
|
||||
bootstrap.InitAll()
|
||||
core.Run()
|
||||
func Run(cmd *cobra.Command, _ []string) {
|
||||
ctx := cmd.Context()
|
||||
logger := log.NewWithOptions(os.Stdout, log.Options{
|
||||
Level: log.DebugLevel,
|
||||
ReportTimestamp: true,
|
||||
TimeFormat: time.TimeOnly,
|
||||
ReportCaller: true,
|
||||
})
|
||||
ctx = log.WithContext(ctx, logger)
|
||||
|
||||
quit := make(chan os.Signal, 1)
|
||||
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||
sig := <-quit
|
||||
logger.L.Info(sig, ", exitting...")
|
||||
defer logger.L.Info("Bye!")
|
||||
initAll(ctx)
|
||||
core.Run(ctx)
|
||||
|
||||
<-ctx.Done()
|
||||
logger.Info(i18n.T(i18nk.Exiting))
|
||||
defer logger.Info(i18n.T(i18nk.Bye))
|
||||
cleanCache()
|
||||
}
|
||||
|
||||
func initAll(ctx context.Context) {
|
||||
if err := config.Init(ctx); err != nil {
|
||||
fmt.Println("Failed to load config:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
cache.Init()
|
||||
logger := log.FromContext(ctx)
|
||||
i18n.Init(config.Cfg.Lang)
|
||||
logger.Info(i18n.T(i18nk.Initing))
|
||||
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)
|
||||
}
|
||||
}
|
||||
bot.Init(ctx)
|
||||
}
|
||||
|
||||
func cleanCache() {
|
||||
if config.Cfg.NoCleanCache {
|
||||
return
|
||||
}
|
||||
if config.Cfg.Temp.BasePath != "" {
|
||||
for _, path := range []string{"/", ".", "\\", ".."} {
|
||||
if filepath.Clean(config.Cfg.Temp.BasePath) == path {
|
||||
logger.L.Error("Invalid cache dir: ", config.Cfg.Temp.BasePath)
|
||||
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,
|
||||
}))
|
||||
return
|
||||
}
|
||||
currentDir, err := os.Getwd()
|
||||
if err != nil {
|
||||
logger.L.Error("Failed to get current dir: ", err)
|
||||
log.Error(i18n.T(i18nk.GetWorkdirFailed, map[string]any{
|
||||
"Error": err,
|
||||
}))
|
||||
return
|
||||
}
|
||||
cachePath := filepath.Join(currentDir, config.Cfg.Temp.BasePath)
|
||||
cachePath, err = filepath.Abs(cachePath)
|
||||
if err != nil {
|
||||
logger.L.Error("Failed to get absolute path: ", err)
|
||||
log.Error(i18n.T(i18nk.GetCacheAbsPathFailed, map[string]any{
|
||||
"Error": err,
|
||||
}))
|
||||
return
|
||||
}
|
||||
logger.L.Info("Cleaning cache dir: ", cachePath)
|
||||
if err := os.RemoveAll(cachePath); err != nil {
|
||||
logger.L.Error("Failed to clean cache dir: ", err)
|
||||
log.Info(i18n.T(i18nk.CleaningCache, map[string]any{
|
||||
"Path": cachePath,
|
||||
}))
|
||||
if err := fsutil.RemoveAllInDir(cachePath); err != nil {
|
||||
log.Error(i18n.T(i18nk.CleanCacheFailed, map[string]any{
|
||||
"Error": err,
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"fmt"
|
||||
"runtime"
|
||||
|
||||
"github.com/krau/SaveAny-Bot/common"
|
||||
"github.com/krau/SaveAny-Bot/pkg/consts"
|
||||
"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", common.Version, runtime.GOOS, runtime.GOARCH, common.BuildTime, common.GitCommit)
|
||||
fmt.Printf("saveany-bot version: %s %s/%s\nBuildTime: %s, Commit: %s\n", consts.Version, runtime.GOOS, runtime.GOARCH, consts.BuildTime, consts.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(common.Version)
|
||||
v := semver.MustParse(consts.Version)
|
||||
latest, err := selfupdate.UpdateSelf(v, "krau/SaveAny-Bot")
|
||||
if err != nil {
|
||||
fmt.Println("Binary update failed:", err)
|
||||
return
|
||||
}
|
||||
if latest.Version.Equals(v) {
|
||||
fmt.Println("Current binary is the latest version", common.Version)
|
||||
fmt.Println("Current binary is the latest version", consts.Version)
|
||||
} else {
|
||||
fmt.Println("Successfully updated to version", latest.Version)
|
||||
fmt.Println("Release note:\n", latest.ReleaseNotes)
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/gob"
|
||||
"sync"
|
||||
|
||||
"github.com/coocood/freecache"
|
||||
"github.com/gotd/td/tg"
|
||||
"github.com/krau/SaveAny-Bot/types"
|
||||
)
|
||||
|
||||
type CommonCache struct {
|
||||
cache *freecache.Cache
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
var Cache *CommonCache
|
||||
|
||||
func initCache() {
|
||||
gob.Register(types.File{})
|
||||
gob.Register(tg.InputDocumentFileLocation{})
|
||||
gob.Register(tg.InputPhotoFileLocation{})
|
||||
Cache = &CommonCache{cache: freecache.NewCache(10 * 1024 * 1024)}
|
||||
}
|
||||
|
||||
func (c *CommonCache) Get(key string, value *types.File) error {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
data, err := Cache.cache.Get([]byte(key))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dec := gob.NewDecoder(bytes.NewReader(data))
|
||||
err = dec.Decode(&value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *CommonCache) Set(key string, value *types.File, expireSeconds int) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
var buf bytes.Buffer
|
||||
enc := gob.NewEncoder(&buf)
|
||||
err := enc.Encode(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
Cache.cache.Set([]byte(key), buf.Bytes(), expireSeconds)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *CommonCache) Delete(key string) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
Cache.cache.Del([]byte(key))
|
||||
return nil
|
||||
}
|
||||
53
common/cache/ristretto.go
vendored
Normal file
53
common/cache/ristretto.go
vendored
Normal file
@@ -0,0 +1,53 @@
|
||||
package cache
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/log"
|
||||
"github.com/dgraph-io/ristretto/v2"
|
||||
"github.com/krau/SaveAny-Bot/config"
|
||||
)
|
||||
|
||||
var cache *ristretto.Cache[string, any]
|
||||
|
||||
func Init() {
|
||||
if cache != nil {
|
||||
panic("cache already initialized")
|
||||
}
|
||||
c, err := ristretto.NewCache(&ristretto.Config[string, any]{
|
||||
NumCounters: config.Cfg.Cache.NumCounters,
|
||||
MaxCost: config.Cfg.Cache.MaxCost,
|
||||
BufferItems: 64,
|
||||
OnReject: func(item *ristretto.Item[any]) {
|
||||
log.Warnf("Cache item rejected: key=%d, value=%v", item.Key, item.Value)
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("failed to create ristretto cache: %v", err)
|
||||
}
|
||||
cache = c
|
||||
}
|
||||
|
||||
func Set(key string, value any) error {
|
||||
ok := cache.SetWithTTL(key, value, 0, time.Duration(config.Cfg.Cache.TTL)*time.Second)
|
||||
if !ok {
|
||||
return fmt.Errorf("failed to set value in cache")
|
||||
}
|
||||
cache.Wait()
|
||||
return nil
|
||||
}
|
||||
|
||||
func Get[T any](key string) (T, bool) {
|
||||
v, ok := cache.Get(key)
|
||||
if !ok {
|
||||
var zero T
|
||||
return zero, false
|
||||
}
|
||||
vT, ok := v.(T)
|
||||
if !ok {
|
||||
var zero T
|
||||
return zero, false
|
||||
}
|
||||
return vT, true
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
package common
|
||||
|
||||
func Init() {
|
||||
initCache()
|
||||
}
|
||||
108
common/i18n/i18n.go
Normal file
108
common/i18n/i18n.go
Normal file
@@ -0,0 +1,108 @@
|
||||
package i18n
|
||||
|
||||
import (
|
||||
"embed"
|
||||
|
||||
"maps"
|
||||
|
||||
"github.com/nicksnyder/go-i18n/v2/i18n"
|
||||
"github.com/pelletier/go-toml/v2"
|
||||
"golang.org/x/text/language"
|
||||
)
|
||||
|
||||
//go:embed locale/*.toml
|
||||
var localesFS embed.FS
|
||||
|
||||
var (
|
||||
bundle *i18n.Bundle
|
||||
localizer *i18n.Localizer
|
||||
)
|
||||
|
||||
func Init(lang string) {
|
||||
bundle = i18n.NewBundle(language.SimplifiedChinese)
|
||||
bundle.RegisterUnmarshalFunc("toml", toml.Unmarshal)
|
||||
files, err := localesFS.ReadDir("locale")
|
||||
if err != nil {
|
||||
panic("failed to read locale directory: " + err.Error())
|
||||
}
|
||||
for _, file := range files {
|
||||
if _, err := bundle.LoadMessageFileFS(localesFS, "locale/"+file.Name()); err != nil {
|
||||
panic("failed to load message file: " + err.Error())
|
||||
}
|
||||
}
|
||||
if lang == "" {
|
||||
lang = "zh-Hans"
|
||||
}
|
||||
localizer = i18n.NewLocalizer(bundle, lang)
|
||||
if localizer == nil {
|
||||
panic("failed to create localizer, check your config for valid language setting")
|
||||
}
|
||||
}
|
||||
|
||||
func T(key string, templateData ...map[string]any) string {
|
||||
if localizer == nil || bundle == nil {
|
||||
panic("localizer or bundle is not initialized, call Init() first")
|
||||
}
|
||||
templateDataMap := make(map[string]any)
|
||||
for _, data := range templateData {
|
||||
maps.Copy(templateDataMap, data)
|
||||
}
|
||||
msg, err := localizer.Localize(&i18n.LocalizeConfig{
|
||||
MessageID: key,
|
||||
TemplateData: templateDataMap,
|
||||
})
|
||||
if err != nil {
|
||||
return key
|
||||
}
|
||||
return msg
|
||||
}
|
||||
|
||||
func TWithLang(lang, key string, templateData ...map[string]any) string {
|
||||
if bundle == nil {
|
||||
panic("bundle is not initialized, call Init() first")
|
||||
}
|
||||
templateDataMap := make(map[string]any)
|
||||
for _, data := range templateData {
|
||||
maps.Copy(templateDataMap, data)
|
||||
}
|
||||
localizerWithLang := i18n.NewLocalizer(bundle, lang)
|
||||
msg, err := localizerWithLang.Localize(&i18n.LocalizeConfig{
|
||||
MessageID: key,
|
||||
TemplateData: templateDataMap,
|
||||
})
|
||||
if err != nil {
|
||||
return key
|
||||
}
|
||||
return msg
|
||||
}
|
||||
|
||||
// Only use in tests or packages that load before i18n
|
||||
func TWithoutInit(lang, key string, templateData ...map[string]any) string {
|
||||
bundle := i18n.NewBundle(language.SimplifiedChinese)
|
||||
bundle.RegisterUnmarshalFunc("toml", toml.Unmarshal)
|
||||
files, err := localesFS.ReadDir("locale")
|
||||
if err != nil {
|
||||
return key
|
||||
}
|
||||
for _, file := range files {
|
||||
if _, err := bundle.LoadMessageFileFS(localesFS, "locale/"+file.Name()); err != nil {
|
||||
return key
|
||||
}
|
||||
}
|
||||
localizer := i18n.NewLocalizer(bundle, lang)
|
||||
if localizer == nil {
|
||||
return key
|
||||
}
|
||||
templateDataMap := make(map[string]any)
|
||||
for _, data := range templateData {
|
||||
maps.Copy(templateDataMap, data)
|
||||
}
|
||||
msg, err := localizer.Localize(&i18n.LocalizeConfig{
|
||||
MessageID: key,
|
||||
TemplateData: templateDataMap,
|
||||
})
|
||||
if err != nil {
|
||||
return key
|
||||
}
|
||||
return msg
|
||||
}
|
||||
19
common/i18n/i18nk/keys.go
Normal file
19
common/i18n/i18nk/keys.go
Normal file
@@ -0,0 +1,19 @@
|
||||
// Code generated by cmd/gen_i18n. DO NOT EDIT.
|
||||
package i18nk
|
||||
|
||||
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"
|
||||
)
|
||||
28
common/i18n/locale/zh-Hans.toml
Normal file
28
common/i18n/locale/zh-Hans.toml
Normal file
@@ -0,0 +1,28 @@
|
||||
[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}}"
|
||||
57
common/os.go
57
common/os.go
@@ -1,57 +0,0 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/krau/SaveAny-Bot/logger"
|
||||
)
|
||||
|
||||
// 创建文件, 自动创建目录
|
||||
func MkFile(path string, data []byte) error {
|
||||
err := os.MkdirAll(filepath.Dir(path), os.ModePerm)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path, data, os.ModePerm)
|
||||
}
|
||||
|
||||
// 删除文件, 并清理空目录. 如果文件不存在则返回 nil
|
||||
func PurgeFile(path string) error {
|
||||
if err := os.Remove(path); err != nil {
|
||||
if !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return RemoveEmptyDirectories(filepath.Dir(path))
|
||||
}
|
||||
|
||||
func RmFileAfter(path string, td time.Duration) {
|
||||
_, err := os.Stat(path)
|
||||
if err != nil {
|
||||
logger.L.Errorf("Failed to create timer for %s: %s", path, err)
|
||||
return
|
||||
}
|
||||
logger.L.Debugf("Remove file after %s: %s", td, path)
|
||||
time.AfterFunc(td, func() {
|
||||
PurgeFile(path)
|
||||
})
|
||||
}
|
||||
|
||||
// 递归删除空目录
|
||||
func RemoveEmptyDirectories(dirPath string) error {
|
||||
entries, err := os.ReadDir(dirPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(entries) == 0 {
|
||||
err := os.Remove(dirPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return RemoveEmptyDirectories(filepath.Dir(dirPath))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"crypto/md5"
|
||||
"encoding/hex"
|
||||
)
|
||||
|
||||
func HashString(s string) string {
|
||||
hash := md5.New()
|
||||
hash.Write([]byte(s))
|
||||
return hex.EncodeToString(hash.Sum(nil))
|
||||
}
|
||||
33
common/utils/dlutil/dl.go
Normal file
33
common/utils/dlutil/dl.go
Normal file
@@ -0,0 +1,33 @@
|
||||
package dlutil
|
||||
|
||||
import "time"
|
||||
|
||||
var threadsLevels = []struct {
|
||||
threads int
|
||||
size int64
|
||||
}{
|
||||
{1, 10 << 20},
|
||||
{2, 50 << 20},
|
||||
{4, 200 << 20},
|
||||
{8, 500 << 20},
|
||||
}
|
||||
|
||||
func BestThreads(size int64, max int) int {
|
||||
for _, thread := range threadsLevels {
|
||||
if size < thread.size {
|
||||
return min(thread.threads, max)
|
||||
}
|
||||
}
|
||||
return max
|
||||
}
|
||||
|
||||
func GetSpeed(downloaded int64, startTime time.Time) float64 {
|
||||
if startTime.IsZero() {
|
||||
return 0
|
||||
}
|
||||
elapsed := time.Since(startTime).Seconds()
|
||||
if elapsed <= 0 {
|
||||
return 0
|
||||
}
|
||||
return float64(downloaded) / elapsed
|
||||
}
|
||||
57
common/utils/fsutil/fs.go
Normal file
57
common/utils/fsutil/fs.go
Normal file
@@ -0,0 +1,57 @@
|
||||
package fsutil
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/gabriel-vasile/mimetype"
|
||||
)
|
||||
|
||||
// 删除文件夹内的所有文件和子目录, 但不删除文件夹本身
|
||||
func RemoveAllInDir(dirPath string) error {
|
||||
entries, err := os.ReadDir(dirPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, entry := range entries {
|
||||
entryPath := filepath.Join(dirPath, entry.Name())
|
||||
if err := os.RemoveAll(entryPath); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func DetectFileExt(fp string) string {
|
||||
mt, err := mimetype.DetectFile(fp)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return mt.Extension()
|
||||
}
|
||||
|
||||
type File struct {
|
||||
*os.File
|
||||
}
|
||||
|
||||
func (f *File) Remove() error {
|
||||
return os.Remove(f.Name())
|
||||
}
|
||||
|
||||
func (f *File) CloseAndRemove() error {
|
||||
if err := f.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return f.Remove()
|
||||
}
|
||||
|
||||
func CreateFile(fp string) (*File, error) {
|
||||
if err := os.MkdirAll(filepath.Dir(fp), os.ModePerm); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
file, err := os.Create(fp)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &File{File: file}, nil
|
||||
}
|
||||
49
common/utils/ioutil/writer.go
Normal file
49
common/utils/ioutil/writer.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package ioutil
|
||||
|
||||
import "io"
|
||||
|
||||
type ProgressWriterAt struct {
|
||||
wrAt io.WriterAt
|
||||
onWrite func(n int)
|
||||
}
|
||||
|
||||
func (p *ProgressWriterAt) WriteAt(buf []byte, off int64) (n int, err error) {
|
||||
n, err = p.wrAt.WriteAt(buf, off)
|
||||
if n > 0 {
|
||||
p.onWrite(n)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func NewProgressWriterAt(
|
||||
wrAt io.WriterAt,
|
||||
onWrite func(n int),
|
||||
) *ProgressWriterAt {
|
||||
return &ProgressWriterAt{
|
||||
wrAt: wrAt,
|
||||
onWrite: onWrite,
|
||||
}
|
||||
}
|
||||
|
||||
type ProgressWriter struct {
|
||||
wr io.Writer
|
||||
onWrite func(n int)
|
||||
}
|
||||
|
||||
func (p *ProgressWriter) Write(buf []byte) (n int, err error) {
|
||||
n, err = p.wr.Write(buf)
|
||||
if n > 0 {
|
||||
p.onWrite(n)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func NewProgressWriter(
|
||||
wr io.Writer,
|
||||
onWrite func(n int),
|
||||
) *ProgressWriter {
|
||||
return &ProgressWriter{
|
||||
wr: wr,
|
||||
onWrite: onWrite,
|
||||
}
|
||||
}
|
||||
15
common/utils/netutil/proxy.go
Normal file
15
common/utils/netutil/proxy.go
Normal file
@@ -0,0 +1,15 @@
|
||||
package netutil
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
|
||||
"golang.org/x/net/proxy"
|
||||
)
|
||||
|
||||
func NewProxyDialer(proxyUrl string) (proxy.Dialer, error) {
|
||||
url, err := url.Parse(proxyUrl)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return proxy.FromURL(url, proxy.Direct)
|
||||
}
|
||||
50
common/utils/strutil/string.go
Normal file
50
common/utils/strutil/string.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package strutil
|
||||
|
||||
import (
|
||||
"crypto/md5"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/duke-git/lancet/v2/slice"
|
||||
)
|
||||
|
||||
func HashString(s string) string {
|
||||
hash := md5.New()
|
||||
hash.Write([]byte(s))
|
||||
return hex.EncodeToString(hash.Sum(nil))
|
||||
}
|
||||
|
||||
var TagRe = regexp.MustCompile(`(?:^|[\p{Zs}\s.,!?(){}[\]<>\"\',。!?():;、])#([\p{L}\d_]+)`)
|
||||
|
||||
func ExtractTagsFromText(text string) []string {
|
||||
matches := TagRe.FindAllStringSubmatch(text, -1)
|
||||
tags := make([]string, 0)
|
||||
for _, match := range matches {
|
||||
if len(match) > 1 {
|
||||
tags = append(tags, match[1])
|
||||
}
|
||||
}
|
||||
return slice.Compact(tags)
|
||||
}
|
||||
|
||||
func ParseIntStrRange(input string, sep string) (int64, int64, error) {
|
||||
parts := strings.Split(input, sep)
|
||||
if len(parts) != 2 {
|
||||
return 0, 0, fmt.Errorf("invalid range format: %s", input)
|
||||
}
|
||||
min, err := strconv.ParseInt(strings.TrimSpace(parts[0]), 10, 64)
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("invalid minimum value: %s", parts[0])
|
||||
}
|
||||
max, err := strconv.ParseInt(strings.TrimSpace(parts[1]), 10, 64)
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("invalid maximum value: %s", parts[1])
|
||||
}
|
||||
if min > max {
|
||||
min, max = max, min
|
||||
}
|
||||
return min, max, nil
|
||||
}
|
||||
22
common/utils/tgutil/context.go
Normal file
22
common/utils/tgutil/context.go
Normal file
@@ -0,0 +1,22 @@
|
||||
package tgutil
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/celestix/gotgproto/ext"
|
||||
)
|
||||
|
||||
type contextKey struct{}
|
||||
|
||||
var extKey = contextKey{}
|
||||
|
||||
func ExtFromContext(ctx context.Context) *ext.Context {
|
||||
if extCtx, ok := ctx.Value(extKey).(*ext.Context); ok {
|
||||
return extCtx
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ExtWithContext(ctx context.Context, extCtx *ext.Context) context.Context {
|
||||
return context.WithValue(ctx, extKey, extCtx)
|
||||
}
|
||||
302
common/utils/tgutil/message.go
Normal file
302
common/utils/tgutil/message.go
Normal file
@@ -0,0 +1,302 @@
|
||||
package tgutil
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/celestix/gotgproto/ext"
|
||||
"github.com/duke-git/lancet/v2/maputil"
|
||||
|
||||
"github.com/duke-git/lancet/v2/mathutil"
|
||||
"github.com/duke-git/lancet/v2/slice"
|
||||
lcstrutil "github.com/duke-git/lancet/v2/strutil"
|
||||
"github.com/duke-git/lancet/v2/validator"
|
||||
"github.com/gabriel-vasile/mimetype"
|
||||
"github.com/gotd/td/tg"
|
||||
"github.com/krau/SaveAny-Bot/common/cache"
|
||||
"github.com/krau/SaveAny-Bot/common/utils/strutil"
|
||||
"github.com/rs/xid"
|
||||
)
|
||||
|
||||
func GenFileNameFromMessage(message tg.Message) string {
|
||||
ext := func(media tg.MessageMediaClass) string {
|
||||
switch media := media.(type) {
|
||||
case *tg.MessageMediaDocument:
|
||||
doc, ok := media.Document.AsNotEmpty()
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
mmt := mimetype.Lookup(doc.MimeType)
|
||||
if mmt == nil || mmt.Extension() == "" {
|
||||
return ""
|
||||
}
|
||||
return mmt.Extension()
|
||||
case *tg.MessageMediaPhoto:
|
||||
return ".jpg"
|
||||
}
|
||||
return ""
|
||||
}(message.Media)
|
||||
text := strings.TrimSpace(message.GetMessage())
|
||||
if text == "" {
|
||||
return fmt.Sprintf("%d_%s%s", message.GetID(), xid.New().String(), ext)
|
||||
}
|
||||
filename := func() string {
|
||||
tags := strutil.ExtractTagsFromText(text)
|
||||
if len(tags) > 0 {
|
||||
tagStrRunes := make([]rune, 0, 64)
|
||||
for i, tag := range tags {
|
||||
if i > 0 {
|
||||
tagStrRunes = append(tagStrRunes, '_')
|
||||
}
|
||||
tagStrRunes = append(tagStrRunes, []rune(tag)...)
|
||||
if len(tagStrRunes) >= 64 {
|
||||
break
|
||||
}
|
||||
}
|
||||
tagStr := string(tagStrRunes)
|
||||
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':
|
||||
return '_'
|
||||
}
|
||||
if validator.IsPrintable(string(r)) {
|
||||
return r
|
||||
}
|
||||
return '_'
|
||||
}, text), 0, 64)
|
||||
text = strings.Join(strings.FieldsFunc(text, func(r rune) bool {
|
||||
return r == '_' || r == ' '
|
||||
}), "_")
|
||||
return text
|
||||
}()
|
||||
|
||||
if filename == "" {
|
||||
filename = fmt.Sprintf("%d_%s", message.GetID(), xid.New().String())
|
||||
}
|
||||
return filename + ext
|
||||
}
|
||||
|
||||
func BuildCancelButton(taskID string) tg.KeyboardButtonClass {
|
||||
return &tg.KeyboardButtonCallback{
|
||||
Text: "取消任务",
|
||||
Data: fmt.Appendf(nil, "cancel %s", taskID),
|
||||
}
|
||||
}
|
||||
|
||||
func InputMessageClassSliceFromInt(ids []int) []tg.InputMessageClass {
|
||||
result := make([]tg.InputMessageClass, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
result = append(result, &tg.InputMessageID{
|
||||
ID: id,
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func GetMessagesRange(ctx *ext.Context, chatID int64, minId, maxId int) ([]*tg.Message, error) {
|
||||
if minId > maxId {
|
||||
return nil, fmt.Errorf("minId (%d) cannot be greater than maxId (%d)", minId, maxId)
|
||||
}
|
||||
total := maxId - minId + 1
|
||||
msgIds := mathutil.Range(minId, total)
|
||||
toFetchIds := make([]int, 0, total)
|
||||
cached := make(map[int]*tg.Message, total)
|
||||
for _, id := range msgIds {
|
||||
if msg, ok := cache.Get[*tg.Message](fmt.Sprintf("tgmsg:%d:%d:%d", ctx.Self.ID, chatID, id)); ok {
|
||||
cached[id] = msg
|
||||
} else {
|
||||
toFetchIds = append(toFetchIds, id)
|
||||
}
|
||||
}
|
||||
if len(toFetchIds) == 0 {
|
||||
return maputil.Values(cached), nil
|
||||
}
|
||||
|
||||
result := make([]*tg.Message, 0, total)
|
||||
chunks := slice.Chunk(toFetchIds, 100)
|
||||
for _, chunk := range chunks {
|
||||
msgs, err := ctx.GetMessages(chatID, InputMessageClassSliceFromInt(chunk))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(msgs) == 0 {
|
||||
continue
|
||||
}
|
||||
for _, msg := range msgs {
|
||||
if msg == nil {
|
||||
continue
|
||||
}
|
||||
tgMessage, ok := msg.(*tg.Message)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if tgMessage.GetID() < minId || tgMessage.GetID() > maxId {
|
||||
continue
|
||||
}
|
||||
result = append(result, tgMessage)
|
||||
}
|
||||
}
|
||||
|
||||
for _, msg := range result {
|
||||
cache.Set(fmt.Sprintf("tgmsg:%d:%d:%d", ctx.Self.ID, chatID, msg.GetID()), msg)
|
||||
}
|
||||
for _, msg := range cached {
|
||||
if msg == nil {
|
||||
continue
|
||||
}
|
||||
result = append(result, msg)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
type MessageItem struct {
|
||||
Message *tg.Message
|
||||
Error error
|
||||
}
|
||||
|
||||
func IterMessages(ctx *ext.Context, chatID int64, minId, maxId int) (<-chan MessageItem, error) {
|
||||
total := maxId - minId + 1
|
||||
ch := make(chan MessageItem, 100)
|
||||
|
||||
go func() {
|
||||
defer close(ch)
|
||||
if !ctx.Self.Bot {
|
||||
perr := ctx.PeerStorage.GetInputPeerById(chatID)
|
||||
if perr == nil || perr.(*tg.InputPeerEmpty) != nil {
|
||||
ch <- MessageItem{
|
||||
Error: fmt.Errorf("peer not found: %d", chatID),
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
for i := 0; i < total; i += 100 {
|
||||
start := minId + i
|
||||
end := min(start+100, maxId)
|
||||
msgs, err := ctx.Raw.MessagesGetHistory(ctx, &tg.MessagesGetHistoryRequest{
|
||||
Peer: perr,
|
||||
OffsetID: start,
|
||||
AddOffset: start - end,
|
||||
Limit: 100,
|
||||
})
|
||||
if err != nil {
|
||||
ch <- MessageItem{
|
||||
Error: fmt.Errorf("failed to get messages: %w", err),
|
||||
}
|
||||
return
|
||||
}
|
||||
var msgClass []tg.MessageClass
|
||||
switch msgsv := msgs.(type) {
|
||||
case *tg.MessagesMessages:
|
||||
msgClass = msgsv.GetMessages()
|
||||
case *tg.MessagesMessagesSlice:
|
||||
msgClass = msgsv.GetMessages()
|
||||
case *tg.MessagesChannelMessages:
|
||||
msgClass = msgsv.GetMessages()
|
||||
default:
|
||||
ch <- MessageItem{
|
||||
Error: fmt.Errorf("unsupported message type: %T", msgsv),
|
||||
}
|
||||
continue
|
||||
}
|
||||
for _, msg := range msgClass {
|
||||
msg, ok := msg.AsNotEmpty()
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
switch msg := msg.(type) {
|
||||
case *tg.Message:
|
||||
key := fmt.Sprintf("tgmsg:%d:%d:%d", ctx.Self.ID, chatID, msg.GetID())
|
||||
cache.Set(key, msg)
|
||||
ch <- MessageItem{
|
||||
Message: msg,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for i := 0; i < total; i += 100 {
|
||||
start := minId + i
|
||||
end := min(start+100, maxId)
|
||||
msgs, err := GetMessagesRange(ctx, chatID, start, end)
|
||||
if err != nil {
|
||||
ch <- MessageItem{
|
||||
Error: fmt.Errorf("failed to get messages: %w", err),
|
||||
}
|
||||
return
|
||||
}
|
||||
for _, msg := range msgs {
|
||||
if msg == nil {
|
||||
continue
|
||||
}
|
||||
ch <- MessageItem{
|
||||
Message: msg,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
func GetMessageByID(ctx *ext.Context, chatID int64, msgID int) (*tg.Message, error) {
|
||||
key := fmt.Sprintf("tgmsg:%d:%d:%d", ctx.Self.ID, chatID, msgID)
|
||||
if msg, ok := cache.Get[*tg.Message](key); ok {
|
||||
return msg, nil
|
||||
}
|
||||
msgs, err := ctx.GetMessages(chatID, []tg.InputMessageClass{
|
||||
&tg.InputMessageID{ID: msgID},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get message by ID: %w", err)
|
||||
}
|
||||
if len(msgs) == 0 {
|
||||
return nil, fmt.Errorf("message not found: chatID=%d, msgID=%d", chatID, msgID)
|
||||
}
|
||||
msg := msgs[0]
|
||||
tgm, ok := msg.(*tg.Message)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unexpected message type: %T", msg)
|
||||
}
|
||||
cache.Set(key, tgm)
|
||||
return tgm, nil
|
||||
}
|
||||
|
||||
func GetGroupedMessages(ctx *ext.Context, chatID int64, msg *tg.Message) ([]*tg.Message, error) {
|
||||
groupID, isGroup := msg.GetGroupedID()
|
||||
if !isGroup || groupID == 0 {
|
||||
return nil, fmt.Errorf("message %d is not grouped", msg.GetID())
|
||||
}
|
||||
msgID := msg.GetID()
|
||||
minID := msgID - 10
|
||||
maxID := msgID + 10
|
||||
if minID < 1 {
|
||||
minID = 1
|
||||
}
|
||||
msgs, err := GetMessagesRange(ctx, chatID, minID, maxID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get grouped messages: %w", err)
|
||||
}
|
||||
groupedMessages := make([]*tg.Message, 0, len(msgs))
|
||||
for _, m := range msgs {
|
||||
if m == nil {
|
||||
continue
|
||||
}
|
||||
mgid, isGroup := m.GetGroupedID()
|
||||
if isGroup && mgid == groupID {
|
||||
groupedMessages = append(groupedMessages, m)
|
||||
}
|
||||
}
|
||||
return groupedMessages, nil
|
||||
}
|
||||
119
common/utils/tgutil/resolve.go
Normal file
119
common/utils/tgutil/resolve.go
Normal file
@@ -0,0 +1,119 @@
|
||||
package tgutil
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/celestix/gotgproto/ext"
|
||||
"github.com/duke-git/lancet/v2/validator"
|
||||
"github.com/gotd/td/tg"
|
||||
)
|
||||
|
||||
func ParseChatID(ctx *ext.Context, idOrUsername string) (int64, error) {
|
||||
idOrUsername = strings.TrimPrefix(idOrUsername, "@")
|
||||
if validator.IsIntStr(idOrUsername) {
|
||||
chatID, err := strconv.Atoi(idOrUsername)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return int64(chatID), nil
|
||||
}
|
||||
username := idOrUsername
|
||||
peer := ctx.PeerStorage.GetPeerByUsername(username)
|
||||
if peer != nil && peer.ID != 0 {
|
||||
return peer.ID, nil
|
||||
}
|
||||
chat, err := ctx.ResolveUsername(username)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if chat == nil {
|
||||
return 0, fmt.Errorf("no chat found for username: %s", idOrUsername)
|
||||
}
|
||||
chatID := chat.GetID()
|
||||
if chatID == 0 {
|
||||
return 0, fmt.Errorf("chat ID is zero for username: %s", idOrUsername)
|
||||
}
|
||||
return chatID, nil
|
||||
}
|
||||
|
||||
// return: ChatID, MessageID, error
|
||||
func ParseMessageLink(ctx *ext.Context, link string) (int64, int, error) {
|
||||
u, err := url.Parse(link)
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("invalid URL: %w", err)
|
||||
}
|
||||
paths := strings.Split(strings.TrimPrefix(u.Path, "/"), "/")
|
||||
|
||||
if cmt := u.Query().Get("comment"); cmt != "" {
|
||||
// 频道评论的消息链接
|
||||
// https://t.me/acherkrau/123?comment=2
|
||||
chid, err := ParseChatID(ctx, paths[0])
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("failed to parse chat ID: %w", err)
|
||||
}
|
||||
chatfull, err := ctx.GetChat(chid)
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("failed to get chat: %w", err)
|
||||
}
|
||||
chfull, ok := chatfull.(*tg.ChannelFull)
|
||||
if !ok {
|
||||
return 0, 0, fmt.Errorf("chat is not a channel: %s", chatfull.TypeName())
|
||||
}
|
||||
linkChatId, ok := chfull.GetLinkedChatID()
|
||||
if !ok {
|
||||
return 0, 0, fmt.Errorf("channel has no linked chat")
|
||||
}
|
||||
msgID, err := strconv.Atoi(cmt)
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("failed to parse comment ID: %w", err)
|
||||
}
|
||||
return linkChatId, msgID, nil
|
||||
}
|
||||
|
||||
switch len(paths) {
|
||||
case 2: // https://t.me/acherkrau/123
|
||||
chatID, err := ParseChatID(ctx, paths[0])
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("failed to parse chat ID: %w", err)
|
||||
}
|
||||
msgID, err := strconv.Atoi(paths[1])
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("failed to parse message ID: %w", err)
|
||||
}
|
||||
return chatID, msgID, nil
|
||||
case 3:
|
||||
// https://t.me/c/123456789/123
|
||||
// https://t.me/acherkrau/123/456 , 456: message thread ID
|
||||
chatPart, msgPart := paths[1], paths[2]
|
||||
if paths[0] != "c" {
|
||||
chatPart = paths[0]
|
||||
}
|
||||
chatID, err := ParseChatID(ctx, chatPart)
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("failed to parse chat ID: %w", err)
|
||||
}
|
||||
msgID, err := strconv.Atoi(msgPart)
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("failed to parse message ID: %w", err)
|
||||
}
|
||||
return chatID, msgID, nil
|
||||
case 4:
|
||||
// https://t.me/c/123456789/111/456 111: topic id
|
||||
if paths[0] != "c" {
|
||||
return 0, 0, fmt.Errorf("invalid message link format: %s", link)
|
||||
}
|
||||
chatID, err := ParseChatID(ctx, paths[1])
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("failed to parse chat ID: %w", err)
|
||||
}
|
||||
msgID, err := strconv.Atoi(paths[3])
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("failed to parse message ID: %w", err)
|
||||
}
|
||||
return chatID, msgID, nil
|
||||
}
|
||||
return 0, 0, fmt.Errorf("invalid message link format: %s", link)
|
||||
}
|
||||
51
common/utils/tphutil/tph.go
Normal file
51
common/utils/tphutil/tph.go
Normal file
@@ -0,0 +1,51 @@
|
||||
package tphutil
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/krau/SaveAny-Bot/config"
|
||||
"github.com/krau/SaveAny-Bot/pkg/telegraph"
|
||||
)
|
||||
|
||||
var tphClient *telegraph.Client
|
||||
|
||||
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
|
||||
var err error
|
||||
tphClient, err = telegraph.NewClientWithProxy(proxyUrl)
|
||||
if err != nil {
|
||||
tphClient = telegraph.NewClient()
|
||||
}
|
||||
} else {
|
||||
tphClient = telegraph.NewClient()
|
||||
}
|
||||
return tphClient
|
||||
}
|
||||
|
||||
func GetNodeImages(node telegraph.Node) []string {
|
||||
var srcs []string
|
||||
|
||||
var nodeElement telegraph.NodeElement
|
||||
data, err := json.Marshal(node)
|
||||
if err != nil {
|
||||
return srcs
|
||||
}
|
||||
err = json.Unmarshal(data, &nodeElement)
|
||||
if err != nil {
|
||||
return srcs
|
||||
}
|
||||
|
||||
if nodeElement.Tag == "img" {
|
||||
if src, exists := nodeElement.Attrs["src"]; exists {
|
||||
srcs = append(srcs, src)
|
||||
}
|
||||
}
|
||||
for _, child := range nodeElement.Children {
|
||||
srcs = append(srcs, GetNodeImages(child)...)
|
||||
}
|
||||
return srcs
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
package common
|
||||
|
||||
var (
|
||||
Version string = "dev"
|
||||
BuildTime string = "unknown"
|
||||
GitCommit string = "unknown"
|
||||
)
|
||||
@@ -1,84 +1,54 @@
|
||||
workers = 4 # 同时下载文件数
|
||||
retry = 3 # 下载失败重试次数
|
||||
# 创建文件时,若需要保留中文注释,请务必确保本文件编码为 UTF-8 ,否则会无法读取。
|
||||
# 更详细的配置请在 https://sabot.unv.app/deployment/configuration 查看
|
||||
workers = 4 # 同时下载文件数
|
||||
retry = 3 # 下载失败重试次数
|
||||
threads = 4 # 单个任务下载使用的最大线程数
|
||||
stream = false # 使用流式传输模式, 建议仅在硬盘空间十分有限时使用.
|
||||
|
||||
[telegram]
|
||||
# Bot Token
|
||||
# 更换 Bot Token 后请删除会话数据库文件 (默认路径为 data/session.db )
|
||||
token = ""
|
||||
# Telegram API 配置, 若不配置也可运行, 将使用默认的 API ID 和 API HASH
|
||||
# 推荐使用自己的 API ID 和 API HASH (https://my.telegram.org)
|
||||
# app_id = 123456
|
||||
# app_hash = "0123456789abcdef0123456789abcdef"
|
||||
|
||||
# app_id = 1025907
|
||||
# app_hash = "452b0359b988148995f22ff0f4229750"
|
||||
[telegram.proxy]
|
||||
# 启用代理连接 telegram, 只支持 socks5
|
||||
enable = false
|
||||
url = "socks5://127.0.0.1:7890"
|
||||
|
||||
|
||||
# 存储配置列表
|
||||
# 存储列表
|
||||
[[storages]]
|
||||
# 标识名, 需要唯一
|
||||
name = "本机1"
|
||||
# 存储类型, 目前可用: local , alist , webdav
|
||||
# 存储类型, 目前可用: local, alist, webdav, minio, telegram
|
||||
type = "local"
|
||||
# 启用存储
|
||||
enable = true
|
||||
# 文件保存根路径
|
||||
base_path = "./downloads"
|
||||
|
||||
[[storages]]
|
||||
name = "本机2"
|
||||
type = "local"
|
||||
enable = true
|
||||
base_path = "./downloads/2"
|
||||
|
||||
[[storages]]
|
||||
name = "MyAlist"
|
||||
type = "alist"
|
||||
enable = true
|
||||
base_path = '/'
|
||||
url = 'https://alist.com'
|
||||
username = 'admin'
|
||||
password = 'password'
|
||||
token_exp = 86400
|
||||
# alist 可直接使用 token 登录, 此时 username, password, token_exp 将被忽略
|
||||
# 请自行在 alist 侧配置合理的 token 过期时间
|
||||
# token = ""
|
||||
|
||||
|
||||
[[storages]]
|
||||
name = "MyWebdav"
|
||||
type = "webdav"
|
||||
enable = false
|
||||
base_path = '/path/telegram'
|
||||
enable = true
|
||||
url = 'https://example.com/dav'
|
||||
username = 'username'
|
||||
password = 'password'
|
||||
|
||||
|
||||
# 用户列表
|
||||
[[users]]
|
||||
# user id
|
||||
id = 123456
|
||||
# 存储名称过滤列表
|
||||
storages = ["MyAlist", "本机1"]
|
||||
# 开启黑名单模式, 过滤列表中的存储将无法使用, 默认为白名单模式
|
||||
blacklist = false
|
||||
|
||||
[[users]]
|
||||
# telegram user id
|
||||
id = 114514
|
||||
# 将列表留空并开启黑名单模式以允许使用所有存储
|
||||
# 存储过滤列表, 元素为存储标识名.
|
||||
# 将该列表留空并开启黑名单过滤模式以允许使用所有存储,此处示例为黑名单模式,用户 114514 可使用所有存储
|
||||
storages = []
|
||||
# 使用列表过滤黑名单模式,反之则为白名单,白名单请在列表中指定可用的存储.
|
||||
blacklist = true
|
||||
|
||||
|
||||
# [log]
|
||||
# # 日志等级
|
||||
# level = "DEBUG"
|
||||
|
||||
# [temp]
|
||||
# # 下载文件临时目录, 请不要在此目录下存放任何其他文件
|
||||
# base_path = "cache/"
|
||||
# # 临时文件保存时间, 单位: 秒
|
||||
# cache_ttl = 30
|
||||
|
||||
# [db]
|
||||
# path = "data/data.db" # 数据库文件路径
|
||||
[[users]]
|
||||
id = 123456
|
||||
storages = ["本机1"]
|
||||
blacklist = false # 使用白名单模式,此时,用户 123456 仅可使用标识名为 '本地1' 的存储
|
||||
7
config/cache.go
Normal file
7
config/cache.go
Normal file
@@ -0,0 +1,7 @@
|
||||
package config
|
||||
|
||||
type cacheConfig struct {
|
||||
TTL int64 `toml:"ttl" mapstructure:"ttl" json:"ttl"`
|
||||
NumCounters int64 `toml:"num_counters" mapstructure:"num_counters" json:"num_counters"`
|
||||
MaxCost int64 `toml:"max_cost" mapstructure:"max_cost" json:"max_cost"`
|
||||
}
|
||||
6
config/db.go
Normal file
6
config/db.go
Normal file
@@ -0,0 +1,6 @@
|
||||
package config
|
||||
|
||||
type dbConfig struct {
|
||||
Path string `toml:"path" mapstructure:"path"`
|
||||
Session string `toml:"session" mapstructure:"session"`
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"github.com/krau/SaveAny-Bot/types"
|
||||
"gorm.io/datatypes"
|
||||
)
|
||||
|
||||
// for compatibility
|
||||
type deprecatedStorageConfig struct {
|
||||
Alist alistConfig `toml:"alist" mapstructure:"alist"`
|
||||
Local localConfig `toml:"local" mapstructure:"local"`
|
||||
Webdav webdavConfig `toml:"webdav" mapstructure:"webdav"`
|
||||
}
|
||||
|
||||
type alistConfig struct {
|
||||
Enable bool `toml:"enable" mapstructure:"enable" json:"enable"`
|
||||
URL string `toml:"url" mapstructure:"url" json:"url"`
|
||||
Username string `toml:"username" mapstructure:"username" json:"username"`
|
||||
Password string `toml:"password" mapstructure:"password" json:"password"`
|
||||
Token string `toml:"token" mapstructure:"token" json:"token"`
|
||||
BasePath string `toml:"base_path" mapstructure:"base_path" json:"base_path"`
|
||||
TokenExp int64 `toml:"token_exp" mapstructure:"token_exp" json:"token_exp"`
|
||||
}
|
||||
|
||||
func (a *alistConfig) ToJSON() datatypes.JSON {
|
||||
tokenExp := strconv.FormatInt(a.TokenExp, 10)
|
||||
return datatypes.JSON([]byte(`{"url":"` + a.URL + `","username":"` + a.Username + `","password":"` + a.Password + `","token":"` + a.Token + `","base_path":"` + a.BasePath + `","token_exp":` + tokenExp + `}`))
|
||||
}
|
||||
|
||||
type localConfig struct {
|
||||
Enable bool `toml:"enable" mapstructure:"enable" json:"enable"`
|
||||
BasePath string `toml:"base_path" mapstructure:"base_path" json:"base_path"`
|
||||
}
|
||||
|
||||
func (l *localConfig) ToJSON() datatypes.JSON {
|
||||
return datatypes.JSON([]byte(`{"base_path":"` + l.BasePath + `"}`))
|
||||
}
|
||||
|
||||
type webdavConfig struct {
|
||||
Enable bool `toml:"enable" mapstructure:"enable" json:"enable"`
|
||||
URL string `toml:"url" mapstructure:"url" json:"url"`
|
||||
Username string `toml:"username" mapstructure:"username" json:"username"`
|
||||
Password string `toml:"password" mapstructure:"password" json:"password"`
|
||||
BasePath string `toml:"base_path" mapstructure:"base_path" json:"base_path"`
|
||||
}
|
||||
|
||||
func (w *webdavConfig) ToJSON() datatypes.JSON {
|
||||
return datatypes.JSON([]byte(`{"url":"` + w.URL + `","username":"` + w.Username + `","password":"` + w.Password + `","base_path":"` + w.BasePath + `"}`))
|
||||
}
|
||||
|
||||
func transformDeprecatedStorageConfig() {
|
||||
if Cfg.DeprecatedStorage.Alist.Enable {
|
||||
alistStorage := &AlistStorageConfig{
|
||||
NewStorageConfig: NewStorageConfig{
|
||||
Name: "Alist",
|
||||
Enable: true,
|
||||
Type: string(types.StorageTypeAlist),
|
||||
},
|
||||
URL: Cfg.DeprecatedStorage.Alist.URL,
|
||||
Username: Cfg.DeprecatedStorage.Alist.Username,
|
||||
Password: Cfg.DeprecatedStorage.Alist.Password,
|
||||
Token: Cfg.DeprecatedStorage.Alist.Token,
|
||||
BasePath: Cfg.DeprecatedStorage.Alist.BasePath,
|
||||
TokenExp: Cfg.DeprecatedStorage.Alist.TokenExp,
|
||||
}
|
||||
Cfg.Storages = append(Cfg.Storages, alistStorage)
|
||||
}
|
||||
if Cfg.DeprecatedStorage.Local.Enable {
|
||||
localStorage := &LocalStorageConfig{
|
||||
NewStorageConfig: NewStorageConfig{
|
||||
Name: "Local",
|
||||
Enable: true,
|
||||
Type: string(types.StorageTypeLocal),
|
||||
},
|
||||
BasePath: Cfg.DeprecatedStorage.Local.BasePath,
|
||||
}
|
||||
Cfg.Storages = append(Cfg.Storages, localStorage)
|
||||
}
|
||||
if Cfg.DeprecatedStorage.Webdav.Enable {
|
||||
webdavStorage := &WebdavStorageConfig{
|
||||
NewStorageConfig: NewStorageConfig{
|
||||
Name: "Webdav",
|
||||
Enable: true,
|
||||
Type: string(types.StorageTypeWebdav),
|
||||
},
|
||||
URL: Cfg.DeprecatedStorage.Webdav.URL,
|
||||
Username: Cfg.DeprecatedStorage.Webdav.Username,
|
||||
Password: Cfg.DeprecatedStorage.Webdav.Password,
|
||||
BasePath: Cfg.DeprecatedStorage.Webdav.BasePath,
|
||||
}
|
||||
Cfg.Storages = append(Cfg.Storages, webdavStorage)
|
||||
}
|
||||
}
|
||||
22
config/hook.go
Normal file
22
config/hook.go
Normal file
@@ -0,0 +1,22 @@
|
||||
package config
|
||||
|
||||
type hookConfig struct {
|
||||
Exec hookExecConfig `toml:"exec" mapstructure:"exec" json:"exec"`
|
||||
}
|
||||
|
||||
type hookExecConfig struct {
|
||||
// command to execute, for all task types
|
||||
TaskBeforeStart string `toml:"task_before_start" mapstructure:"task_before_start" json:"task_before_start"`
|
||||
TaskSuccess string `toml:"task_success" mapstructure:"task_success" json:"task_success"`
|
||||
TaskFail string `toml:"task_fail" mapstructure:"task_fail" json:"task_fail"`
|
||||
TaskCancel string `toml:"task_cancel" mapstructure:"task_cancel" json:"task_cancel"`
|
||||
|
||||
// TaskTypes map[string]hookExecOnTypeConfig `toml:"task_types" mapstructure:"task_types" json:"task_types"` // [TODO]
|
||||
}
|
||||
|
||||
// type hookExecOnTypeConfig struct {
|
||||
// TaskBeforeStart string `toml:"task_before_start" mapstructure:"task_before_start" json:"task_before_start"`
|
||||
// TaskSuccess string `toml:"task_success" mapstructure:"task_success" json:"task_success"`
|
||||
// TaskFail string `toml:"task_fail" mapstructure:"task_fail" json:"task_fail"`
|
||||
// TaskCancel string `toml:"task_cancel" mapstructure:"task_cancel" json:"task_cancel"`
|
||||
// }
|
||||
38
config/storage/alist.go
Normal file
38
config/storage/alist.go
Normal file
@@ -0,0 +1,38 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
storenum "github.com/krau/SaveAny-Bot/pkg/enums/storage"
|
||||
)
|
||||
|
||||
type AlistStorageConfig struct {
|
||||
BaseConfig
|
||||
URL string `toml:"url" mapstructure:"url" json:"url"`
|
||||
Username string `toml:"username" mapstructure:"username" json:"username"`
|
||||
Password string `toml:"password" mapstructure:"password" json:"password"`
|
||||
Token string `toml:"token" mapstructure:"token" json:"token"`
|
||||
BasePath string `toml:"base_path" mapstructure:"base_path" json:"base_path"`
|
||||
TokenExp int64 `toml:"token_exp" mapstructure:"token_exp" json:"token_exp"`
|
||||
}
|
||||
|
||||
func (a *AlistStorageConfig) Validate() error {
|
||||
if a.URL == "" {
|
||||
return fmt.Errorf("url is required for alist storage")
|
||||
}
|
||||
if a.Token == "" && (a.Username == "" || a.Password == "") {
|
||||
return fmt.Errorf("username and password or token is required for alist storage")
|
||||
}
|
||||
if a.BasePath == "" {
|
||||
return fmt.Errorf("base_path is required for alist storage")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *AlistStorageConfig) GetType() storenum.StorageType {
|
||||
return storenum.Alist
|
||||
}
|
||||
|
||||
func (a *AlistStorageConfig) GetName() string {
|
||||
return a.Name
|
||||
}
|
||||
68
config/storage/factory.go
Normal file
68
config/storage/factory.go
Normal file
@@ -0,0 +1,68 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
|
||||
storenum "github.com/krau/SaveAny-Bot/pkg/enums/storage"
|
||||
"github.com/mitchellh/mapstructure"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
var storageFactories = map[storenum.StorageType]func(cfg *BaseConfig) (StorageConfig, error){
|
||||
storenum.Local: createStorageConfig(&LocalStorageConfig{}),
|
||||
storenum.Alist: createStorageConfig(&AlistStorageConfig{}),
|
||||
storenum.Webdav: createStorageConfig(&WebdavStorageConfig{}),
|
||||
storenum.Minio: createStorageConfig(&MinioStorageConfig{}),
|
||||
storenum.Telegram: createStorageConfig(&TelegramStorageConfig{}),
|
||||
}
|
||||
|
||||
func createStorageConfig(configType StorageConfig) func(cfg *BaseConfig) (StorageConfig, error) {
|
||||
return func(cfg *BaseConfig) (StorageConfig, error) {
|
||||
configValue := reflect.New(reflect.TypeOf(configType).Elem()).Interface().(StorageConfig)
|
||||
|
||||
reflect.ValueOf(configValue).Elem().FieldByName("BaseConfig").Set(reflect.ValueOf(*cfg))
|
||||
|
||||
if err := mapstructure.Decode(cfg.RawConfig, configValue); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode %s storage config: %w", cfg.Type, err)
|
||||
}
|
||||
|
||||
return configValue, nil
|
||||
}
|
||||
}
|
||||
|
||||
func LoadStorageConfigs(v *viper.Viper) ([]StorageConfig, error) {
|
||||
var baseConfigs []BaseConfig
|
||||
if err := v.UnmarshalKey("storages", &baseConfigs); err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal storage configs: %w", err)
|
||||
}
|
||||
|
||||
var configs []StorageConfig
|
||||
for _, baseCfg := range baseConfigs {
|
||||
if !baseCfg.Enable {
|
||||
continue
|
||||
}
|
||||
st, err := storenum.ParseStorageType(baseCfg.Type)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid storage type %s for %s: %w", baseCfg.Type, baseCfg.Name, err)
|
||||
}
|
||||
|
||||
factory, ok := storageFactories[st]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unsupported storage type: %s", baseCfg.Type)
|
||||
}
|
||||
|
||||
cfg, err := factory(&baseCfg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create storage config for %s: %w", baseCfg.Name, err)
|
||||
}
|
||||
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("invalid storage config for %s: %w", baseCfg.Name, err)
|
||||
}
|
||||
|
||||
configs = append(configs, cfg)
|
||||
}
|
||||
|
||||
return configs, nil
|
||||
}
|
||||
27
config/storage/local.go
Normal file
27
config/storage/local.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
storenum "github.com/krau/SaveAny-Bot/pkg/enums/storage"
|
||||
)
|
||||
|
||||
type LocalStorageConfig struct {
|
||||
BaseConfig
|
||||
BasePath string `toml:"base_path" mapstructure:"base_path" json:"base_path"`
|
||||
}
|
||||
|
||||
func (l *LocalStorageConfig) Validate() error {
|
||||
if l.BasePath == "" {
|
||||
return fmt.Errorf("path is required for local storage")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (l *LocalStorageConfig) GetType() storenum.StorageType {
|
||||
return storenum.Local
|
||||
}
|
||||
|
||||
func (l *LocalStorageConfig) GetName() string {
|
||||
return l.Name
|
||||
}
|
||||
41
config/storage/minio.go
Normal file
41
config/storage/minio.go
Normal file
@@ -0,0 +1,41 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
storenum "github.com/krau/SaveAny-Bot/pkg/enums/storage"
|
||||
)
|
||||
|
||||
type MinioStorageConfig struct {
|
||||
BaseConfig
|
||||
Endpoint string `toml:"endpoint" mapstructure:"endpoint" json:"endpoint"`
|
||||
AccessKeyID string `toml:"access_key_id" mapstructure:"access_key_id" json:"access_key_id"`
|
||||
SecretAccessKey string `toml:"secret_access_key" mapstructure:"secret_access_key" json:"secret_access_key"`
|
||||
BucketName string `toml:"bucket_name" mapstructure:"bucket_name" json:"bucket_name"`
|
||||
UseSSL bool `toml:"use_ssl" mapstructure:"use_ssl" json:"use_ssl"`
|
||||
BasePath string `toml:"base_path" mapstructure:"base_path" json:"base_path"`
|
||||
}
|
||||
|
||||
func (m *MinioStorageConfig) Validate() error {
|
||||
if m.Endpoint == "" {
|
||||
return fmt.Errorf("endpoint is required for minio storage")
|
||||
}
|
||||
if m.AccessKeyID == "" || m.SecretAccessKey == "" {
|
||||
return fmt.Errorf("access_key_id and secret_access_key are required for minio storage")
|
||||
}
|
||||
if m.BucketName == "" {
|
||||
return fmt.Errorf("bucket_name is required for minio storage")
|
||||
}
|
||||
if m.BasePath == "" {
|
||||
return fmt.Errorf("base_path is required for minio storage")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MinioStorageConfig) GetType() storenum.StorageType {
|
||||
return storenum.Minio
|
||||
}
|
||||
|
||||
func (m *MinioStorageConfig) GetName() string {
|
||||
return m.Name
|
||||
}
|
||||
32
config/storage/telegram.go
Normal file
32
config/storage/telegram.go
Normal file
@@ -0,0 +1,32 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
storenum "github.com/krau/SaveAny-Bot/pkg/enums/storage"
|
||||
)
|
||||
|
||||
type TelegramStorageConfig struct {
|
||||
BaseConfig
|
||||
ChatID int64 `toml:"chat_id" mapstructure:"chat_id" json:"chat_id"`
|
||||
RateLimit int `toml:"rate_limit" mapstructure:"rate_limit" json:"rate_limit"`
|
||||
RateBurst int `toml:"rate_burst" mapstructure:"rate_burst" json:"rate_burst"`
|
||||
}
|
||||
|
||||
func (m *TelegramStorageConfig) Validate() error {
|
||||
if m.ChatID == 0 {
|
||||
return fmt.Errorf("chat_id is required for telegram storage")
|
||||
}
|
||||
if m.RateLimit < 0 || m.RateBurst < 0 {
|
||||
return fmt.Errorf("rate_limit and rate_burst must be greater than 0 for telegram storage")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *TelegramStorageConfig) GetType() storenum.StorageType {
|
||||
return storenum.Telegram
|
||||
}
|
||||
|
||||
func (m *TelegramStorageConfig) GetName() string {
|
||||
return m.Name
|
||||
}
|
||||
18
config/storage/types.go
Normal file
18
config/storage/types.go
Normal file
@@ -0,0 +1,18 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
storenum "github.com/krau/SaveAny-Bot/pkg/enums/storage"
|
||||
)
|
||||
|
||||
type StorageConfig interface {
|
||||
Validate() error
|
||||
GetType() storenum.StorageType
|
||||
GetName() string
|
||||
}
|
||||
|
||||
type BaseConfig struct {
|
||||
Name string `toml:"name" mapstructure:"name" json:"name"`
|
||||
Type string `toml:"type" mapstructure:"type" json:"type"`
|
||||
Enable bool `toml:"enable" mapstructure:"enable" json:"enable"`
|
||||
RawConfig map[string]any `toml:"-" mapstructure:",remain"`
|
||||
}
|
||||
36
config/storage/webdav.go
Normal file
36
config/storage/webdav.go
Normal file
@@ -0,0 +1,36 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
storenum "github.com/krau/SaveAny-Bot/pkg/enums/storage"
|
||||
)
|
||||
|
||||
type WebdavStorageConfig struct {
|
||||
BaseConfig
|
||||
URL string `toml:"url" mapstructure:"url" json:"url"`
|
||||
Username string `toml:"username" mapstructure:"username" json:"username"`
|
||||
Password string `toml:"password" mapstructure:"password" json:"password"`
|
||||
BasePath string `toml:"base_path" mapstructure:"base_path" json:"base_path"`
|
||||
}
|
||||
|
||||
func (w *WebdavStorageConfig) Validate() error {
|
||||
if w.URL == "" {
|
||||
return fmt.Errorf("url is required for webdav storage")
|
||||
}
|
||||
if w.Username == "" || w.Password == "" {
|
||||
return fmt.Errorf("username and password is required for webdav storage")
|
||||
}
|
||||
if w.BasePath == "" {
|
||||
return fmt.Errorf("base_path is required for webdav storage")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *WebdavStorageConfig) GetType() storenum.StorageType {
|
||||
return storenum.Webdav
|
||||
}
|
||||
|
||||
func (w *WebdavStorageConfig) GetName() string {
|
||||
return w.Name
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
// storage_config.go
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/krau/SaveAny-Bot/types"
|
||||
"github.com/mitchellh/mapstructure"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
type StorageConfig interface {
|
||||
Validate() error
|
||||
GetType() types.StorageType
|
||||
GetName() string
|
||||
}
|
||||
|
||||
// Base storage config
|
||||
type NewStorageConfig struct {
|
||||
Name string `toml:"name" mapstructure:"name" json:"name"`
|
||||
Type string `toml:"type" mapstructure:"type" json:"type"`
|
||||
Enable bool `toml:"enable" mapstructure:"enable" json:"enable"`
|
||||
RawConfig map[string]interface{} `toml:"-" mapstructure:",remain"`
|
||||
}
|
||||
|
||||
type StorageConfigFactory func(cfg *NewStorageConfig) (StorageConfig, error)
|
||||
|
||||
var storageFactories = make(map[string]StorageConfigFactory)
|
||||
|
||||
func RegisterStorageFactory(storageType string, factory StorageConfigFactory) {
|
||||
storageFactories[storageType] = factory
|
||||
}
|
||||
|
||||
func init() {
|
||||
RegisterStorageFactory(string(types.StorageTypeLocal), newLocalStorageConfig)
|
||||
RegisterStorageFactory(string(types.StorageTypeAlist), newAlistStorageConfig)
|
||||
RegisterStorageFactory(string(types.StorageTypeWebdav), newWebdavStorageConfig)
|
||||
}
|
||||
|
||||
func newLocalStorageConfig(cfg *NewStorageConfig) (StorageConfig, error) {
|
||||
var localCfg LocalStorageConfig
|
||||
localCfg.NewStorageConfig = *cfg
|
||||
|
||||
if err := mapstructure.Decode(cfg.RawConfig, &localCfg); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode local storage config: %w", err)
|
||||
}
|
||||
|
||||
return &localCfg, nil
|
||||
}
|
||||
|
||||
func newAlistStorageConfig(cfg *NewStorageConfig) (StorageConfig, error) {
|
||||
var alistCfg AlistStorageConfig
|
||||
alistCfg.NewStorageConfig = *cfg
|
||||
|
||||
if err := mapstructure.Decode(cfg.RawConfig, &alistCfg); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode alist storage config: %w", err)
|
||||
}
|
||||
|
||||
return &alistCfg, nil
|
||||
}
|
||||
|
||||
func newWebdavStorageConfig(cfg *NewStorageConfig) (StorageConfig, error) {
|
||||
var webdavCfg WebdavStorageConfig
|
||||
webdavCfg.NewStorageConfig = *cfg
|
||||
|
||||
if err := mapstructure.Decode(cfg.RawConfig, &webdavCfg); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode webdav storage config: %w", err)
|
||||
}
|
||||
|
||||
return &webdavCfg, nil
|
||||
}
|
||||
|
||||
func LoadStorageConfigs(v *viper.Viper) ([]StorageConfig, error) {
|
||||
var baseConfigs []NewStorageConfig
|
||||
if err := v.UnmarshalKey("storages", &baseConfigs); err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal storage configs: %w", err)
|
||||
}
|
||||
|
||||
var configs []StorageConfig
|
||||
for _, baseCfg := range baseConfigs {
|
||||
if !baseCfg.Enable {
|
||||
continue
|
||||
}
|
||||
|
||||
factory, ok := storageFactories[baseCfg.Type]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unsupported storage type: %s", baseCfg.Type)
|
||||
}
|
||||
|
||||
cfg, err := factory(&baseCfg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create storage config for %s: %w", baseCfg.Name, err)
|
||||
}
|
||||
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("invalid storage config for %s: %w", baseCfg.Name, err)
|
||||
}
|
||||
|
||||
configs = append(configs, cfg)
|
||||
}
|
||||
|
||||
return configs, nil
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/krau/SaveAny-Bot/types"
|
||||
)
|
||||
|
||||
func (c *Config) GetStoragesByType(storageType types.StorageType) []StorageConfig {
|
||||
var storages []StorageConfig
|
||||
for _, storage := range c.Storages {
|
||||
if storage.GetType() == storageType {
|
||||
storages = append(storages, storage)
|
||||
}
|
||||
}
|
||||
return storages
|
||||
}
|
||||
|
||||
func (c *Config) GetStorageByName(name string) StorageConfig {
|
||||
for _, storage := range c.Storages {
|
||||
if storage.GetName() == name {
|
||||
return storage
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type LocalStorageConfig struct {
|
||||
NewStorageConfig
|
||||
BasePath string `toml:"base_path" mapstructure:"base_path" json:"base_path"`
|
||||
}
|
||||
|
||||
func (l *LocalStorageConfig) Validate() error {
|
||||
if l.BasePath == "" {
|
||||
return fmt.Errorf("path is required for local storage")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (l *LocalStorageConfig) GetType() types.StorageType {
|
||||
return types.StorageTypeLocal
|
||||
}
|
||||
|
||||
func (l *LocalStorageConfig) GetName() string {
|
||||
return l.Name
|
||||
}
|
||||
|
||||
type AlistStorageConfig struct {
|
||||
NewStorageConfig
|
||||
URL string `toml:"url" mapstructure:"url" json:"url"`
|
||||
Username string `toml:"username" mapstructure:"username" json:"username"`
|
||||
Password string `toml:"password" mapstructure:"password" json:"password"`
|
||||
Token string `toml:"token" mapstructure:"token" json:"token"`
|
||||
BasePath string `toml:"base_path" mapstructure:"base_path" json:"base_path"`
|
||||
TokenExp int64 `toml:"token_exp" mapstructure:"token_exp" json:"token_exp"`
|
||||
}
|
||||
|
||||
func (a *AlistStorageConfig) Validate() error {
|
||||
if a.URL == "" {
|
||||
return fmt.Errorf("url is required for alist storage")
|
||||
}
|
||||
if a.Token == "" && (a.Username == "" || a.Password == "") {
|
||||
return fmt.Errorf("username and password or token is required for alist storage")
|
||||
}
|
||||
if a.BasePath == "" {
|
||||
return fmt.Errorf("base_path is required for alist storage")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *AlistStorageConfig) GetType() types.StorageType {
|
||||
return types.StorageTypeAlist
|
||||
}
|
||||
|
||||
func (a *AlistStorageConfig) GetName() string {
|
||||
return a.Name
|
||||
}
|
||||
|
||||
type WebdavStorageConfig struct {
|
||||
NewStorageConfig
|
||||
URL string `toml:"url" mapstructure:"url" json:"url"`
|
||||
Username string `toml:"username" mapstructure:"username" json:"username"`
|
||||
Password string `toml:"password" mapstructure:"password" json:"password"`
|
||||
BasePath string `toml:"base_path" mapstructure:"base_path" json:"base_path"`
|
||||
}
|
||||
|
||||
func (w *WebdavStorageConfig) Validate() error {
|
||||
if w.URL == "" {
|
||||
return fmt.Errorf("url is required for webdav storage")
|
||||
}
|
||||
if w.Username == "" || w.Password == "" {
|
||||
return fmt.Errorf("username and password is required for webdav storage")
|
||||
}
|
||||
if w.BasePath == "" {
|
||||
return fmt.Errorf("base_path is required for webdav storage")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *WebdavStorageConfig) GetType() types.StorageType {
|
||||
return types.StorageTypeWebdav
|
||||
}
|
||||
|
||||
func (w *WebdavStorageConfig) GetName() string {
|
||||
return w.Name
|
||||
}
|
||||
5
config/temp.go
Normal file
5
config/temp.go
Normal file
@@ -0,0 +1,5 @@
|
||||
package config
|
||||
|
||||
type tempConfig struct {
|
||||
BasePath string `toml:"base_path" mapstructure:"base_path" json:"base_path"`
|
||||
}
|
||||
20
config/tg.go
Normal file
20
config/tg.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package config
|
||||
|
||||
type telegramConfig struct {
|
||||
Token string `toml:"token" mapstructure:"token"`
|
||||
AppID int `toml:"app_id" mapstructure:"app_id" json:"app_id"`
|
||||
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]
|
||||
}
|
||||
|
||||
type userbotConfig struct {
|
||||
Enable bool `toml:"enable" mapstructure:"enable"`
|
||||
Session string `toml:"session" mapstructure:"session"`
|
||||
}
|
||||
|
||||
type tgProxyConfig struct {
|
||||
Enable bool `toml:"enable" mapstructure:"enable"`
|
||||
URL string `toml:"url" mapstructure:"url"`
|
||||
}
|
||||
@@ -10,40 +10,26 @@ type userConfig struct {
|
||||
Blacklist bool `toml:"blacklist" mapstructure:"blacklist" json:"blacklist"` // 黑名单模式, storage names 中的存储将不会被使用, 默认为白名单模式
|
||||
}
|
||||
|
||||
var userIDs []int64
|
||||
var storages []string
|
||||
var userStorages = make(map[int64][]string)
|
||||
|
||||
func (c *Config) GetStorageNamesByUserID(userID int64) []string {
|
||||
for _, user := range c.Users {
|
||||
if user.ID == userID {
|
||||
if user.Blacklist {
|
||||
allStorages := make([]string, 0, len(c.Storages))
|
||||
for _, storage := range c.Storages {
|
||||
allStorages = append(allStorages, storage.GetName())
|
||||
}
|
||||
return slice.Compact(slice.Difference(allStorages, user.Storages))
|
||||
} else {
|
||||
return user.Storages
|
||||
}
|
||||
}
|
||||
us, ok := userStorages[userID]
|
||||
if ok {
|
||||
return us
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Config) GetUsersID() []int64 {
|
||||
var ids []int64
|
||||
for _, user := range c.Users {
|
||||
ids = append(ids, user.ID)
|
||||
}
|
||||
return ids
|
||||
return userIDs
|
||||
}
|
||||
|
||||
func (c *Config) HasStorage(userID int64, storageName string) bool {
|
||||
for _, user := range c.Users {
|
||||
if user.ID == userID {
|
||||
if user.Blacklist {
|
||||
return !slice.Contain(user.Storages, storageName)
|
||||
} else {
|
||||
return slice.Contain(user.Storages, storageName)
|
||||
}
|
||||
}
|
||||
us, ok := userStorages[userID]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return false
|
||||
return slice.Contain(us, storageName)
|
||||
}
|
||||
|
||||
161
config/viper.go
161
config/viper.go
@@ -1,61 +1,48 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/duke-git/lancet/v2/slice"
|
||||
"github.com/krau/SaveAny-Bot/common/i18n"
|
||||
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
|
||||
"github.com/krau/SaveAny-Bot/config/storage"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Workers int `toml:"workers" mapstructure:"workers"`
|
||||
Retry int `toml:"retry" mapstructure:"retry"`
|
||||
NoCleanCache bool `toml:"no_clean_cache" mapstructure:"no_clean_cache" json:"no_clean_cache"`
|
||||
Users []userConfig `toml:"users" mapstructure:"users" json:"users"`
|
||||
Lang string `toml:"lang" mapstructure:"lang" json:"lang"`
|
||||
Workers int `toml:"workers" mapstructure:"workers"`
|
||||
Retry int `toml:"retry" mapstructure:"retry"`
|
||||
NoCleanCache bool `toml:"no_clean_cache" mapstructure:"no_clean_cache" json:"no_clean_cache"`
|
||||
Threads int `toml:"threads" mapstructure:"threads" json:"threads"`
|
||||
Stream bool `toml:"stream" mapstructure:"stream" json:"stream"`
|
||||
|
||||
Temp tempConfig `toml:"temp" mapstructure:"temp"`
|
||||
Log logConfig `toml:"log" mapstructure:"log"`
|
||||
DB dbConfig `toml:"db" mapstructure:"db"`
|
||||
Telegram telegramConfig `toml:"telegram" mapstructure:"telegram"`
|
||||
Storages []StorageConfig `toml:"-" mapstructure:"-" json:"storages"`
|
||||
// Deprecated
|
||||
DeprecatedStorage deprecatedStorageConfig `toml:"storage" mapstructure:"storage"`
|
||||
Cache cacheConfig `toml:"cache" mapstructure:"cache" json:"cache"`
|
||||
Users []userConfig `toml:"users" mapstructure:"users" json:"users"`
|
||||
Temp tempConfig `toml:"temp" mapstructure:"temp"`
|
||||
DB dbConfig `toml:"db" mapstructure:"db"`
|
||||
Telegram telegramConfig `toml:"telegram" mapstructure:"telegram"`
|
||||
Storages []storage.StorageConfig `toml:"-" mapstructure:"-" json:"storages"`
|
||||
Hook hookConfig `toml:"hook" mapstructure:"hook" json:"hook"`
|
||||
}
|
||||
|
||||
type tempConfig struct {
|
||||
BasePath string `toml:"base_path" mapstructure:"base_path" json:"base_path"`
|
||||
CacheTTL int64 `toml:"cache_ttl" mapstructure:"cache_ttl" json:"cache_ttl"`
|
||||
var Cfg *Config = &Config{}
|
||||
|
||||
func (c Config) GetStorageByName(name string) storage.StorageConfig {
|
||||
for _, storage := range c.Storages {
|
||||
if storage.GetName() == name {
|
||||
return storage
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type logConfig struct {
|
||||
Level string `toml:"level" mapstructure:"level"`
|
||||
File string `toml:"file" mapstructure:"file"`
|
||||
BackupCount uint `toml:"backup_count" mapstructure:"backup_count" json:"backup_count"`
|
||||
}
|
||||
|
||||
type dbConfig struct {
|
||||
Path string `toml:"path" mapstructure:"path"`
|
||||
}
|
||||
|
||||
type telegramConfig struct {
|
||||
Token string `toml:"token" mapstructure:"token"`
|
||||
AppID int `toml:"app_id" mapstructure:"app_id" json:"app_id"`
|
||||
AppHash string `toml:"app_hash" mapstructure:"app_hash" json:"app_hash"`
|
||||
Proxy proxyConfig `toml:"proxy" mapstructure:"proxy"`
|
||||
|
||||
// Deprecated
|
||||
Admins []int64 `toml:"admins" mapstructure:"admins"`
|
||||
}
|
||||
|
||||
type proxyConfig struct {
|
||||
Enable bool `toml:"enable" mapstructure:"enable"`
|
||||
URL string `toml:"url" mapstructure:"url"`
|
||||
}
|
||||
|
||||
var Cfg *Config
|
||||
|
||||
func Init() error {
|
||||
func Init(ctx context.Context) error {
|
||||
viper.SetConfigName("config")
|
||||
viper.AddConfigPath(".")
|
||||
viper.AddConfigPath("/etc/saveany/")
|
||||
@@ -65,20 +52,36 @@ func Init() error {
|
||||
replacer := strings.NewReplacer(".", "_")
|
||||
viper.SetEnvKeyReplacer(replacer)
|
||||
|
||||
viper.SetDefault("workers", 3)
|
||||
viper.SetDefault("retry", 3)
|
||||
defaultConfigs := map[string]any{
|
||||
// 基础配置
|
||||
"lang": "zh-Hans",
|
||||
"workers": 3,
|
||||
"retry": 3,
|
||||
"threads": 4,
|
||||
|
||||
viper.SetDefault("telegram.app_id", 1025907)
|
||||
viper.SetDefault("telegram.app_hash", "452b0359b988148995f22ff0f4229750")
|
||||
// 缓存配置
|
||||
"cache.ttl": 86400,
|
||||
"cache.num_counters": 1e5,
|
||||
"cache.max_cost": 1e6,
|
||||
|
||||
viper.SetDefault("temp.base_path", "cache/")
|
||||
viper.SetDefault("temp.cache_ttl", 3600)
|
||||
// Telegram
|
||||
"telegram.app_id": 1025907,
|
||||
"telegram.app_hash": "452b0359b988148995f22ff0f4229750",
|
||||
"telegram.rpc_retry": 5,
|
||||
"telegram.userbot.enable": false,
|
||||
"telegram.userbot.session": "data/usersession.db",
|
||||
|
||||
viper.SetDefault("log.level", "INFO")
|
||||
viper.SetDefault("log.file", "logs/saveany.log")
|
||||
viper.SetDefault("log.backup_count", 7)
|
||||
// 临时目录
|
||||
"temp.base_path": "cache/",
|
||||
|
||||
viper.SetDefault("db.path", "data/saveany.db")
|
||||
// 数据库
|
||||
"db.path": "data/saveany.db",
|
||||
"db.session": "data/session.db",
|
||||
}
|
||||
|
||||
for key, value := range defaultConfigs {
|
||||
viper.SetDefault(key, value)
|
||||
}
|
||||
|
||||
if err := viper.SafeWriteConfigAs("config.toml"); err != nil {
|
||||
if _, ok := err.(viper.ConfigFileAlreadyExistsError); !ok {
|
||||
@@ -91,62 +94,52 @@ func Init() error {
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
Cfg = &Config{}
|
||||
|
||||
if err := viper.Unmarshal(Cfg); err != nil {
|
||||
fmt.Println("Error unmarshalling config file, ", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if Cfg.Telegram.Admins != nil {
|
||||
fmt.Println("警告: 你正在使用旧版 Telegram 管理员配置, 该配置下的用户将可用所有存储.\ntelegram.admins 未来版本将会被废弃, 请参考新的配置文件模板, 使用 users 配置替代.")
|
||||
for _, admin := range Cfg.Telegram.Admins {
|
||||
found := false
|
||||
for _, user := range Cfg.Users {
|
||||
if user.ID == admin {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if found {
|
||||
continue
|
||||
}
|
||||
Cfg.Users = append(Cfg.Users, userConfig{
|
||||
ID: admin,
|
||||
Storages: []string{},
|
||||
Blacklist: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
storagesConfig, err := LoadStorageConfigs(viper.GetViper())
|
||||
storagesConfig, err := storage.LoadStorageConfigs(viper.GetViper())
|
||||
if err != nil {
|
||||
return fmt.Errorf("error loading storage configs: %w", err)
|
||||
}
|
||||
Cfg.Storages = storagesConfig
|
||||
|
||||
if Cfg.DeprecatedStorage != (deprecatedStorageConfig{}) {
|
||||
fmt.Println("\n警告: 你正在使用旧版存储配置, 未来版本将会被废弃.\n请参考新的配置文件模板.")
|
||||
transformDeprecatedStorageConfig()
|
||||
}
|
||||
|
||||
storageNames := make(map[string]struct{})
|
||||
for _, storage := range Cfg.Storages {
|
||||
if _, ok := storageNames[storage.GetName()]; ok {
|
||||
return fmt.Errorf("重复的存储名: %s", storage.GetName())
|
||||
return errors.New(i18n.TWithoutInit(Cfg.Lang, i18nk.ConfigInvalidDuplicateStorageName, map[string]any{
|
||||
"Name": storage.GetName(),
|
||||
}))
|
||||
}
|
||||
storageNames[storage.GetName()] = struct{}{}
|
||||
}
|
||||
|
||||
fmt.Printf("已加载 %d 个存储:\n", len(Cfg.Storages))
|
||||
fmt.Println(i18n.TWithoutInit(Cfg.Lang, i18nk.LoadedStorages, map[string]any{
|
||||
"Count": len(Cfg.Storages),
|
||||
}))
|
||||
for _, storage := range Cfg.Storages {
|
||||
fmt.Printf(" - %s (%s)\n", storage.GetName(), storage.GetType())
|
||||
}
|
||||
|
||||
if Cfg.Workers < 1 || Cfg.Retry < 1 {
|
||||
return fmt.Errorf("workers 和 retry 必须大于 0, 当前值: workers=%d, retry=%d", Cfg.Workers, Cfg.Retry)
|
||||
return errors.New(i18n.TWithoutInit(Cfg.Lang, i18nk.ConfigInvalidWorkersOrRetry, map[string]any{
|
||||
"Workers": Cfg.Workers,
|
||||
"Retry": Cfg.Retry,
|
||||
}))
|
||||
}
|
||||
|
||||
for _, storage := range Cfg.Storages {
|
||||
storages = append(storages, storage.GetName())
|
||||
}
|
||||
for _, user := range Cfg.Users {
|
||||
userIDs = append(userIDs, user.ID)
|
||||
if user.Blacklist {
|
||||
userStorages[user.ID] = slice.Compact(slice.Difference(storages, user.Storages))
|
||||
} else {
|
||||
userStorages[user.ID] = user.Storages
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
122
core/batchtftask/execute.go
Normal file
122
core/batchtftask/execute.go
Normal file
@@ -0,0 +1,122 @@
|
||||
package batchtftask
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path"
|
||||
|
||||
"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/tfile"
|
||||
"golang.org/x/sync/errgroup"
|
||||
)
|
||||
|
||||
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
|
||||
eg, gctx := errgroup.WithContext(ctx)
|
||||
eg.SetLimit(workers)
|
||||
for _, elem := range t.Elems {
|
||||
elem := elem
|
||||
eg.Go(func() error {
|
||||
if t.processing[elem.ID] != nil {
|
||||
return fmt.Errorf("element with ID %s is already being processed", elem.ID)
|
||||
}
|
||||
t.processing[elem.ID] = &elem
|
||||
defer func() {
|
||||
delete(t.processing, elem.ID)
|
||||
}()
|
||||
return t.processElement(gctx, elem)
|
||||
})
|
||||
}
|
||||
err := eg.Wait()
|
||||
if err != nil {
|
||||
logger.Errorf("Error during batch file processing: %v", err)
|
||||
} else {
|
||||
logger.Info("Batch file task completed successfully")
|
||||
}
|
||||
t.Progress.OnDone(ctx, t, err)
|
||||
return err
|
||||
}
|
||||
|
||||
func (t *Task) processElement(ctx context.Context, elem TaskElement) error {
|
||||
logger := log.FromContext(ctx).WithPrefix(fmt.Sprintf("file[%s]", elem.File.Name()))
|
||||
if elem.stream {
|
||||
pr, pw := io.Pipe()
|
||||
defer pr.Close()
|
||||
errg, uploadCtx := errgroup.WithContext(ctx)
|
||||
errg.Go(func() error {
|
||||
return elem.Storage.Save(uploadCtx, pr, elem.Path)
|
||||
})
|
||||
wr := ioutil.NewProgressWriter(pw, func(n int) {
|
||||
t.downloaded.Add(int64(n))
|
||||
t.Progress.OnProgress(ctx, t)
|
||||
})
|
||||
errg.Go(func() error {
|
||||
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)
|
||||
}
|
||||
return err
|
||||
})
|
||||
if err := errg.Wait(); err != nil {
|
||||
return fmt.Errorf("failed to download file in stream mode: %w", err)
|
||||
}
|
||||
logger.Info("File downloaded successfully in stream mode")
|
||||
return nil
|
||||
}
|
||||
logger.Info("Starting file download")
|
||||
localFile, err := fsutil.CreateFile(elem.localPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create local file: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := localFile.CloseAndRemove(); err != nil {
|
||||
logger.Errorf("Failed to close local file: %v", err)
|
||||
}
|
||||
}()
|
||||
wrAt := ioutil.NewProgressWriterAt(localFile, func(n int) {
|
||||
t.downloaded.Add(int64(n))
|
||||
t.Progress.OnProgress(ctx, t)
|
||||
})
|
||||
_, err = tfile.NewDownloader(elem.File).Parallel(ctx, wrAt)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to download file: %w", err)
|
||||
}
|
||||
logger.Info("File downloaded successfully")
|
||||
if path.Ext(elem.FileName()) == "" {
|
||||
ext := fsutil.DetectFileExt(elem.localPath)
|
||||
if ext != "" {
|
||||
elem.Path = elem.Path + ext
|
||||
}
|
||||
}
|
||||
var fileStat os.FileInfo
|
||||
fileStat, err = os.Stat(elem.localPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get file stat: %w", err)
|
||||
}
|
||||
vctx := context.WithValue(ctx, ctxkey.ContentLength, fileStat.Size())
|
||||
err = retry.Retry(func() error {
|
||||
var file *os.File
|
||||
file, err = os.Open(elem.localPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open cache file: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
if err = elem.Storage.Save(vctx, file, elem.Path); err != nil {
|
||||
logger.Errorf("Failed to save file: %s, retrying...", err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}, retry.Context(vctx), retry.RetryTimes(uint(config.Cfg.Retry)))
|
||||
return err
|
||||
}
|
||||
176
core/batchtftask/progress.go
Normal file
176
core/batchtftask/progress.go
Normal file
@@ -0,0 +1,176 @@
|
||||
package batchtftask
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"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"
|
||||
)
|
||||
|
||||
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) {
|
||||
p.start = time.Now()
|
||||
p.lastUpdatePercent.Store(0)
|
||||
log.FromContext(ctx).Debugf("Batch 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("开始执行批量下载任务\n总大小: "),
|
||||
styling.Code(fmt.Sprintf("%.2f MB (%d个文件)", float64(info.TotalSize())/(1024*1024), info.Count())),
|
||||
); 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.TotalSize(), info.Downloaded(), int(p.lastUpdatePercent.Load())) {
|
||||
return
|
||||
}
|
||||
percent := int((info.Downloaded() * 100) / info.TotalSize())
|
||||
if p.lastUpdatePercent.Load() == int32(percent) {
|
||||
return
|
||||
}
|
||||
p.lastUpdatePercent.Store(int32(percent))
|
||||
log.FromContext(ctx).Debugf("Progress update: %s, %d/%d", info.TaskID(), info.Downloaded(), info.TotalSize())
|
||||
entityBuilder := entity.Builder{}
|
||||
var entities []tg.MessageEntityClass
|
||||
if err := styling.Perform(&entityBuilder,
|
||||
styling.Plain("正在处理批量下载任务\n总大小: "),
|
||||
styling.Code(fmt.Sprintf("%.2f MB (%d个文件)", float64(info.TotalSize())/(1024*1024), info.Count())),
|
||||
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.Downloaded(), p.start)/(1024*1024))),
|
||||
styling.Plain("\n当前进度: "),
|
||||
styling.Bold(fmt.Sprintf("%.2f%%", float64(info.Downloaded())/float64(info.TotalSize())*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) {
|
||||
if err != nil {
|
||||
log.FromContext(ctx).Errorf("Batch task %s failed: %s", info.TaskID(), err)
|
||||
} else {
|
||||
log.FromContext(ctx).Debugf("Batch task %s completed successfully", info.TaskID())
|
||||
}
|
||||
entityBuilder := entity.Builder{}
|
||||
var stylingErr error
|
||||
|
||||
if err != nil {
|
||||
if errors.Is(err, context.Canceled) {
|
||||
stylingErr = styling.Perform(&entityBuilder,
|
||||
styling.Plain("任务已取消"),
|
||||
)
|
||||
} else {
|
||||
stylingErr = styling.Perform(&entityBuilder,
|
||||
styling.Plain("处理失败, 错误:\n "),
|
||||
styling.Code(err.Error()),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
stylingErr = styling.Perform(&entityBuilder,
|
||||
styling.Plain("处理完成\n文件数: "),
|
||||
styling.Code(strconv.Itoa(info.Count())),
|
||||
styling.Plain("\n总大小: "),
|
||||
styling.Code(fmt.Sprintf("%.2f MB", float64(info.TotalSize())/(1024*1024))),
|
||||
)
|
||||
}
|
||||
|
||||
if stylingErr != nil {
|
||||
log.FromContext(ctx).Errorf("Failed to build entities: %s", stylingErr)
|
||||
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 NewProgressTracker(messageID int, chatID int64) ProgressTracker {
|
||||
return &Progress{
|
||||
MessageID: messageID,
|
||||
ChatID: chatID,
|
||||
}
|
||||
}
|
||||
95
core/batchtftask/task.go
Normal file
95
core/batchtftask/task.go
Normal file
@@ -0,0 +1,95 @@
|
||||
package batchtftask
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/krau/SaveAny-Bot/config"
|
||||
"github.com/krau/SaveAny-Bot/pkg/enums/tasktype"
|
||||
"github.com/krau/SaveAny-Bot/pkg/tfile"
|
||||
"github.com/krau/SaveAny-Bot/storage"
|
||||
"github.com/rs/xid"
|
||||
)
|
||||
|
||||
type TaskElement struct {
|
||||
ID string
|
||||
Storage storage.Storage
|
||||
Path string
|
||||
File tfile.TGFile
|
||||
localPath string
|
||||
stream bool
|
||||
}
|
||||
|
||||
type Task struct {
|
||||
ID string
|
||||
Ctx context.Context
|
||||
Elems []TaskElement
|
||||
Progress ProgressTracker
|
||||
IgnoreErrors bool // if true, errors during processing will be ignored
|
||||
downloaded atomic.Int64
|
||||
totalSize int64
|
||||
processing map[string]TaskElementInfo
|
||||
failed map[string]error // errors for each element
|
||||
}
|
||||
|
||||
func (t *Task) Type() tasktype.TaskType {
|
||||
return tasktype.TaskTypeTgfiles
|
||||
}
|
||||
|
||||
func NewTaskElement(
|
||||
stor storage.Storage,
|
||||
path string,
|
||||
file tfile.TGFile,
|
||||
) (*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 err != nil {
|
||||
return nil, fmt.Errorf("failed to get absolute path for cache: %w", err)
|
||||
}
|
||||
return &TaskElement{
|
||||
ID: id,
|
||||
Storage: stor,
|
||||
Path: path,
|
||||
File: file,
|
||||
localPath: cachePath,
|
||||
}, nil
|
||||
}
|
||||
return &TaskElement{
|
||||
ID: id,
|
||||
Storage: stor,
|
||||
Path: path,
|
||||
File: file,
|
||||
stream: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func NewBatchTGFileTask(
|
||||
id string,
|
||||
ctx context.Context,
|
||||
files []TaskElement,
|
||||
progress ProgressTracker,
|
||||
ignoreErrors bool,
|
||||
) *Task {
|
||||
task := &Task{
|
||||
ID: id,
|
||||
Ctx: ctx,
|
||||
Elems: files,
|
||||
Progress: progress,
|
||||
downloaded: atomic.Int64{},
|
||||
totalSize: func() int64 {
|
||||
var total int64
|
||||
for _, elem := range files {
|
||||
total += elem.File.Size()
|
||||
}
|
||||
return total
|
||||
}(),
|
||||
processing: make(map[string]TaskElementInfo),
|
||||
IgnoreErrors: ignoreErrors,
|
||||
failed: make(map[string]error),
|
||||
}
|
||||
return task
|
||||
}
|
||||
56
core/batchtftask/taskinfo.go
Normal file
56
core/batchtftask/taskinfo.go
Normal file
@@ -0,0 +1,56 @@
|
||||
package batchtftask
|
||||
|
||||
type TaskElementInfo interface {
|
||||
FileName() string
|
||||
FileSize() int64
|
||||
StoragePath() string
|
||||
StorageName() string
|
||||
}
|
||||
|
||||
func (e *TaskElement) FileName() string {
|
||||
return e.File.Name()
|
||||
}
|
||||
|
||||
func (e *TaskElement) FileSize() int64 {
|
||||
return e.File.Size()
|
||||
}
|
||||
|
||||
func (e *TaskElement) StoragePath() string {
|
||||
return e.Path
|
||||
}
|
||||
|
||||
func (e *TaskElement) StorageName() string {
|
||||
return e.Storage.Name()
|
||||
}
|
||||
|
||||
type TaskInfo interface {
|
||||
TaskID() string
|
||||
TotalSize() int64
|
||||
Downloaded() int64
|
||||
Count() int
|
||||
Processing() []TaskElementInfo
|
||||
}
|
||||
|
||||
func (t *Task) TaskID() string {
|
||||
return t.ID
|
||||
}
|
||||
|
||||
func (t *Task) TotalSize() int64 {
|
||||
return t.totalSize
|
||||
}
|
||||
|
||||
func (t *Task) Downloaded() int64 {
|
||||
return t.downloaded.Load()
|
||||
}
|
||||
|
||||
func (t *Task) Count() int {
|
||||
return len(t.Elems)
|
||||
}
|
||||
|
||||
func (t *Task) Processing() []TaskElementInfo {
|
||||
processing := make([]TaskElementInfo, 0, len(t.Elems))
|
||||
for _, elem := range t.processing {
|
||||
processing = append(processing, elem)
|
||||
}
|
||||
return processing
|
||||
}
|
||||
32
core/batchtftask/utils.go
Normal file
32
core/batchtftask/utils.go
Normal file
@@ -0,0 +1,32 @@
|
||||
package batchtftask
|
||||
|
||||
var progressUpdatesLevels = []struct {
|
||||
size int64 // 文件大小阈值
|
||||
stepPercent int // 每多少 % 更新一次
|
||||
}{
|
||||
{10 << 20, 100},
|
||||
{50 << 20, 20},
|
||||
{200 << 20, 10},
|
||||
{500 << 20, 5},
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
200
core/core.go
200
core/core.go
@@ -3,165 +3,83 @@ package core
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/gabriel-vasile/mimetype"
|
||||
|
||||
"github.com/celestix/gotgproto/ext"
|
||||
"github.com/duke-git/lancet/v2/fileutil"
|
||||
"github.com/gotd/td/tg"
|
||||
"github.com/krau/SaveAny-Bot/bot"
|
||||
"github.com/charmbracelet/log"
|
||||
"github.com/krau/SaveAny-Bot/config"
|
||||
"github.com/krau/SaveAny-Bot/logger"
|
||||
"github.com/krau/SaveAny-Bot/queue"
|
||||
"github.com/krau/SaveAny-Bot/storage"
|
||||
"github.com/krau/SaveAny-Bot/types"
|
||||
"github.com/krau/SaveAny-Bot/pkg/enums/tasktype"
|
||||
"github.com/krau/SaveAny-Bot/pkg/queue"
|
||||
)
|
||||
|
||||
func processPendingTask(task *types.Task) error {
|
||||
logger.L.Debugf("Start processing task: %s", task.String())
|
||||
if task.FileName() == "" {
|
||||
task.File.FileName = fmt.Sprintf("%d_%d_%s", task.FileChatID, task.FileMessageID, task.File.Hash())
|
||||
}
|
||||
cacheDestPath := filepath.Join(config.Cfg.Temp.BasePath, task.FileName())
|
||||
cacheDestPath, err := filepath.Abs(cacheDestPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("处理路径失败: %w", err)
|
||||
}
|
||||
if err := fileutil.CreateDir(filepath.Dir(cacheDestPath)); err != nil {
|
||||
return fmt.Errorf("创建目录失败: %w", err)
|
||||
}
|
||||
var queueInstance *queue.TaskQueue[Exectable]
|
||||
|
||||
if task.StoragePath == "" {
|
||||
task.StoragePath = task.File.FileName
|
||||
}
|
||||
|
||||
taskStorage, err := storage.GetStorageByUserIDAndName(task.UserID, task.StorageName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
task.StoragePath = taskStorage.JoinStoragePath(*task)
|
||||
|
||||
if task.File.FileSize == 0 {
|
||||
return processPhoto(task, taskStorage, cacheDestPath)
|
||||
}
|
||||
|
||||
ctx := task.Ctx.(*ext.Context)
|
||||
|
||||
barTotalCount := calculateBarTotalCount(task.File.FileSize)
|
||||
|
||||
progressCallback := func(bytesRead, contentLength int64) {
|
||||
progress := float64(bytesRead) / float64(contentLength) * 100
|
||||
logger.L.Tracef("Downloading %s: %.2f%%", task.String(), progress)
|
||||
if task.File.FileSize < 1024*1024*50 || int(progress)%(100/barTotalCount) != 0 {
|
||||
return
|
||||
}
|
||||
text, entities := buildProgressMessageEntity(task, barTotalCount, bytesRead, task.StartTime, progress)
|
||||
ctx.EditMessage(task.ReplyChatID, &tg.MessagesEditMessageRequest{
|
||||
Message: text,
|
||||
Entities: entities,
|
||||
ID: task.ReplyMessageID,
|
||||
})
|
||||
}
|
||||
|
||||
text, entities := buildProgressMessageEntity(task, barTotalCount, 0, task.StartTime, 0)
|
||||
ctx.EditMessage(task.ReplyChatID, &tg.MessagesEditMessageRequest{
|
||||
Message: text,
|
||||
Entities: entities,
|
||||
ID: task.ReplyMessageID,
|
||||
})
|
||||
|
||||
readCloser, err := NewTelegramReader(task.Ctx, bot.Client, &task.File.Location,
|
||||
0, task.File.FileSize-1, task.File.FileSize,
|
||||
progressCallback, task.File.FileSize/100)
|
||||
if err != nil {
|
||||
return fmt.Errorf("创建下载失败: %w", err)
|
||||
}
|
||||
defer readCloser.Close()
|
||||
|
||||
dest, err := os.Create(cacheDestPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("创建文件失败: %w", err)
|
||||
}
|
||||
defer dest.Close()
|
||||
task.StartTime = time.Now()
|
||||
if _, err := io.CopyN(dest, readCloser, task.File.FileSize); err != nil {
|
||||
return fmt.Errorf("下载文件失败: %w", err)
|
||||
}
|
||||
defer cleanCacheFile(cacheDestPath)
|
||||
if path.Ext(task.FileName()) == "" {
|
||||
mimeType, err := mimetype.DetectFile(cacheDestPath)
|
||||
if err != nil {
|
||||
logger.L.Errorf("Failed to detect mime type: %s", err)
|
||||
} else {
|
||||
task.File.FileName = fmt.Sprintf("%s%s", task.FileName(), mimeType.Extension())
|
||||
task.StoragePath = fmt.Sprintf("%s%s", task.StoragePath, mimeType.Extension())
|
||||
}
|
||||
}
|
||||
|
||||
logger.L.Infof("Downloaded file: %s", cacheDestPath)
|
||||
ctx.EditMessage(task.ReplyChatID, &tg.MessagesEditMessageRequest{
|
||||
Message: fmt.Sprintf("下载完成: %s\n正在转存文件...", task.FileName()),
|
||||
ID: task.ReplyMessageID,
|
||||
})
|
||||
|
||||
return saveFileWithRetry(task, taskStorage, cacheDestPath)
|
||||
type Exectable interface {
|
||||
Type() tasktype.TaskType
|
||||
TaskID() string
|
||||
Execute(ctx context.Context) error
|
||||
}
|
||||
|
||||
func worker(queue *queue.TaskQueue, semaphore chan struct{}) {
|
||||
func worker(ctx context.Context, qe *queue.TaskQueue[Exectable], semaphore chan struct{}) {
|
||||
logger := log.FromContext(ctx)
|
||||
execHooks := config.Cfg.Hook.Exec
|
||||
for {
|
||||
semaphore <- struct{}{}
|
||||
task := queue.GetTask()
|
||||
logger.L.Debugf("Got task: %s", task.String())
|
||||
|
||||
switch task.Status {
|
||||
case types.Pending:
|
||||
logger.L.Infof("Processing task: %s", task.String())
|
||||
if err := processPendingTask(&task); err != nil {
|
||||
logger.L.Errorf("Failed to do task: %s", err)
|
||||
task.Error = err
|
||||
if errors.Is(err, context.Canceled) {
|
||||
logger.L.Debugf("Task canceled: %s", task.String())
|
||||
task.Status = types.Canceled
|
||||
} else {
|
||||
task.Status = types.Failed
|
||||
qtask, err := qe.Get()
|
||||
if err != nil {
|
||||
logger.Error("Failed to get task from queue:", err)
|
||||
break // queue closed and empty
|
||||
}
|
||||
task := qtask.Data
|
||||
logger.Infof("Processing task: %s", task.TaskID())
|
||||
if err := ExecCommandString(qtask.Context(), execHooks.TaskBeforeStart); err != nil {
|
||||
logger.Errorf("Failed to execute before start hook for task %s: %v", task.TaskID(), err)
|
||||
}
|
||||
if err := task.Execute(qtask.Context()); err != nil {
|
||||
if errors.Is(err, context.Canceled) {
|
||||
logger.Infof("Task %s was canceled", task.TaskID())
|
||||
if err := ExecCommandString(ctx, execHooks.TaskCancel); err != nil {
|
||||
logger.Errorf("Failed to execute cancel hook for task %s: %v", task.TaskID(), err)
|
||||
}
|
||||
} else {
|
||||
task.Status = types.Succeeded
|
||||
logger.Errorf("Failed to execute task %s: %v", task.TaskID(), err)
|
||||
if err := ExecCommandString(ctx, execHooks.TaskFail); err != nil {
|
||||
logger.Errorf("Failed to execute fail hook for task %s: %v", task.TaskID(), err)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
logger.Infof("Task %s completed successfully", task.TaskID())
|
||||
if err := ExecCommandString(ctx, execHooks.TaskSuccess); err != nil {
|
||||
logger.Errorf("Failed to execute success hook for task %s: %v", task.TaskID(), err)
|
||||
}
|
||||
queue.AddTask(task)
|
||||
case types.Succeeded:
|
||||
logger.L.Infof("Task succeeded: %s", task.String())
|
||||
task.Ctx.(*ext.Context).EditMessage(task.ReplyChatID, &tg.MessagesEditMessageRequest{
|
||||
Message: fmt.Sprintf("文件保存成功\n [%s]: %s", task.StorageName, task.StoragePath),
|
||||
ID: task.ReplyMessageID,
|
||||
})
|
||||
case types.Failed:
|
||||
logger.L.Errorf("Task failed: %s", task.String())
|
||||
task.Ctx.(*ext.Context).EditMessage(task.ReplyChatID, &tg.MessagesEditMessageRequest{
|
||||
Message: "文件保存失败\n" + task.Error.Error(),
|
||||
ID: task.ReplyMessageID,
|
||||
})
|
||||
case types.Canceled:
|
||||
logger.L.Infof("Task canceled: %s", task.String())
|
||||
default:
|
||||
logger.L.Errorf("Unknown task status: %s", task.Status)
|
||||
}
|
||||
qe.Done(qtask.ID)
|
||||
<-semaphore
|
||||
logger.L.Debugf("Task done: %s", task.String())
|
||||
}
|
||||
}
|
||||
|
||||
func Run() {
|
||||
logger.L.Info("Start processing tasks...")
|
||||
func Run(ctx context.Context) {
|
||||
log.FromContext(ctx).Info("Start processing tasks...")
|
||||
semaphore := make(chan struct{}, config.Cfg.Workers)
|
||||
for i := 0; i < config.Cfg.Workers; i++ {
|
||||
go worker(queue.Queue, semaphore)
|
||||
if queueInstance == nil {
|
||||
queueInstance = queue.NewTaskQueue[Exectable]()
|
||||
}
|
||||
for range config.Cfg.Workers {
|
||||
go worker(ctx, queueInstance, semaphore)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func AddTask(ctx context.Context, task Exectable) error {
|
||||
return queueInstance.Add(queue.NewTask(ctx, task.TaskID(), task))
|
||||
}
|
||||
|
||||
func CancelTask(ctx context.Context, id string) error {
|
||||
err := queueInstance.CancelTask(id)
|
||||
return err
|
||||
}
|
||||
|
||||
func GetLength(ctx context.Context) int {
|
||||
if queueInstance == nil {
|
||||
return 0
|
||||
}
|
||||
return queueInstance.ActiveLength()
|
||||
}
|
||||
|
||||
23
core/hookutil.go
Normal file
23
core/hookutil.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
)
|
||||
|
||||
func ExecCommandString(ctx context.Context, cmd string) error {
|
||||
if cmd == "" {
|
||||
return nil
|
||||
}
|
||||
var execCmd *exec.Cmd
|
||||
if runtime.GOOS == "windows" {
|
||||
execCmd = exec.CommandContext(ctx, "cmd.exe", "/C", cmd)
|
||||
} else {
|
||||
execCmd = exec.CommandContext(ctx, "sh", "-c", cmd)
|
||||
}
|
||||
execCmd.Stdout = os.Stdout
|
||||
execCmd.Stderr = os.Stderr
|
||||
return execCmd.Run()
|
||||
}
|
||||
154
core/reader.go
154
core/reader.go
@@ -1,154 +0,0 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/celestix/gotgproto"
|
||||
"github.com/gotd/td/tg"
|
||||
"github.com/krau/SaveAny-Bot/config"
|
||||
)
|
||||
|
||||
type telegramReader struct {
|
||||
client *gotgproto.Client
|
||||
location *tg.InputFileLocationClass
|
||||
bytesread int64
|
||||
chunkSize int64
|
||||
i int64
|
||||
contentLength int64
|
||||
start int64
|
||||
end int64
|
||||
next func() ([]byte, error)
|
||||
progressCallback func(bytesRead, contentLength int64)
|
||||
callbackInterval int64
|
||||
lastProgress int64
|
||||
buffer []byte
|
||||
ctx context.Context
|
||||
}
|
||||
|
||||
func (*telegramReader) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *telegramReader) Read(p []byte) (n int, err error) {
|
||||
if r.bytesread == r.contentLength {
|
||||
return 0, io.EOF
|
||||
}
|
||||
|
||||
if r.i >= int64(len(r.buffer)) {
|
||||
r.buffer, err = r.next()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if len(r.buffer) == 0 {
|
||||
r.next = r.partStream()
|
||||
r.buffer, err = r.next()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
}
|
||||
r.i = 0
|
||||
}
|
||||
n = copy(p, r.buffer[r.i:])
|
||||
r.i += int64(n)
|
||||
r.bytesread += int64(n)
|
||||
|
||||
if r.progressCallback != nil && (r.bytesread-r.lastProgress >= r.callbackInterval || r.bytesread == r.contentLength) {
|
||||
r.progressCallback(r.bytesread, r.contentLength)
|
||||
r.lastProgress = r.bytesread
|
||||
}
|
||||
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func NewTelegramReader(
|
||||
ctx context.Context,
|
||||
client *gotgproto.Client,
|
||||
location *tg.InputFileLocationClass,
|
||||
start int64,
|
||||
end int64,
|
||||
contentLength int64,
|
||||
progressCallback func(bytesRead, contentLength int64),
|
||||
callbackInterval int64,
|
||||
) (io.ReadCloser, error) {
|
||||
|
||||
r := &telegramReader{
|
||||
ctx: ctx,
|
||||
location: location,
|
||||
client: client,
|
||||
start: start,
|
||||
end: end,
|
||||
chunkSize: int64(1024 * 1024),
|
||||
contentLength: contentLength,
|
||||
progressCallback: progressCallback,
|
||||
callbackInterval: callbackInterval,
|
||||
}
|
||||
|
||||
r.next = r.partStream()
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func (r *telegramReader) chunk(offset int64, limit int64) ([]byte, error) {
|
||||
var lastError error
|
||||
for i := 0; i < config.Cfg.Retry; i++ {
|
||||
req := &tg.UploadGetFileRequest{
|
||||
Offset: offset,
|
||||
Limit: int(limit),
|
||||
Location: *r.location,
|
||||
}
|
||||
res, err := r.client.API().UploadGetFile(r.ctx, req)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), tg.ErrTimeout) {
|
||||
lastError = err
|
||||
continue
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
switch result := res.(type) {
|
||||
case *tg.UploadFile:
|
||||
return result.Bytes, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unexpected type %T", r)
|
||||
}
|
||||
}
|
||||
return nil, lastError
|
||||
}
|
||||
|
||||
func (r *telegramReader) partStream() func() ([]byte, error) {
|
||||
|
||||
start := r.start
|
||||
end := r.end
|
||||
offset := start - (start % r.chunkSize)
|
||||
|
||||
firstPartCut := start - offset
|
||||
lastPartCut := (end % r.chunkSize) + 1
|
||||
partCount := int((end - offset + r.chunkSize) / r.chunkSize)
|
||||
currentPart := 1
|
||||
|
||||
readData := func() ([]byte, error) {
|
||||
if currentPart > partCount {
|
||||
return make([]byte, 0), nil
|
||||
}
|
||||
res, err := r.chunk(offset, r.chunkSize)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(res) == 0 {
|
||||
return res, nil
|
||||
} else if partCount == 1 {
|
||||
res = res[firstPartCut:lastPartCut]
|
||||
} else if currentPart == 1 {
|
||||
res = res[firstPartCut:]
|
||||
} else if currentPart == partCount {
|
||||
res = res[:lastPartCut]
|
||||
}
|
||||
|
||||
currentPart++
|
||||
offset += r.chunkSize
|
||||
return res, nil
|
||||
}
|
||||
return readData
|
||||
}
|
||||
82
core/tftask/execute.go
Normal file
82
core/tftask/execute.go
Normal file
@@ -0,0 +1,82 @@
|
||||
package tftask
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/log"
|
||||
"github.com/krau/SaveAny-Bot/common/utils/fsutil"
|
||||
"github.com/krau/SaveAny-Bot/config"
|
||||
"github.com/krau/SaveAny-Bot/pkg/enums/ctxkey"
|
||||
"github.com/krau/SaveAny-Bot/pkg/tfile"
|
||||
)
|
||||
|
||||
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.stream {
|
||||
return executeStream(ctx, t)
|
||||
}
|
||||
|
||||
logger.Info("Starting file download")
|
||||
localFile, err := fsutil.CreateFile(t.localPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create local file: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := localFile.CloseAndRemove(); err != nil {
|
||||
logger.Errorf("Failed to close local file: %v", err)
|
||||
}
|
||||
}()
|
||||
wrAt := newWriterAt(ctx, localFile, t.Progress, t)
|
||||
|
||||
defer func() {
|
||||
t.Progress.OnDone(ctx, t, err)
|
||||
}()
|
||||
_, err = tfile.NewDownloader(t.File).Parallel(ctx, wrAt)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to download file: %w", err)
|
||||
}
|
||||
logger.Infof("File downloaded successfully")
|
||||
if path.Ext(t.File.Name()) == "" {
|
||||
ext := fsutil.DetectFileExt(t.localPath)
|
||||
if ext != "" {
|
||||
t.Path = t.Path + ext
|
||||
}
|
||||
}
|
||||
var fileStat os.FileInfo
|
||||
fileStat, err = os.Stat(t.localPath)
|
||||
if err != nil {
|
||||
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 {
|
||||
if err = vctx.Err(); err != nil {
|
||||
return fmt.Errorf("context canceled while saving file: %w", err)
|
||||
}
|
||||
var file *os.File
|
||||
file, err = os.Open(t.localPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open cache file: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
if err = t.Storage.Save(vctx, file, t.Path); err != nil {
|
||||
if i == config.Cfg.Retry {
|
||||
return fmt.Errorf("failed to save file: %w", err)
|
||||
}
|
||||
logger.Errorf("Failed to save file: %s, retrying...", err)
|
||||
select {
|
||||
case <-vctx.Done():
|
||||
return fmt.Errorf("context canceled during retry delay: %w", vctx.Err())
|
||||
case <-time.After(time.Duration(i*500) * time.Millisecond):
|
||||
}
|
||||
continue
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("failed to save file after retries")
|
||||
|
||||
}
|
||||
186
core/tftask/progress.go
Normal file
186
core/tftask/progress.go
Normal file
@@ -0,0 +1,186 @@
|
||||
package tftask
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/log"
|
||||
"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"
|
||||
)
|
||||
|
||||
type ProgressTracker interface {
|
||||
OnStart(ctx context.Context, info TaskInfo)
|
||||
OnProgress(ctx context.Context, info TaskInfo, downloaded, total int64)
|
||||
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) {
|
||||
p.start = time.Now()
|
||||
p.lastUpdatePercent.Store(0)
|
||||
log.FromContext(ctx).Debugf("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("开始下载\n文件名: "),
|
||||
styling.Code(info.FileName()),
|
||||
styling.Plain("\n保存路径: "),
|
||||
styling.Code(fmt.Sprintf("[%s]:%s", info.StorageName(), info.StoragePath())),
|
||||
styling.Plain("\n文件大小: "),
|
||||
styling.Code(fmt.Sprintf("%.2f MB", float64(info.FileSize())/(1024*1024))),
|
||||
); 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, downloaded, total int64) {
|
||||
if !shouldUpdateProgress(total, downloaded, int(p.lastUpdatePercent.Load())) {
|
||||
return
|
||||
}
|
||||
percent := int32((downloaded * 100) / total)
|
||||
if p.lastUpdatePercent.Load() == percent {
|
||||
return
|
||||
}
|
||||
p.lastUpdatePercent.Store(percent)
|
||||
log.FromContext(ctx).Debugf("Progress update: %s, %d/%d", info.FileName(), downloaded, total)
|
||||
entityBuilder := entity.Builder{}
|
||||
var entities []tg.MessageEntityClass
|
||||
if err := styling.Perform(&entityBuilder,
|
||||
styling.Plain("正在处理下载任务\n文件名: "),
|
||||
styling.Code(info.FileName()),
|
||||
styling.Plain("\n保存路径: "),
|
||||
styling.Code(fmt.Sprintf("[%s]:%s", info.StorageName(), info.StoragePath())),
|
||||
styling.Plain("\n文件大小: "),
|
||||
styling.Code(fmt.Sprintf("%.2f MB", float64(total)/(1024*1024))),
|
||||
styling.Plain("\n平均速度: "),
|
||||
styling.Bold(fmt.Sprintf("%.2f MB/s", dlutil.GetSpeed(downloaded, p.start)/(1024*1024))),
|
||||
styling.Plain("\n当前进度: "),
|
||||
styling.Bold(fmt.Sprintf("%.2f%%", float64(downloaded)/float64(total)*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) {
|
||||
if err != nil {
|
||||
log.FromContext(ctx).Errorf("Progress error for file [%s]: %v", info.FileName(), err)
|
||||
} else {
|
||||
log.FromContext(ctx).Debugf("Progress done for file [%s]", info.FileName())
|
||||
}
|
||||
|
||||
entityBuilder := entity.Builder{}
|
||||
var stylingErr error
|
||||
|
||||
if err != nil {
|
||||
if errors.Is(err, context.Canceled) {
|
||||
stylingErr = styling.Perform(&entityBuilder,
|
||||
styling.Plain("任务已取消\n文件名: "),
|
||||
styling.Code(info.FileName()),
|
||||
)
|
||||
} else {
|
||||
stylingErr = styling.Perform(&entityBuilder,
|
||||
styling.Plain("下载失败\n文件名: "),
|
||||
styling.Code(info.FileName()),
|
||||
styling.Plain("\n错误: "),
|
||||
styling.Bold(err.Error()),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
stylingErr = styling.Perform(&entityBuilder,
|
||||
styling.Plain("下载完成\n文件名: "),
|
||||
styling.Code(info.FileName()),
|
||||
styling.Plain("\n保存路径: "),
|
||||
styling.Code(fmt.Sprintf("[%s]:%s", info.StorageName(), info.StoragePath())),
|
||||
)
|
||||
}
|
||||
|
||||
if stylingErr != nil {
|
||||
log.FromContext(ctx).Errorf("Failed to build entities: %s", stylingErr)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
type ProgressOption func(*Progress)
|
||||
|
||||
func NewProgressTrack(
|
||||
messageID int,
|
||||
chatID int64,
|
||||
opts ...ProgressOption,
|
||||
) ProgressTracker {
|
||||
p := &Progress{
|
||||
MessageID: messageID,
|
||||
ChatID: chatID,
|
||||
}
|
||||
for _, opt := range opts {
|
||||
opt(p)
|
||||
}
|
||||
return p
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user