Compare commits
135 Commits
test-v0.23
...
v0.38.4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fef7d37a7e | ||
|
|
b5e9cf987a | ||
|
|
c58fa454bb | ||
|
|
2c5d6f0e57 | ||
|
|
7d57ad30a9 | ||
|
|
4f314bd37f | ||
|
|
131dfeb4cd | ||
|
|
3f40acff55 | ||
|
|
fe47ee3b51 | ||
|
|
4a6f63e58f | ||
|
|
16c71e6384 | ||
|
|
0c2d116708 | ||
|
|
450d32b2b7 | ||
|
|
f80ecae3cc | ||
|
|
f0853536d9 | ||
|
|
15cf81e1bd | ||
|
|
ae48bd52bf | ||
|
|
44de871f63 | ||
|
|
7a2a530e49 | ||
|
|
3aa84e89bf | ||
|
|
257c292679 | ||
|
|
0e2a9cacf2 | ||
|
|
a7854afb2a | ||
|
|
0e989cc1a6 | ||
|
|
76a82a38ee | ||
|
|
c7a0076c15 | ||
|
|
ea07ff7eca | ||
|
|
4d837e946c | ||
|
|
f947ee6fc7 | ||
|
|
40ad12a892 | ||
|
|
697e419643 | ||
|
|
eef051de3b | ||
|
|
6e29442c05 | ||
|
|
a3f1f75caf | ||
|
|
f05dd883e3 | ||
|
|
9cb866de8c | ||
|
|
980455fd24 | ||
|
|
24978470cd | ||
|
|
215e082028 | ||
|
|
a7b93e57fc | ||
|
|
a4b3b459a9 | ||
|
|
06f326088a | ||
|
|
b7d3ec6230 | ||
|
|
f812990e1c | ||
|
|
492900bbef | ||
|
|
764be2a083 | ||
|
|
46c21b77e9 | ||
|
|
8b389a58d5 | ||
|
|
25ad9befa0 | ||
|
|
e824b210d1 | ||
|
|
ae0aa7db3f | ||
|
|
226c15ef08 | ||
|
|
9b3f955e48 | ||
|
|
4997ec408f | ||
|
|
0756cc9eb1 | ||
|
|
37c32a23d4 | ||
|
|
3aa1e2eaed | ||
|
|
b87dd68880 | ||
|
|
68e5a51300 | ||
|
|
7300e54c40 | ||
|
|
94f796d0e8 | ||
|
|
c023fd869d | ||
|
|
e5d1e143e0 | ||
|
|
03eb4f8a18 | ||
|
|
231eb61d25 | ||
|
|
fd1b586b8d | ||
|
|
d035a3409e | ||
|
|
6112f6c240 | ||
|
|
18eedf2edb | ||
|
|
5f9bba9ff7 | ||
|
|
0d3d2209be | ||
|
|
302db2fe75 | ||
|
|
79386bdd7d | ||
|
|
f0607de2cc | ||
|
|
b2bfc96a8f | ||
|
|
0c5bb2ba77 | ||
|
|
9cc87380ff | ||
|
|
46afc14322 | ||
|
|
0c16650ea5 | ||
|
|
133453b5d4 | ||
|
|
8f9ef07d1c | ||
|
|
36285a0700 | ||
|
|
ccf206d176 | ||
|
|
4c851cbbaf | ||
|
|
b9d14f79c8 | ||
|
|
ee5e0b8ff0 | ||
|
|
6423fb25a7 | ||
|
|
03907f2d32 | ||
|
|
9e5042bda1 | ||
|
|
4ebacb02c1 | ||
|
|
818ac9b240 | ||
|
|
fc4a112f08 | ||
|
|
7a2274baa0 | ||
|
|
69a3ed6f4e | ||
|
|
36f3dd83fc | ||
|
|
501b9d844a | ||
|
|
03cec7ec01 | ||
|
|
dc0debcd1c | ||
|
|
4b136bd41e | ||
|
|
d703f11ea0 | ||
|
|
3ce9926967 | ||
|
|
80146176f0 | ||
|
|
14ba2afdf8 | ||
|
|
f4d427a1cb | ||
|
|
f84c83a7e2 | ||
|
|
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 |
@@ -6,6 +6,7 @@
|
|||||||
downloads/
|
downloads/
|
||||||
data/
|
data/
|
||||||
cache/
|
cache/
|
||||||
docs
|
docs/
|
||||||
config.example.toml
|
config.example.toml
|
||||||
docker-compose.*
|
docker-compose.*
|
||||||
|
playwright/
|
||||||
2
.github/FUNDING.yml
vendored
2
.github/FUNDING.yml
vendored
@@ -1,5 +1,5 @@
|
|||||||
# These are supported funding model platforms
|
# These are supported funding model platforms
|
||||||
|
|
||||||
custom: [
|
custom: [
|
||||||
"https://afdian.com/a/acherkrau"
|
"https://afdian.com/a/unvapp"
|
||||||
]
|
]
|
||||||
31
.github/workflows/build-docker.yml
vendored
31
.github/workflows/build-docker.yml
vendored
@@ -29,13 +29,7 @@ jobs:
|
|||||||
type=semver,pattern={{version}}
|
type=semver,pattern={{version}}
|
||||||
type=semver,pattern={{major}}.{{minor}}
|
type=semver,pattern={{major}}.{{minor}}
|
||||||
type=sha
|
type=sha
|
||||||
type=raw,value=latest
|
type=raw,value=latest,enable={{is_default_branch}}
|
||||||
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: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v3
|
uses: docker/setup-qemu-action@v3
|
||||||
@@ -50,23 +44,26 @@ jobs:
|
|||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Extract version from Git Ref
|
- name: Extract Dockerfile args
|
||||||
id: extract_version
|
id: args
|
||||||
run: |
|
run: |
|
||||||
VERSION=$(echo "${{ github.ref }}" | sed 's/refs\/tags\/v//')
|
echo "git_commit=$(git rev-parse --short HEAD)" >> "$GITHUB_OUTPUT"
|
||||||
echo "VERSION=${VERSION}" >> $GITHUB_ENV
|
echo "build_time=$(git show -s --format=%cI)" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
- name: Build and push Docker image
|
- name: Build and push Docker image
|
||||||
|
id: build-and-push
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
platforms: linux/amd64,linux/arm64
|
platforms: linux/amd64,linux/arm64
|
||||||
cache-from: type=gha
|
push: ${{ github.event_name != 'pull_request' }}
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
cache-from: |
|
||||||
|
type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
|
||||||
|
type=gha
|
||||||
cache-to: type=gha,mode=max
|
cache-to: type=gha,mode=max
|
||||||
build-args: |
|
build-args: |
|
||||||
VERSION=${{ steps.meta.outputs.version }}
|
VERSION=${{ steps.meta.outputs.version }}
|
||||||
GitCommit=${{ github.sha }}
|
GitCommit=${{ steps.args.outputs.git_commit }}
|
||||||
BuildTime=${{ format(github.event.repository.updated_at, 'yyyy-MM-dd HH:mm:ss') }}
|
BuildTime=${{ steps.args.outputs.build_time }}
|
||||||
push: true
|
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
|
||||||
|
|||||||
6
.github/workflows/build-release.yml
vendored
6
.github/workflows/build-release.yml
vendored
@@ -63,9 +63,9 @@ jobs:
|
|||||||
README.md
|
README.md
|
||||||
ldflags: >-
|
ldflags: >-
|
||||||
-s -w
|
-s -w
|
||||||
-X "github.com/krau/SaveAny-Bot/common.Version=${{ env.VERSION }}"
|
-X "github.com/krau/SaveAny-Bot/config.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/config.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/config.GitCommit=${{ github.sha }}"
|
||||||
binary_name: saveany-bot
|
binary_name: saveany-bot
|
||||||
env:
|
env:
|
||||||
VERSION: ${{ env.VERSION }}
|
VERSION: ${{ env.VERSION }}
|
||||||
|
|||||||
30
.github/workflows/docs.yml
vendored
30
.github/workflows/docs.yml
vendored
@@ -6,17 +6,31 @@ on:
|
|||||||
paths:
|
paths:
|
||||||
- "docs/**"
|
- "docs/**"
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
deploy:
|
deploy:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-22.04
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: actions/setup-python@v5
|
|
||||||
with:
|
with:
|
||||||
python-version: "3.11"
|
submodules: true # Fetch Hugo themes (true OR recursive)
|
||||||
- uses: actions/cache@v4
|
fetch-depth: 0 # Fetch all history for .GitInfo and .Lastmod
|
||||||
|
|
||||||
|
- name: Setup Hugo
|
||||||
|
uses: peaceiris/actions-hugo@v3
|
||||||
with:
|
with:
|
||||||
key: ${{ github.ref }}
|
hugo-version: '0.147.8'
|
||||||
path: .cache
|
extended: true
|
||||||
- run: pip install mkdocs-material
|
|
||||||
- run: cd docs && mkdocs gh-deploy --force
|
- 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
|
||||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -3,7 +3,10 @@ logs/
|
|||||||
tmp/
|
tmp/
|
||||||
data/
|
data/
|
||||||
downloads/
|
downloads/
|
||||||
cache/
|
|
||||||
session.*
|
session.*
|
||||||
cache.db
|
cache.db
|
||||||
.vscode/
|
.vscode/
|
||||||
|
temp/
|
||||||
|
.hugo_build.lock
|
||||||
|
playwright/
|
||||||
|
testplugins/
|
||||||
27
Dockerfile
27
Dockerfile
@@ -6,22 +6,33 @@ ARG BuildTime="Unknown"
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY . .
|
COPY go.mod go.sum ./
|
||||||
|
RUN --mount=type=cache,target=/go/pkg/mod \
|
||||||
|
go mod download
|
||||||
|
|
||||||
|
COPY . .
|
||||||
RUN --mount=type=cache,target=/root/.cache/go-build \
|
RUN --mount=type=cache,target=/root/.cache/go-build \
|
||||||
--mount=type=cache,target=/go/pkg \
|
--mount=type=cache,target=/go/pkg \
|
||||||
CGO_ENABLED=0 \
|
CGO_ENABLED=0 \
|
||||||
go build -trimpath \
|
go build -trimpath \
|
||||||
-ldflags "-s -w \
|
-ldflags=" \
|
||||||
-X github.com/krau/SaveAny-Bot/common.Version=${VERSION} \
|
-s -w \
|
||||||
-X github.com/krau/SaveAny-Bot/common.GitCommit=${GiTCommit} \
|
-X 'github.com/krau/SaveAny-Bot/config.Version=${VERSION}' \
|
||||||
-X github.com/krau/SaveAny-Bot/common.BuildTime=${BuildTime}" \
|
-X 'github.com/krau/SaveAny-Bot/config.GitCommit=${GitCommit}' \
|
||||||
|
-X 'github.com/krau/SaveAny-Bot/config.BuildTime=${BuildTime}' \
|
||||||
|
" \
|
||||||
-o saveany-bot .
|
-o saveany-bot .
|
||||||
|
|
||||||
FROM alpine:latest
|
FROM alpine:latest
|
||||||
|
|
||||||
|
RUN apk add --no-cache curl
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY --from=builder /app/saveany-bot .
|
COPY --from=builder /app/saveany-bot .
|
||||||
|
COPY entrypoint.sh .
|
||||||
|
|
||||||
ENTRYPOINT ["/app/saveany-bot"]
|
RUN chmod +x /app/saveany-bot && \
|
||||||
|
chmod +x /app/entrypoint.sh
|
||||||
|
|
||||||
|
ENTRYPOINT ["/app/entrypoint.sh"]
|
||||||
|
|||||||
89
README.md
89
README.md
@@ -1,28 +1,81 @@
|
|||||||
<div align="center">
|
<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 的文件保存到各类存储端.
|
> **把 Telegram 上的文件转存到多种存储端.**
|
||||||
|
|
||||||
> _就像 PikPak Bot 一样_
|
[](https://github.com/krau/saveany-bot/releases)
|
||||||
|
[](https://github.com/krau/saveany-bot/releases)
|
||||||
|
[](https://github.com/krau/saveany-bot/actions/workflows/build-release.yml)
|
||||||
|
[](https://github.com/krau/saveany-bot/stargazers)
|
||||||
|
[](https://github.com/krau/saveany-bot/releases)
|
||||||
|
[](https://github.com/krau/saveany-bot/issues)
|
||||||
|
[](https://github.com/krau/saveany-bot/pulls)
|
||||||
|
[](./LICENSE)
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
## [部署](https://sabot.unv.app/deploy/)
|
## 🎯 Features
|
||||||
|
|
||||||
## [参与开发](https://sabot.unv.app/contribute/)
|
- 支持文档/视频/图片/贴纸…甚至还有 [Telegraph](https://telegra.ph/)
|
||||||
|
- 破解禁止保存的文件
|
||||||
|
- 批量下载
|
||||||
|
- 流式传输
|
||||||
|
- 多用户使用
|
||||||
|
- 基于存储规则的自动整理
|
||||||
|
- 监听并自动转存指定聊天的消息, 支持过滤
|
||||||
|
- 使用 js 编写解析器插件以转存任意网站的文件
|
||||||
|
- 存储端支持:
|
||||||
|
- Alist
|
||||||
|
- S3 (MinioSDK)
|
||||||
|
- WebDAV
|
||||||
|
- 本地磁盘
|
||||||
|
- Telegram (重传回指定聊天)
|
||||||
|
|
||||||
---
|
## 📦 Quick Start
|
||||||
|
|
||||||
## 赞助
|
创建文件 `config.toml` 并填入以下内容:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[telegram]
|
||||||
|
token = "" # 你的 Bot Token, 在 @BotFather 获取
|
||||||
|
[telegram.proxy]
|
||||||
|
# 启用代理连接 telegram, 当前只支持 socks5
|
||||||
|
enable = false
|
||||||
|
url = "socks5://127.0.0.1:7890"
|
||||||
|
|
||||||
|
[[storages]]
|
||||||
|
name = "本地磁盘"
|
||||||
|
type = "local"
|
||||||
|
enable = true
|
||||||
|
base_path = "./downloads"
|
||||||
|
|
||||||
|
[[users]]
|
||||||
|
id = 114514 # 你的 Telegram 账号 id
|
||||||
|
storages = []
|
||||||
|
blacklist = true
|
||||||
|
```
|
||||||
|
|
||||||
|
使用 Docker 运行 Save Any Bot:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run -d --name saveany-bot \
|
||||||
|
-v ./config.toml:/app/config.toml \
|
||||||
|
-v ./downloads:/app/downloads \
|
||||||
|
ghcr.io/krau/saveany-bot:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
请 [**查看文档**](https://sabot.unv.app/) 以获取更多配置选项和使用方法.
|
||||||
|
|
||||||
|
## Sponsors
|
||||||
|
|
||||||
本项目受到 [YxVM](https://yxvm.com/) 与 [NodeSupport](https://github.com/NodeSeekDev/NodeSupport) 的支持.
|
本项目受到 [YxVM](https://yxvm.com/) 与 [NodeSupport](https://github.com/NodeSeekDev/NodeSupport) 的支持.
|
||||||
|
|
||||||
如果这个项目对你有帮助, 你可以考虑通过以下方式赞助我:
|
如果这个项目对你有帮助, 你可以考虑通过以下方式赞助我:
|
||||||
|
|
||||||
- [爱发电](https://afdian.com/a/acherkrau)
|
- [爱发电](https://afdian.com/a/unvapp)
|
||||||
|
|
||||||
## Contributors
|
## Contributors
|
||||||
|
|
||||||
@@ -37,6 +90,13 @@
|
|||||||
<sub><b>Krau</b></sub>
|
<sub><b>Krau</b></sub>
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
|
<td align="center">
|
||||||
|
<a href="https://github.com/Silentely">
|
||||||
|
<img src="https://avatars.githubusercontent.com/u/22141172?v=4" width="100;" alt="Silentely"/>
|
||||||
|
<br />
|
||||||
|
<sub><b>Abner</b></sub>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
<td align="center">
|
<td align="center">
|
||||||
<a href="https://github.com/TG-Twilight">
|
<a href="https://github.com/TG-Twilight">
|
||||||
<img src="https://avatars.githubusercontent.com/u/121682528?v=4" width="100;" alt="TG-Twilight"/>
|
<img src="https://avatars.githubusercontent.com/u/121682528?v=4" width="100;" alt="TG-Twilight"/>
|
||||||
@@ -52,8 +112,8 @@
|
|||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
<td align="center">
|
<td align="center">
|
||||||
<a href="https://github.com/ahcorn">
|
<a href="https://github.com/AHCorn">
|
||||||
<img src="https://avatars.githubusercontent.com/u/42889600?v=4" width="100;" alt="ahcorn"/>
|
<img src="https://avatars.githubusercontent.com/u/42889600?v=4" width="100;" alt="AHCorn"/>
|
||||||
<br />
|
<br />
|
||||||
<sub><b>安和</b></sub>
|
<sub><b>安和</b></sub>
|
||||||
</a>
|
</a>
|
||||||
@@ -68,4 +128,11 @@
|
|||||||
- [gotd](https://github.com/gotd/td)
|
- [gotd](https://github.com/gotd/td)
|
||||||
- [TG-FileStreamBot](https://github.com/EverythingSuckz/TG-FileStreamBot)
|
- [TG-FileStreamBot](https://github.com/EverythingSuckz/TG-FileStreamBot)
|
||||||
- [gotgproto](https://github.com/celestix/gotgproto)
|
- [gotgproto](https://github.com/celestix/gotgproto)
|
||||||
|
- [tdl](https://github.com/iyear/tdl)
|
||||||
- All the dependencies
|
- All the dependencies
|
||||||
|
|
||||||
|
## Contact
|
||||||
|
|
||||||
|
- [](https://t.me/ProjectSaveAny)
|
||||||
|
- [](https://github.com/krau/saveany-bot/discussions)
|
||||||
|
- [](https://t.me/acherkrau)
|
||||||
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>
|
|
||||||
|
|
||||||
## 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.example.toml](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.example.toml](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.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Sponsors
|
|
||||||
|
|
||||||
This project is supported by [YxVM](https://yxvm.com/) and [NodeSupport](https://github.com/NodeSeekDev/NodeSupport).
|
|
||||||
|
|
||||||
You can consider sponsoring me if this project helps you:
|
|
||||||
|
|
||||||
- [Afdian](https://afdian.com/a/acherkrau)
|
|
||||||
|
|
||||||
## Thanks
|
|
||||||
|
|
||||||
- [gotd](https://github.com/gotd/td)
|
|
||||||
- [TG-FileStreamBot](https://github.com/EverythingSuckz/TG-FileStreamBot)
|
|
||||||
- [gotgproto](https://github.com/celestix/gotgproto)
|
|
||||||
- All the dependencies
|
|
||||||
101
bot/bot.go
101
bot/bot.go
@@ -1,101 +0,0 @@
|
|||||||
package bot
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"net/url"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/celestix/gotgproto"
|
|
||||||
"github.com/celestix/gotgproto/sessionMaker"
|
|
||||||
"github.com/glebarez/sqlite"
|
|
||||||
"github.com/gotd/td/telegram/dcs"
|
|
||||||
"github.com/gotd/td/tg"
|
|
||||||
"github.com/krau/SaveAny-Bot/common"
|
|
||||||
"github.com/krau/SaveAny-Bot/config"
|
|
||||||
"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() {
|
|
||||||
common.Log.Info("初始化 Telegram 客户端...")
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(config.Cfg.Telegram.Timeout)*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
go InitTelegraphClient()
|
|
||||||
resultChan := 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 := newProxyDialer(config.Cfg.Telegram.Proxy.URL)
|
|
||||||
if err != nil {
|
|
||||||
resultChan <- struct {
|
|
||||||
client *gotgproto.Client
|
|
||||||
err error
|
|
||||||
}{nil, err}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
resolver = dcs.Plain(dcs.PlainOptions{
|
|
||||||
Dial: dialer.(proxy.ContextDialer).DialContext,
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
resolver = dcs.DefaultResolver()
|
|
||||||
}
|
|
||||||
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(config.Cfg.DB.Session)),
|
|
||||||
DisableCopyright: true,
|
|
||||||
Middlewares: FloodWaitMiddleware(),
|
|
||||||
Resolver: resolver,
|
|
||||||
MaxRetries: config.Cfg.Telegram.RpcRetry,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
resultChan <- struct {
|
|
||||||
client *gotgproto.Client
|
|
||||||
err error
|
|
||||||
}{nil, err}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
_, err = client.API().BotsSetBotCommands(ctx, &tg.BotsSetBotCommandsRequest{
|
|
||||||
Scope: &tg.BotCommandScopeDefault{},
|
|
||||||
Commands: []tg.BotCommand{
|
|
||||||
{Command: "start", Description: "开始使用"},
|
|
||||||
{Command: "help", Description: "显示帮助"},
|
|
||||||
{Command: "silent", Description: "开启/关闭静默模式"},
|
|
||||||
{Command: "storage", Description: "设置默认存储端"},
|
|
||||||
{Command: "save", Description: "保存所回复的文件"},
|
|
||||||
{Command: "dir", Description: "管理存储文件夹"},
|
|
||||||
{Command: "rule", Description: "管理规则"},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
resultChan <- struct {
|
|
||||||
client *gotgproto.Client
|
|
||||||
err error
|
|
||||||
}{client, err}
|
|
||||||
}()
|
|
||||||
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
common.Log.Panic("初始化客户端失败: 超时")
|
|
||||||
case result := <-resultChan:
|
|
||||||
if result.err != nil {
|
|
||||||
common.Log.Panicf("初始化客户端失败: %s", result.err)
|
|
||||||
}
|
|
||||||
Client = result.client
|
|
||||||
RegisterHandlers(Client.Dispatcher)
|
|
||||||
common.Log.Info("客户端初始化完成")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,216 +0,0 @@
|
|||||||
package bot
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"path"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/celestix/gotgproto/dispatcher"
|
|
||||||
"github.com/celestix/gotgproto/ext"
|
|
||||||
"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"
|
|
||||||
"github.com/krau/SaveAny-Bot/config"
|
|
||||||
"github.com/krau/SaveAny-Bot/dao"
|
|
||||||
"github.com/krau/SaveAny-Bot/queue"
|
|
||||||
"github.com/krau/SaveAny-Bot/types"
|
|
||||||
"github.com/krau/SaveAny-Bot/userclient"
|
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
|
||||||
|
|
||||||
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), " ")
|
|
||||||
addToDir := args[0] == "add_to_dir" // 已经选择了路径
|
|
||||||
cbDataId, _ := strconv.Atoi(args[1])
|
|
||||||
cbData, err := dao.GetCallbackData(uint(cbDataId))
|
|
||||||
if err != nil {
|
|
||||||
common.Log.Errorf("获取回调数据失败: %s", err)
|
|
||||||
ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{
|
|
||||||
QueryID: update.CallbackQuery.QueryID,
|
|
||||||
Alert: true,
|
|
||||||
Message: "获取回调数据失败",
|
|
||||||
CacheTime: 5,
|
|
||||||
})
|
|
||||||
return dispatcher.EndGroups
|
|
||||||
}
|
|
||||||
|
|
||||||
data := strings.Split(cbData, " ")
|
|
||||||
fileChatID, _ := strconv.Atoi(data[0])
|
|
||||||
fileMessageID, _ := strconv.Atoi(data[1])
|
|
||||||
storageName := data[2]
|
|
||||||
dirIdInt, _ := strconv.Atoi(data[3])
|
|
||||||
dirId := uint(dirIdInt)
|
|
||||||
|
|
||||||
user, err := dao.GetUserByChatID(update.CallbackQuery.UserID)
|
|
||||||
if err != nil {
|
|
||||||
common.Log.Errorf("获取用户失败: %s", err)
|
|
||||||
ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{
|
|
||||||
QueryID: update.CallbackQuery.QueryID,
|
|
||||||
Alert: true,
|
|
||||||
Message: "获取用户失败",
|
|
||||||
CacheTime: 5,
|
|
||||||
})
|
|
||||||
return dispatcher.EndGroups
|
|
||||||
}
|
|
||||||
|
|
||||||
if !addToDir {
|
|
||||||
dirs, err := dao.GetDirsByUserIDAndStorageName(user.ID, storageName)
|
|
||||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
|
||||||
common.Log.Errorf("获取路径失败: %s", err)
|
|
||||||
ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{
|
|
||||||
QueryID: update.CallbackQuery.QueryID,
|
|
||||||
Alert: true,
|
|
||||||
Message: "获取路径失败",
|
|
||||||
CacheTime: 5,
|
|
||||||
})
|
|
||||||
return dispatcher.EndGroups
|
|
||||||
}
|
|
||||||
if len(dirs) != 0 {
|
|
||||||
markup, err := getSelectDirMarkup(fileChatID, fileMessageID, storageName, dirs)
|
|
||||||
if err != nil {
|
|
||||||
common.Log.Errorf("获取路径失败: %s", err)
|
|
||||||
ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{
|
|
||||||
QueryID: update.CallbackQuery.QueryID,
|
|
||||||
Alert: true,
|
|
||||||
Message: "获取路径失败",
|
|
||||||
CacheTime: 5,
|
|
||||||
})
|
|
||||||
return dispatcher.EndGroups
|
|
||||||
}
|
|
||||||
_, err = ctx.EditMessage(update.EffectiveChat().GetID(), &tg.MessagesEditMessageRequest{
|
|
||||||
ID: update.CallbackQuery.GetMsgID(),
|
|
||||||
Message: "请选择要保存到的路径",
|
|
||||||
ReplyMarkup: markup,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
common.Log.Errorf("编辑消息失败: %s", err)
|
|
||||||
}
|
|
||||||
return dispatcher.EndGroups
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
common.Log.Tracef("Got add to queue: chatID: %d, messageID: %d, storage: %s", fileChatID, fileMessageID, storageName)
|
|
||||||
record, err := dao.GetReceivedFileByChatAndMessageID(int64(fileChatID), fileMessageID)
|
|
||||||
if err != nil {
|
|
||||||
common.Log.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 {
|
|
||||||
common.Log.Errorf("更新记录失败: %s", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var dir *dao.Dir
|
|
||||||
if addToDir && dirId != 0 {
|
|
||||||
dir, err = dao.GetDirByID(dirId)
|
|
||||||
if err != nil {
|
|
||||||
common.Log.Errorf("获取路径失败: %s", err)
|
|
||||||
ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{
|
|
||||||
QueryID: update.CallbackQuery.QueryID,
|
|
||||||
Alert: true,
|
|
||||||
Message: "获取路径失败",
|
|
||||||
CacheTime: 5,
|
|
||||||
})
|
|
||||||
return dispatcher.EndGroups
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var task types.Task
|
|
||||||
if record.IsTelegraph {
|
|
||||||
task = types.Task{
|
|
||||||
Ctx: ctx,
|
|
||||||
Status: types.Pending,
|
|
||||||
IsTelegraph: true,
|
|
||||||
TelegraphURL: record.TelegraphURL,
|
|
||||||
StorageName: storageName,
|
|
||||||
FileChatID: record.ChatID,
|
|
||||||
FileMessageID: record.MessageID,
|
|
||||||
ReplyMessageID: record.ReplyMessageID,
|
|
||||||
ReplyChatID: record.ReplyChatID,
|
|
||||||
UserID: update.GetUserChat().GetID(),
|
|
||||||
}
|
|
||||||
if dir != nil {
|
|
||||||
task.StoragePath = path.Join(dir.Path, record.FileName)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
var file *types.File
|
|
||||||
var err error
|
|
||||||
if record.UseUserClient && userclient.UC != nil {
|
|
||||||
uctx := userclient.UC.CreateContext()
|
|
||||||
file, err = FileFromMessage(uctx, record.ChatID, record.MessageID, record.FileName)
|
|
||||||
} else {
|
|
||||||
file, err = FileFromMessage(ctx, record.ChatID, record.MessageID, record.FileName)
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
common.Log.Errorf("获取消息中的文件失败: %s", err)
|
|
||||||
ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{
|
|
||||||
QueryID: update.CallbackQuery.QueryID,
|
|
||||||
Alert: true,
|
|
||||||
Message: fmt.Sprintf("获取消息中的文件失败: %s", err),
|
|
||||||
CacheTime: 5,
|
|
||||||
})
|
|
||||||
return dispatcher.EndGroups
|
|
||||||
}
|
|
||||||
|
|
||||||
task = types.Task{
|
|
||||||
Ctx: ctx,
|
|
||||||
Status: types.Pending,
|
|
||||||
UseUserClient: record.UseUserClient,
|
|
||||||
FileDBID: record.ID,
|
|
||||||
File: file,
|
|
||||||
StorageName: storageName,
|
|
||||||
FileChatID: record.ChatID,
|
|
||||||
ReplyMessageID: record.ReplyMessageID,
|
|
||||||
FileMessageID: record.MessageID,
|
|
||||||
ReplyChatID: record.ReplyChatID,
|
|
||||||
UserID: update.GetUserChat().GetID(),
|
|
||||||
}
|
|
||||||
if dir != nil {
|
|
||||||
task.StoragePath = path.Join(dir.Path, file.FileName)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
queue.AddTask(&task)
|
|
||||||
|
|
||||||
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 {
|
|
||||||
common.Log.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
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
package bot
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/celestix/gotgproto/dispatcher"
|
|
||||||
"github.com/celestix/gotgproto/ext"
|
|
||||||
"github.com/gotd/td/tg"
|
|
||||||
"github.com/krau/SaveAny-Bot/queue"
|
|
||||||
)
|
|
||||||
|
|
||||||
func cancelTask(ctx *ext.Context, update *ext.Update) error {
|
|
||||||
key := strings.Split(string(update.CallbackQuery.Data), " ")[1]
|
|
||||||
ok := queue.CancelTask(key)
|
|
||||||
if ok {
|
|
||||||
ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{
|
|
||||||
QueryID: update.CallbackQuery.QueryID,
|
|
||||||
Message: "任务已取消",
|
|
||||||
})
|
|
||||||
return dispatcher.EndGroups
|
|
||||||
}
|
|
||||||
ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{
|
|
||||||
QueryID: update.CallbackQuery.QueryID,
|
|
||||||
Message: "任务取消失败",
|
|
||||||
})
|
|
||||||
return dispatcher.EndGroups
|
|
||||||
}
|
|
||||||
@@ -1,110 +0,0 @@
|
|||||||
package bot
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/celestix/gotgproto/dispatcher"
|
|
||||||
"github.com/celestix/gotgproto/ext"
|
|
||||||
"github.com/gotd/td/telegram/message/styling"
|
|
||||||
"github.com/krau/SaveAny-Bot/common"
|
|
||||||
"github.com/krau/SaveAny-Bot/dao"
|
|
||||||
"github.com/krau/SaveAny-Bot/storage"
|
|
||||||
)
|
|
||||||
|
|
||||||
func sendDirHelp(ctx *ext.Context, update *ext.Update, userChatID int64) error {
|
|
||||||
dirs, err := dao.GetUserDirsByChatID(userChatID)
|
|
||||||
if err != nil {
|
|
||||||
common.Log.Errorf("获取用户路径失败: %s", err)
|
|
||||||
ctx.Reply(update, ext.ReplyTextString("获取用户路径失败"), nil)
|
|
||||||
return dispatcher.EndGroups
|
|
||||||
}
|
|
||||||
ctx.Reply(update, ext.ReplyTextStyledTextArray(
|
|
||||||
[]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),
|
|
||||||
},
|
|
||||||
), nil)
|
|
||||||
return dispatcher.EndGroups
|
|
||||||
}
|
|
||||||
|
|
||||||
func dirCmd(ctx *ext.Context, update *ext.Update) error {
|
|
||||||
args := strings.Split(update.EffectiveMessage.Text, " ")
|
|
||||||
if len(args) < 2 {
|
|
||||||
return sendDirHelp(ctx, update, update.GetUserChat().GetID())
|
|
||||||
}
|
|
||||||
user, err := dao.GetUserByChatID(update.GetUserChat().GetID())
|
|
||||||
if err != nil {
|
|
||||||
common.Log.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 {
|
|
||||||
return sendDirHelp(ctx, update, update.GetUserChat().GetID())
|
|
||||||
}
|
|
||||||
return addDir(ctx, update, user, args[2], args[3])
|
|
||||||
case "del":
|
|
||||||
// /dir del 3
|
|
||||||
if len(args) < 3 {
|
|
||||||
return sendDirHelp(ctx, update, update.GetUserChat().GetID())
|
|
||||||
}
|
|
||||||
dirID, err := strconv.Atoi(args[2])
|
|
||||||
if err != nil {
|
|
||||||
ctx.Reply(update, ext.ReplyTextString("路径ID无效"), nil)
|
|
||||||
return dispatcher.EndGroups
|
|
||||||
}
|
|
||||||
return delDir(ctx, update, dirID)
|
|
||||||
default:
|
|
||||||
ctx.Reply(update, ext.ReplyTextString("未知操作"), nil)
|
|
||||||
return dispatcher.EndGroups
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func addDir(ctx *ext.Context, update *ext.Update, user *dao.User, storageName, path string) error {
|
|
||||||
if _, err := storage.GetStorageByUserIDAndName(user.ChatID, storageName); err != nil {
|
|
||||||
ctx.Reply(update, ext.ReplyTextString(err.Error()), nil)
|
|
||||||
return dispatcher.EndGroups
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := dao.CreateDirForUser(user.ID, storageName, path); err != nil {
|
|
||||||
common.Log.Errorf("创建路径失败: %s", err)
|
|
||||||
ctx.Reply(update, ext.ReplyTextString("创建路径失败"), nil)
|
|
||||||
return dispatcher.EndGroups
|
|
||||||
}
|
|
||||||
ctx.Reply(update, ext.ReplyTextString("路径添加成功"), nil)
|
|
||||||
return dispatcher.EndGroups
|
|
||||||
}
|
|
||||||
|
|
||||||
func delDir(ctx *ext.Context, update *ext.Update, dirID int) error {
|
|
||||||
if err := dao.DeleteDirByID(uint(dirID)); err != nil {
|
|
||||||
common.Log.Errorf("删除路径失败: %s", err)
|
|
||||||
ctx.Reply(update, ext.ReplyTextString("删除路径失败"), nil)
|
|
||||||
return dispatcher.EndGroups
|
|
||||||
}
|
|
||||||
ctx.Reply(update, ext.ReplyTextString("路径删除成功"), nil)
|
|
||||||
return dispatcher.EndGroups
|
|
||||||
}
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
package bot
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/celestix/gotgproto/dispatcher"
|
|
||||||
"github.com/celestix/gotgproto/ext"
|
|
||||||
"github.com/gotd/td/tg"
|
|
||||||
"github.com/krau/SaveAny-Bot/common"
|
|
||||||
"github.com/krau/SaveAny-Bot/dao"
|
|
||||||
"github.com/krau/SaveAny-Bot/types"
|
|
||||||
)
|
|
||||||
|
|
||||||
func handleFileMessage(ctx *ext.Context, update *ext.Update) error {
|
|
||||||
common.Log.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 {
|
|
||||||
common.Log.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 {
|
|
||||||
common.Log.Errorf("回复失败: %s", err)
|
|
||||||
return dispatcher.EndGroups
|
|
||||||
}
|
|
||||||
media := update.EffectiveMessage.Media
|
|
||||||
file, err := FileFromMedia(media, "")
|
|
||||||
if err != nil {
|
|
||||||
common.Log.Errorf("获取文件失败: %s", err)
|
|
||||||
ctx.Reply(update, ext.ReplyTextString(fmt.Sprintf("获取文件失败: %s", err)), nil)
|
|
||||||
return dispatcher.EndGroups
|
|
||||||
}
|
|
||||||
if file.FileName == "" {
|
|
||||||
file.FileName = GenFileNameFromMessage(*update.EffectiveMessage.Message, file)
|
|
||||||
}
|
|
||||||
|
|
||||||
record, err := dao.SaveReceivedFile(&dao.ReceivedFile{
|
|
||||||
Processing: false,
|
|
||||||
FileName: file.FileName,
|
|
||||||
ChatID: update.EffectiveChat().GetID(),
|
|
||||||
MessageID: update.EffectiveMessage.ID,
|
|
||||||
ReplyMessageID: msg.ID,
|
|
||||||
ReplyChatID: update.GetUserChat().GetID(),
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
common.Log.Errorf("添加接收的文件失败: %s", err)
|
|
||||||
if _, err := ctx.EditMessage(update.EffectiveChat().GetID(), &tg.MessagesEditMessageRequest{
|
|
||||||
Message: fmt.Sprintf("添加接收的文件失败: %s", err),
|
|
||||||
ID: msg.ID,
|
|
||||||
}); err != nil {
|
|
||||||
common.Log.Errorf("编辑消息失败: %s", err)
|
|
||||||
}
|
|
||||||
return dispatcher.EndGroups
|
|
||||||
}
|
|
||||||
|
|
||||||
if !user.Silent || user.DefaultStorage == "" {
|
|
||||||
return ProvideSelectMessage(ctx, update, file.FileName, update.EffectiveChat().GetID(), update.EffectiveMessage.ID, msg.ID)
|
|
||||||
}
|
|
||||||
return HandleSilentAddTask(ctx, update, user, &types.Task{
|
|
||||||
Ctx: ctx,
|
|
||||||
Status: types.Pending,
|
|
||||||
FileDBID: record.ID,
|
|
||||||
File: file,
|
|
||||||
StorageName: user.DefaultStorage,
|
|
||||||
FileChatID: update.EffectiveChat().GetID(),
|
|
||||||
ReplyMessageID: msg.ID,
|
|
||||||
ReplyChatID: update.GetUserChat().GetID(),
|
|
||||||
FileMessageID: update.EffectiveMessage.ID,
|
|
||||||
UserID: user.ChatID,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,235 +0,0 @@
|
|||||||
package bot
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"net/url"
|
|
||||||
"regexp"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/celestix/gotgproto/dispatcher"
|
|
||||||
"github.com/celestix/gotgproto/ext"
|
|
||||||
"github.com/gotd/td/tg"
|
|
||||||
"github.com/krau/SaveAny-Bot/common"
|
|
||||||
"github.com/krau/SaveAny-Bot/dao"
|
|
||||||
"github.com/krau/SaveAny-Bot/types"
|
|
||||||
"github.com/krau/SaveAny-Bot/userclient"
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
linkRegexString = `https?://t\.me/(?:c/\d+|[a-zA-Z0-9_]+)/\d+(?:\?[^\s]*)?`
|
|
||||||
linkRegex = regexp.MustCompile(linkRegexString)
|
|
||||||
)
|
|
||||||
|
|
||||||
type parseResult struct {
|
|
||||||
ChatID int64
|
|
||||||
MessageID int
|
|
||||||
Files []*types.File
|
|
||||||
UserClient bool
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseLink(ctx *ext.Context, link string) (*parseResult, error) {
|
|
||||||
u, err := url.Parse(link)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("无法解析链接: %s", err)
|
|
||||||
}
|
|
||||||
strSlice := strings.Split(u.Path, "/")
|
|
||||||
if len(strSlice) < 3 {
|
|
||||||
return nil, fmt.Errorf("链接格式错误: %s", link)
|
|
||||||
}
|
|
||||||
messageID, err := strconv.Atoi(strSlice[len(strSlice)-1])
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("无法解析消息 ID: %s", err)
|
|
||||||
}
|
|
||||||
var chatID int64
|
|
||||||
if len(strSlice) == 3 {
|
|
||||||
chatUsername := strSlice[1]
|
|
||||||
peer := ctx.PeerStorage.GetPeerByUsername(chatUsername)
|
|
||||||
if peer != nil {
|
|
||||||
chatID = peer.ID
|
|
||||||
} else {
|
|
||||||
linkChat, err := ctx.ResolveUsername(chatUsername)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("解析用户名失败: %s", err)
|
|
||||||
}
|
|
||||||
if linkChat == nil {
|
|
||||||
return nil, fmt.Errorf("找不到该聊天: %s", chatUsername)
|
|
||||||
}
|
|
||||||
chatID = linkChat.GetID()
|
|
||||||
}
|
|
||||||
} else if len(strSlice) == 4 {
|
|
||||||
chatIDInt, err := strconv.Atoi(strSlice[2])
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("无法解析 Chat ID: %s", err)
|
|
||||||
}
|
|
||||||
chatID = int64(chatIDInt)
|
|
||||||
} else {
|
|
||||||
return nil, errors.New("链接格式不正确,无法解析 Chat ID")
|
|
||||||
}
|
|
||||||
if chatID == 0 || messageID == 0 {
|
|
||||||
return nil, fmt.Errorf("链接中缺少 Chat ID 或 Message ID: %s", link)
|
|
||||||
}
|
|
||||||
msg, _, err := tryFetchMessage(ctx, chatID, messageID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("获取消息失败: %s", err)
|
|
||||||
}
|
|
||||||
mediaGroup, isGroup := msg.GetGroupedID()
|
|
||||||
if u.Query().Has("single") || !isGroup || (mediaGroup == 0) || userclient.UC == nil {
|
|
||||||
file, useUserClient, err := tryFetchFileFromMessage(ctx, chatID, messageID, "")
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("获取文件失败: %s", err)
|
|
||||||
}
|
|
||||||
if file.FileName == "" {
|
|
||||||
file.FileName = GenFileNameFromMessage(*msg, file)
|
|
||||||
}
|
|
||||||
return &parseResult{
|
|
||||||
ChatID: chatID,
|
|
||||||
MessageID: messageID,
|
|
||||||
Files: []*types.File{file},
|
|
||||||
UserClient: useUserClient,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
groupMessages, isUserClient, err := tryGetMediaGroup(chatID, messageID, mediaGroup)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("获取媒体组消息失败: %s", err)
|
|
||||||
}
|
|
||||||
var files []*types.File
|
|
||||||
for _, groupMsg := range groupMessages {
|
|
||||||
file, err := FileFromMedia(groupMsg.Media, "")
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("获取媒体文件失败: %s", err)
|
|
||||||
}
|
|
||||||
if file.FileName == "" {
|
|
||||||
file.FileName = GenFileNameFromMessage(*groupMsg, file)
|
|
||||||
}
|
|
||||||
files = append(files, file)
|
|
||||||
}
|
|
||||||
return &parseResult{
|
|
||||||
ChatID: chatID,
|
|
||||||
MessageID: messageID,
|
|
||||||
Files: files,
|
|
||||||
UserClient: isUserClient,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// use passed ctx client to fetch file from message,
|
|
||||||
//
|
|
||||||
// if failed try using userclient
|
|
||||||
func tryFetchFileFromMessage(ctx *ext.Context, chatID int64, messageID int, fileName string) (*types.File, bool, error) {
|
|
||||||
file, err := FileFromMessage(ctx, chatID, messageID, fileName)
|
|
||||||
if err == nil {
|
|
||||||
return file, false, nil
|
|
||||||
}
|
|
||||||
if (strings.Contains(err.Error(), "peer not found") || strings.Contains(err.Error(), "unexpected message type")) && userclient.UC != nil {
|
|
||||||
common.Log.Warnf("无法获取文件 %d:%d, 尝试使用 userbot: %s", chatID, messageID, err)
|
|
||||||
uctx := userclient.GetCtx()
|
|
||||||
peer := uctx.PeerStorage.GetInputPeerById(chatID)
|
|
||||||
if peer == nil {
|
|
||||||
return nil, true, fmt.Errorf("failed to get peer for chat %d: %w", chatID, err)
|
|
||||||
}
|
|
||||||
msg, err := GetSingleHistoryMessage(uctx, uctx.Raw, peer, messageID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, true, err
|
|
||||||
}
|
|
||||||
file, err = FileFromMedia(msg.Media, fileName)
|
|
||||||
if err != nil {
|
|
||||||
return nil, true, fmt.Errorf("failed to get file from userbot message %d:%d: %w", chatID, messageID, err)
|
|
||||||
}
|
|
||||||
return file, true, nil
|
|
||||||
}
|
|
||||||
return nil, false, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func tryGetMediaGroup(chatID int64, messageID int, mediaGroupID int64) ([]*tg.Message, bool, error) {
|
|
||||||
if userclient.UC != nil {
|
|
||||||
uctx := userclient.GetCtx()
|
|
||||||
messages, err := GetMediaGroup(uctx, chatID, messageID, mediaGroupID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, true, fmt.Errorf("failed to get media group from userbot: %w", err)
|
|
||||||
}
|
|
||||||
return messages, true, nil
|
|
||||||
}
|
|
||||||
return nil, false, errors.New("userclient is not available, cannot fetch media group")
|
|
||||||
}
|
|
||||||
|
|
||||||
func tryFetchMessage(ctx *ext.Context, chatID int64, messageID int) (*tg.Message, bool, error) {
|
|
||||||
msg, err := GetTGMessage(ctx, chatID, messageID)
|
|
||||||
if err == nil {
|
|
||||||
return msg, false, nil
|
|
||||||
}
|
|
||||||
if userclient.UC != nil && (strings.Contains(err.Error(), "peer not found") || strings.Contains(err.Error(), "unexpected message type")) {
|
|
||||||
common.Log.Warnf("无法获取消息 %d:%d, 尝试使用 userbot: %s", chatID, messageID, err)
|
|
||||||
uctx := userclient.GetCtx()
|
|
||||||
msg, err := GetTGMessage(uctx, chatID, messageID)
|
|
||||||
if err == nil {
|
|
||||||
return msg, true, nil
|
|
||||||
}
|
|
||||||
return nil, true, fmt.Errorf("获取消息失败: %w", err)
|
|
||||||
}
|
|
||||||
return nil, false, fmt.Errorf("获取消息失败: %s", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
func handleLinkMessage(ctx *ext.Context, update *ext.Update) error {
|
|
||||||
common.Log.Trace("Got link message")
|
|
||||||
link := linkRegex.FindString(update.EffectiveMessage.Text)
|
|
||||||
if link == "" {
|
|
||||||
return dispatcher.ContinueGroups
|
|
||||||
}
|
|
||||||
result, err := parseLink(ctx, link)
|
|
||||||
if err != nil {
|
|
||||||
common.Log.Errorf("解析链接失败: %s", err)
|
|
||||||
ctx.Reply(update, ext.ReplyTextString("解析链接失败"), nil)
|
|
||||||
return dispatcher.EndGroups
|
|
||||||
}
|
|
||||||
|
|
||||||
user, err := dao.GetUserByChatID(update.GetUserChat().GetID())
|
|
||||||
if err != nil {
|
|
||||||
common.Log.Errorf("获取用户失败: %s", err)
|
|
||||||
ctx.Reply(update, ext.ReplyTextString("获取用户失败"), nil)
|
|
||||||
return dispatcher.EndGroups
|
|
||||||
}
|
|
||||||
|
|
||||||
replied, err := ctx.Reply(update, ext.ReplyTextString("正在获取文件..."), nil)
|
|
||||||
if err != nil {
|
|
||||||
common.Log.Errorf("回复失败: %s", err)
|
|
||||||
return dispatcher.EndGroups
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: handle group files
|
|
||||||
receivedFile := &dao.ReceivedFile{
|
|
||||||
Processing: false,
|
|
||||||
FileName: result.Files[0].FileName,
|
|
||||||
ChatID: result.ChatID,
|
|
||||||
MessageID: result.MessageID,
|
|
||||||
ReplyMessageID: replied.ID,
|
|
||||||
ReplyChatID: update.GetUserChat().GetID(),
|
|
||||||
UseUserClient: result.UserClient,
|
|
||||||
}
|
|
||||||
record, err := dao.SaveReceivedFile(receivedFile)
|
|
||||||
if err != nil {
|
|
||||||
common.Log.Errorf("保存接收的文件失败: %s", err)
|
|
||||||
ctx.EditMessage(update.EffectiveChat().GetID(), &tg.MessagesEditMessageRequest{
|
|
||||||
Message: "无法保存文件: " + err.Error(),
|
|
||||||
ID: replied.ID,
|
|
||||||
})
|
|
||||||
return dispatcher.EndGroups
|
|
||||||
}
|
|
||||||
file := result.Files[0]
|
|
||||||
if !user.Silent || user.DefaultStorage == "" {
|
|
||||||
return ProvideSelectMessage(ctx, update, file.FileName, result.ChatID, result.MessageID, replied.ID)
|
|
||||||
}
|
|
||||||
return HandleSilentAddTask(ctx, update, user, &types.Task{
|
|
||||||
Ctx: ctx,
|
|
||||||
Status: types.Pending,
|
|
||||||
FileDBID: record.ID,
|
|
||||||
UseUserClient: result.UserClient,
|
|
||||||
File: file,
|
|
||||||
StorageName: user.DefaultStorage,
|
|
||||||
UserID: user.ChatID,
|
|
||||||
FileChatID: result.ChatID,
|
|
||||||
FileMessageID: result.MessageID,
|
|
||||||
ReplyMessageID: replied.ID,
|
|
||||||
ReplyChatID: update.GetUserChat().GetID(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,141 +0,0 @@
|
|||||||
package bot
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/celestix/gotgproto/dispatcher"
|
|
||||||
"github.com/celestix/gotgproto/ext"
|
|
||||||
"github.com/duke-git/lancet/v2/slice"
|
|
||||||
"github.com/gotd/td/telegram/message/styling"
|
|
||||||
"github.com/krau/SaveAny-Bot/common"
|
|
||||||
"github.com/krau/SaveAny-Bot/dao"
|
|
||||||
"github.com/krau/SaveAny-Bot/types"
|
|
||||||
)
|
|
||||||
|
|
||||||
func sendRuleHelp(ctx *ext.Context, update *ext.Update, userChatID int64) error {
|
|
||||||
user, err := dao.GetUserByChatID(userChatID)
|
|
||||||
if err != nil {
|
|
||||||
common.Log.Errorf("获取用户规则失败: %s", err)
|
|
||||||
ctx.Reply(update, ext.ReplyTextString("获取用户规则失败"), nil)
|
|
||||||
return dispatcher.EndGroups
|
|
||||||
}
|
|
||||||
ctx.Reply(update, ext.ReplyTextStyledTextArray(
|
|
||||||
[]styling.StyledTextOption{
|
|
||||||
styling.Bold("使用方法: /rule <操作> <参数...>"),
|
|
||||||
styling.Bold(fmt.Sprintf("\n当前已%s规则模式", map[bool]string{true: "启用", false: "禁用"}[user.ApplyRule])),
|
|
||||||
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 user.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),
|
|
||||||
},
|
|
||||||
), nil)
|
|
||||||
return dispatcher.EndGroups
|
|
||||||
}
|
|
||||||
|
|
||||||
func ruleCmd(ctx *ext.Context, update *ext.Update) error {
|
|
||||||
args := strings.Split(update.EffectiveMessage.Text, " ")
|
|
||||||
if len(args) < 2 {
|
|
||||||
return sendRuleHelp(ctx, update, update.GetUserChat().GetID())
|
|
||||||
}
|
|
||||||
user, err := dao.GetUserByChatID(update.GetUserChat().GetID())
|
|
||||||
if err != nil {
|
|
||||||
common.Log.Errorf("获取用户失败: %s", err)
|
|
||||||
ctx.Reply(update, ext.ReplyTextString("获取用户失败"), nil)
|
|
||||||
return dispatcher.EndGroups
|
|
||||||
}
|
|
||||||
switch args[1] {
|
|
||||||
case "switch":
|
|
||||||
// /rule switch
|
|
||||||
return switchApplyRule(ctx, update, user)
|
|
||||||
case "add":
|
|
||||||
// /rule add <type> <data> <storage> <dirpath>
|
|
||||||
if len(args) < 6 {
|
|
||||||
return sendRuleHelp(ctx, update, user.ChatID)
|
|
||||||
}
|
|
||||||
return addRule(ctx, update, user, args)
|
|
||||||
case "del":
|
|
||||||
// /rule del <id>
|
|
||||||
if len(args) < 3 {
|
|
||||||
return sendRuleHelp(ctx, update, user.ChatID)
|
|
||||||
}
|
|
||||||
ruleID := args[2]
|
|
||||||
id, err := strconv.Atoi(ruleID)
|
|
||||||
if err != nil {
|
|
||||||
ctx.Reply(update, ext.ReplyTextString("无效的规则ID"), nil)
|
|
||||||
return dispatcher.EndGroups
|
|
||||||
}
|
|
||||||
if err := dao.DeleteRule(uint(id)); err != nil {
|
|
||||||
common.Log.Errorf("删除规则失败: %s", err)
|
|
||||||
ctx.Reply(update, ext.ReplyTextString("删除规则失败"), nil)
|
|
||||||
return dispatcher.EndGroups
|
|
||||||
}
|
|
||||||
ctx.Reply(update, ext.ReplyTextString("删除规则成功"), nil)
|
|
||||||
return dispatcher.EndGroups
|
|
||||||
default:
|
|
||||||
return sendRuleHelp(ctx, update, user.ChatID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func switchApplyRule(ctx *ext.Context, update *ext.Update, user *dao.User) error {
|
|
||||||
applyRule := !user.ApplyRule
|
|
||||||
if err := dao.UpdateUserApplyRule(user.ChatID, applyRule); err != nil {
|
|
||||||
common.Log.Errorf("更新用户失败: %s", err)
|
|
||||||
ctx.Reply(update, ext.ReplyTextString("更新用户失败"), nil)
|
|
||||||
return dispatcher.EndGroups
|
|
||||||
}
|
|
||||||
if applyRule {
|
|
||||||
ctx.Reply(update, ext.ReplyTextString("已启用规则模式"), nil)
|
|
||||||
} else {
|
|
||||||
ctx.Reply(update, ext.ReplyTextString("已禁用规则模式"), nil)
|
|
||||||
}
|
|
||||||
return dispatcher.EndGroups
|
|
||||||
}
|
|
||||||
|
|
||||||
func addRule(ctx *ext.Context, update *ext.Update, user *dao.User, args []string) error {
|
|
||||||
// /rule add <type> <data> <storage> <dirpath>
|
|
||||||
ruleType := args[2]
|
|
||||||
ruleData := args[3]
|
|
||||||
storageName := args[4]
|
|
||||||
dirPath := args[5]
|
|
||||||
|
|
||||||
if !slice.Contain(types.RuleTypes, types.RuleType(ruleType)) {
|
|
||||||
var ruleTypesStylingArray []styling.StyledTextOption
|
|
||||||
ruleTypesStylingArray = append(ruleTypesStylingArray, styling.Bold("无效的规则类型, 可用类型:\n"))
|
|
||||||
for i, ruleType := range types.RuleTypes {
|
|
||||||
ruleTypesStylingArray = append(ruleTypesStylingArray, styling.Code(string(ruleType)))
|
|
||||||
if i != len(types.RuleTypes)-1 {
|
|
||||||
ruleTypesStylingArray = append(ruleTypesStylingArray, styling.Plain(", "))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ctx.Reply(update, ext.ReplyTextStyledTextArray(ruleTypesStylingArray), nil)
|
|
||||||
return dispatcher.EndGroups
|
|
||||||
}
|
|
||||||
rule := &dao.Rule{
|
|
||||||
Type: ruleType,
|
|
||||||
Data: ruleData,
|
|
||||||
StorageName: storageName,
|
|
||||||
DirPath: dirPath,
|
|
||||||
UserID: user.ID,
|
|
||||||
}
|
|
||||||
if err := dao.CreateRule(rule); err != nil {
|
|
||||||
common.Log.Errorf("添加规则失败: %s", err)
|
|
||||||
ctx.Reply(update, ext.ReplyTextString("添加规则失败"), nil)
|
|
||||||
return dispatcher.EndGroups
|
|
||||||
}
|
|
||||||
ctx.Reply(update, ext.ReplyTextString("添加规则成功"), nil)
|
|
||||||
return dispatcher.EndGroups
|
|
||||||
}
|
|
||||||
@@ -1,266 +0,0 @@
|
|||||||
package bot
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/celestix/gotgproto/dispatcher"
|
|
||||||
"github.com/celestix/gotgproto/ext"
|
|
||||||
"github.com/gotd/td/tg"
|
|
||||||
"github.com/krau/SaveAny-Bot/common"
|
|
||||||
"github.com/krau/SaveAny-Bot/dao"
|
|
||||||
"github.com/krau/SaveAny-Bot/queue"
|
|
||||||
"github.com/krau/SaveAny-Bot/storage"
|
|
||||||
"github.com/krau/SaveAny-Bot/types"
|
|
||||||
)
|
|
||||||
|
|
||||||
func sendSaveHelp(ctx *ext.Context, update *ext.Update) error {
|
|
||||||
helpText := `
|
|
||||||
使用方法:
|
|
||||||
|
|
||||||
1. 使用该命令回复要保存的文件, 可选文件名参数.
|
|
||||||
示例:
|
|
||||||
/save custom_file_name.mp4
|
|
||||||
|
|
||||||
2. 设置默认存储后, 发送 /save <频道ID/用户名> <消息ID范围> 来批量保存文件. 遵从存储规则, 若未匹配到任何规则则使用默认存储.
|
|
||||||
示例:
|
|
||||||
/save @moreacg 114-514
|
|
||||||
`
|
|
||||||
ctx.Reply(update, ext.ReplyTextString(helpText), nil)
|
|
||||||
return dispatcher.EndGroups
|
|
||||||
}
|
|
||||||
|
|
||||||
func saveCmd(ctx *ext.Context, update *ext.Update) error {
|
|
||||||
args := strings.Split(update.EffectiveMessage.Text, " ")
|
|
||||||
if len(args) >= 3 {
|
|
||||||
return handleBatchSave(ctx, update, args[1:])
|
|
||||||
}
|
|
||||||
|
|
||||||
replyToMsgID := func() int {
|
|
||||||
res, ok := update.EffectiveMessage.GetReplyTo()
|
|
||||||
if !ok || res == nil {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
replyHeader, ok := res.(*tg.MessageReplyHeader)
|
|
||||||
if !ok {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
replyToMsgID, ok := replyHeader.GetReplyToMsgID()
|
|
||||||
if !ok {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
return replyToMsgID
|
|
||||||
}()
|
|
||||||
if replyToMsgID == 0 {
|
|
||||||
return sendSaveHelp(ctx, update)
|
|
||||||
}
|
|
||||||
|
|
||||||
user, err := dao.GetUserByChatID(update.GetUserChat().GetID())
|
|
||||||
if err != nil {
|
|
||||||
common.Log.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 {
|
|
||||||
common.Log.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 {
|
|
||||||
common.Log.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 {
|
|
||||||
common.Log.Errorf("获取文件失败: %s", err)
|
|
||||||
ctx.EditMessage(update.EffectiveChat().GetID(), &tg.MessagesEditMessageRequest{
|
|
||||||
Message: fmt.Sprintf("获取文件失败: %s", err),
|
|
||||||
ID: replied.ID,
|
|
||||||
})
|
|
||||||
return dispatcher.EndGroups
|
|
||||||
}
|
|
||||||
|
|
||||||
if file.FileName == "" {
|
|
||||||
file.FileName = GenFileNameFromMessage(*msg, file)
|
|
||||||
}
|
|
||||||
receivedFile := &dao.ReceivedFile{
|
|
||||||
Processing: false,
|
|
||||||
FileName: file.FileName,
|
|
||||||
ChatID: update.EffectiveChat().GetID(),
|
|
||||||
MessageID: replyToMsgID,
|
|
||||||
ReplyMessageID: replied.ID,
|
|
||||||
ReplyChatID: update.GetUserChat().GetID(),
|
|
||||||
}
|
|
||||||
|
|
||||||
record, err := dao.SaveReceivedFile(receivedFile)
|
|
||||||
if err != nil {
|
|
||||||
common.Log.Errorf("保存接收的文件失败: %s", err)
|
|
||||||
if _, err := ctx.EditMessage(update.EffectiveChat().GetID(), &tg.MessagesEditMessageRequest{
|
|
||||||
Message: fmt.Sprintf("保存接收的文件失败: %s", err),
|
|
||||||
ID: replied.ID,
|
|
||||||
}); err != nil {
|
|
||||||
common.Log.Errorf("编辑消息失败: %s", err)
|
|
||||||
}
|
|
||||||
return dispatcher.EndGroups
|
|
||||||
}
|
|
||||||
if !user.Silent || user.DefaultStorage == "" {
|
|
||||||
return ProvideSelectMessage(ctx, update, file.FileName, update.EffectiveChat().GetID(), msg.ID, replied.ID)
|
|
||||||
}
|
|
||||||
return HandleSilentAddTask(ctx, update, user, &types.Task{
|
|
||||||
Ctx: ctx,
|
|
||||||
Status: types.Pending,
|
|
||||||
FileDBID: record.ID,
|
|
||||||
File: file,
|
|
||||||
StorageName: user.DefaultStorage,
|
|
||||||
FileChatID: update.EffectiveChat().GetID(),
|
|
||||||
ReplyMessageID: replied.ID,
|
|
||||||
ReplyChatID: update.GetUserChat().GetID(),
|
|
||||||
FileMessageID: msg.ID,
|
|
||||||
UserID: user.ChatID,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func handleBatchSave(ctx *ext.Context, update *ext.Update, args []string) error {
|
|
||||||
// args: [0] = @channel, [1] = 114-514
|
|
||||||
chatArg := args[0]
|
|
||||||
var chatID int64
|
|
||||||
var err error
|
|
||||||
msgIdSlice := strings.Split(args[1], "-")
|
|
||||||
if len(msgIdSlice) != 2 {
|
|
||||||
ctx.Reply(update, ext.ReplyTextString("无效的消息ID范围"), nil)
|
|
||||||
return dispatcher.EndGroups
|
|
||||||
}
|
|
||||||
minMsgID, minerr := strconv.ParseInt(msgIdSlice[0], 10, 64)
|
|
||||||
maxMsgID, maxerr := strconv.ParseInt(msgIdSlice[1], 10, 64)
|
|
||||||
if minerr != nil || maxerr != nil {
|
|
||||||
ctx.Reply(update, ext.ReplyTextString("无效的消息ID范围"), nil)
|
|
||||||
return dispatcher.EndGroups
|
|
||||||
}
|
|
||||||
if minMsgID > maxMsgID || minMsgID <= 0 || maxMsgID <= 0 {
|
|
||||||
ctx.Reply(update, ext.ReplyTextString("无效的消息ID范围"), nil)
|
|
||||||
return dispatcher.EndGroups
|
|
||||||
}
|
|
||||||
user, err := dao.GetUserByChatID(update.GetUserChat().GetID())
|
|
||||||
if err != nil {
|
|
||||||
common.Log.Errorf("获取用户失败: %s", err)
|
|
||||||
ctx.Reply(update, ext.ReplyTextString("获取用户失败"), nil)
|
|
||||||
return dispatcher.EndGroups
|
|
||||||
}
|
|
||||||
if user.DefaultStorage == "" {
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
if strings.HasPrefix(chatArg, "@") {
|
|
||||||
chatUsername := strings.TrimPrefix(chatArg, "@")
|
|
||||||
chat, err := ctx.ResolveUsername(chatUsername)
|
|
||||||
if err != nil {
|
|
||||||
common.Log.Errorf("解析频道用户名失败: %s", err)
|
|
||||||
ctx.Reply(update, ext.ReplyTextString("解析频道用户名失败"), nil)
|
|
||||||
return dispatcher.EndGroups
|
|
||||||
}
|
|
||||||
if chat == nil {
|
|
||||||
ctx.Reply(update, ext.ReplyTextString("无法找到聊天"), nil)
|
|
||||||
return dispatcher.EndGroups
|
|
||||||
}
|
|
||||||
chatID = chat.GetID()
|
|
||||||
} else {
|
|
||||||
chatID, err = strconv.ParseInt(chatArg, 10, 64)
|
|
||||||
if err != nil {
|
|
||||||
ctx.Reply(update, ext.ReplyTextString("无效的频道ID或用户名"), nil)
|
|
||||||
return dispatcher.EndGroups
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if chatID == 0 {
|
|
||||||
ctx.Reply(update, ext.ReplyTextString("无效的频道ID或用户名"), nil)
|
|
||||||
return dispatcher.EndGroups
|
|
||||||
}
|
|
||||||
|
|
||||||
replied, err := ctx.Reply(update, ext.ReplyTextString("正在批量保存..."), nil)
|
|
||||||
if err != nil {
|
|
||||||
common.Log.Errorf("回复失败: %s", err)
|
|
||||||
return dispatcher.EndGroups
|
|
||||||
}
|
|
||||||
|
|
||||||
total := maxMsgID - minMsgID + 1
|
|
||||||
successadd := 0
|
|
||||||
failedGetFile := 0
|
|
||||||
failedGetMsg := 0
|
|
||||||
failedSaveDB := 0
|
|
||||||
for i := minMsgID; i <= maxMsgID; i++ {
|
|
||||||
file, err := FileFromMessage(ctx, chatID, int(i), "")
|
|
||||||
if err != nil {
|
|
||||||
common.Log.Errorf("获取文件失败: %s", err)
|
|
||||||
failedGetFile++
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if file.FileName == "" {
|
|
||||||
message, err := GetTGMessage(ctx, chatID, int(i))
|
|
||||||
if err != nil {
|
|
||||||
common.Log.Errorf("获取消息失败: %s", err)
|
|
||||||
failedGetMsg++
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
file.FileName = GenFileNameFromMessage(*message, file)
|
|
||||||
}
|
|
||||||
receivedFile := &dao.ReceivedFile{
|
|
||||||
Processing: false,
|
|
||||||
FileName: file.FileName,
|
|
||||||
ChatID: chatID,
|
|
||||||
MessageID: int(i),
|
|
||||||
ReplyChatID: update.GetUserChat().GetID(),
|
|
||||||
ReplyMessageID: 0,
|
|
||||||
}
|
|
||||||
record, err := dao.SaveReceivedFile(receivedFile)
|
|
||||||
if err != nil {
|
|
||||||
common.Log.Errorf("保存接收的文件失败: %s", err)
|
|
||||||
failedSaveDB++
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
task := &types.Task{
|
|
||||||
Ctx: ctx,
|
|
||||||
Status: types.Pending,
|
|
||||||
FileDBID: record.ID,
|
|
||||||
File: file,
|
|
||||||
StorageName: user.DefaultStorage,
|
|
||||||
FileChatID: chatID,
|
|
||||||
FileMessageID: int(i),
|
|
||||||
UserID: user.ChatID,
|
|
||||||
ReplyMessageID: 0,
|
|
||||||
ReplyChatID: update.GetUserChat().GetID(),
|
|
||||||
}
|
|
||||||
queue.AddTask(task)
|
|
||||||
successadd++
|
|
||||||
}
|
|
||||||
ctx.EditMessage(update.EffectiveChat().GetID(), &tg.MessagesEditMessageRequest{
|
|
||||||
Message: fmt.Sprintf("批量添加任务完成\n成功添加: %d/%d\n获取文件失败: %d\n获取消息失败: %d\n保存数据库失败: %d", successadd, total, failedGetFile, failedGetMsg, failedSaveDB),
|
|
||||||
ID: replied.ID,
|
|
||||||
})
|
|
||||||
return dispatcher.EndGroups
|
|
||||||
}
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
package bot
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/celestix/gotgproto/dispatcher"
|
|
||||||
"github.com/celestix/gotgproto/ext"
|
|
||||||
tgtypes "github.com/celestix/gotgproto/types"
|
|
||||||
"github.com/gotd/td/tg"
|
|
||||||
)
|
|
||||||
|
|
||||||
func copyMediaToChat(ctx *ext.Context, msg *tg.Message, chatID int64) (*tgtypes.Message, error) {
|
|
||||||
media, ok := msg.GetMedia()
|
|
||||||
if !ok {
|
|
||||||
return nil, fmt.Errorf("获取媒体失败")
|
|
||||||
}
|
|
||||||
|
|
||||||
req := &tg.MessagesSendMediaRequest{
|
|
||||||
InvertMedia: msg.InvertMedia,
|
|
||||||
Message: msg.Message,
|
|
||||||
}
|
|
||||||
|
|
||||||
switch m := media.(type) {
|
|
||||||
case *tg.MessageMediaDocument:
|
|
||||||
document, ok := m.Document.AsNotEmpty()
|
|
||||||
if !ok {
|
|
||||||
return nil, ErrEmptyDocument
|
|
||||||
}
|
|
||||||
inputMedia := &tg.InputMediaDocument{
|
|
||||||
ID: document.AsInput(),
|
|
||||||
}
|
|
||||||
inputMedia.SetFlags()
|
|
||||||
req.Media = inputMedia
|
|
||||||
|
|
||||||
case *tg.MessageMediaPhoto:
|
|
||||||
photo, ok := m.Photo.AsNotEmpty()
|
|
||||||
if !ok {
|
|
||||||
return nil, ErrEmptyPhoto
|
|
||||||
}
|
|
||||||
inputMedia := &tg.InputMediaPhoto{
|
|
||||||
ID: photo.AsInput(),
|
|
||||||
}
|
|
||||||
inputMedia.SetFlags()
|
|
||||||
req.Media = inputMedia
|
|
||||||
|
|
||||||
default:
|
|
||||||
return nil, fmt.Errorf("不支持的媒体类型: %T", media)
|
|
||||||
}
|
|
||||||
|
|
||||||
req.SetEntities(msg.Entities)
|
|
||||||
req.SetFlags()
|
|
||||||
|
|
||||||
return ctx.SendMedia(chatID, req)
|
|
||||||
}
|
|
||||||
|
|
||||||
func sendFileToTelegram(ctx *ext.Context, update *ext.Update) error {
|
|
||||||
args := strings.Split(string(update.CallbackQuery.Data), " ")
|
|
||||||
if len(args) < 3 {
|
|
||||||
ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{
|
|
||||||
QueryID: update.CallbackQuery.QueryID,
|
|
||||||
Alert: true,
|
|
||||||
Message: "参数错误",
|
|
||||||
CacheTime: 5,
|
|
||||||
})
|
|
||||||
return dispatcher.EndGroups
|
|
||||||
}
|
|
||||||
fileChatID, _ := strconv.Atoi(args[1])
|
|
||||||
fileMessageID, _ := strconv.Atoi(args[2])
|
|
||||||
fileMessage, err := GetTGMessage(ctx, int64(fileChatID), fileMessageID)
|
|
||||||
if err != nil {
|
|
||||||
ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{
|
|
||||||
QueryID: update.CallbackQuery.QueryID,
|
|
||||||
Alert: true,
|
|
||||||
Message: "无法获取文件消息",
|
|
||||||
CacheTime: 5,
|
|
||||||
})
|
|
||||||
return dispatcher.EndGroups
|
|
||||||
}
|
|
||||||
_, err = copyMediaToChat(ctx, fileMessage, update.EffectiveChat().GetID())
|
|
||||||
if err != nil {
|
|
||||||
ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{
|
|
||||||
QueryID: update.CallbackQuery.QueryID,
|
|
||||||
Alert: true,
|
|
||||||
Message: fmt.Sprintf("发送文件失败: %s", err),
|
|
||||||
CacheTime: 5,
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{
|
|
||||||
QueryID: update.CallbackQuery.QueryID,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return dispatcher.EndGroups
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
package bot
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/celestix/gotgproto/dispatcher"
|
|
||||||
"github.com/celestix/gotgproto/ext"
|
|
||||||
"github.com/krau/SaveAny-Bot/common"
|
|
||||||
"github.com/krau/SaveAny-Bot/dao"
|
|
||||||
)
|
|
||||||
|
|
||||||
func silent(ctx *ext.Context, update *ext.Update) error {
|
|
||||||
user, err := dao.GetUserByChatID(update.GetUserChat().GetID())
|
|
||||||
if err != nil {
|
|
||||||
common.Log.Errorf("获取用户失败: %s", err)
|
|
||||||
return dispatcher.EndGroups
|
|
||||||
}
|
|
||||||
if !user.Silent && user.DefaultStorage == "" {
|
|
||||||
ctx.Reply(update, ext.ReplyTextString("请先使用 /storage 设置默认存储位置"), nil)
|
|
||||||
return dispatcher.EndGroups
|
|
||||||
}
|
|
||||||
user.Silent = !user.Silent
|
|
||||||
if err := dao.UpdateUser(user); err != nil {
|
|
||||||
common.Log.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
|
|
||||||
}
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
package bot
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/celestix/gotgproto/dispatcher"
|
|
||||||
"github.com/celestix/gotgproto/ext"
|
|
||||||
"github.com/krau/SaveAny-Bot/common"
|
|
||||||
"github.com/krau/SaveAny-Bot/dao"
|
|
||||||
)
|
|
||||||
|
|
||||||
func start(ctx *ext.Context, update *ext.Update) error {
|
|
||||||
if err := dao.CreateUser(update.GetUserChat().GetID()); err != nil {
|
|
||||||
common.Log.Errorf("创建用户失败: %s", err)
|
|
||||||
return dispatcher.EndGroups
|
|
||||||
}
|
|
||||||
return help(ctx, update)
|
|
||||||
}
|
|
||||||
|
|
||||||
const helpText string = `
|
|
||||||
Save Any Bot - 转存你的 Telegram 文件
|
|
||||||
版本: %s , 提交: %s
|
|
||||||
命令:
|
|
||||||
/start - 开始使用
|
|
||||||
/help - 显示帮助
|
|
||||||
/silent - 开关静默模式
|
|
||||||
/storage - 设置默认存储位置
|
|
||||||
/save [自定义文件名] - 保存文件
|
|
||||||
|
|
||||||
静默模式: 开启后 Bot 直接保存到收到的文件到默认位置, 不再询问
|
|
||||||
|
|
||||||
默认存储位置: 在静默模式下保存到的位置
|
|
||||||
|
|
||||||
向 Bot 发送(转发)文件, 或发送一个公开频道的消息链接以保存文件
|
|
||||||
`
|
|
||||||
|
|
||||||
func help(ctx *ext.Context, update *ext.Update) error {
|
|
||||||
ctx.Reply(update, ext.ReplyTextString(fmt.Sprintf(helpText, common.Version, common.GitCommit)), nil)
|
|
||||||
return dispatcher.EndGroups
|
|
||||||
}
|
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
package bot
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/celestix/gotgproto/dispatcher"
|
|
||||||
"github.com/celestix/gotgproto/ext"
|
|
||||||
"github.com/gotd/td/tg"
|
|
||||||
"github.com/krau/SaveAny-Bot/common"
|
|
||||||
"github.com/krau/SaveAny-Bot/dao"
|
|
||||||
"github.com/krau/SaveAny-Bot/storage"
|
|
||||||
)
|
|
||||||
|
|
||||||
func storageCmd(ctx *ext.Context, update *ext.Update) error {
|
|
||||||
userChatID := update.GetUserChat().GetID()
|
|
||||||
storages := storage.GetUserStorages(userChatID)
|
|
||||||
if len(storages) == 0 {
|
|
||||||
ctx.Reply(update, ext.ReplyTextString("无可用的存储"), nil)
|
|
||||||
return dispatcher.EndGroups
|
|
||||||
}
|
|
||||||
markup, err := getSetDefaultStorageMarkup(userChatID, storages)
|
|
||||||
if err != nil {
|
|
||||||
common.Log.Errorf("Failed to get markup: %s", err)
|
|
||||||
ctx.Reply(update, ext.ReplyTextString("获取存储位置失败"), nil)
|
|
||||||
return dispatcher.EndGroups
|
|
||||||
}
|
|
||||||
ctx.Reply(update, ext.ReplyTextString("请选择要设为默认的存储位置"), &ext.ReplyOpts{
|
|
||||||
Markup: markup,
|
|
||||||
})
|
|
||||||
return dispatcher.EndGroups
|
|
||||||
}
|
|
||||||
|
|
||||||
func setDefaultStorage(ctx *ext.Context, update *ext.Update) error {
|
|
||||||
args := strings.Split(string(update.CallbackQuery.Data), " ")
|
|
||||||
userID, _ := strconv.Atoi(args[1])
|
|
||||||
if userID != int(update.CallbackQuery.GetUserID()) {
|
|
||||||
ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{
|
|
||||||
QueryID: update.CallbackQuery.QueryID,
|
|
||||||
Alert: true,
|
|
||||||
Message: "你没有权限",
|
|
||||||
CacheTime: 5,
|
|
||||||
})
|
|
||||||
return dispatcher.EndGroups
|
|
||||||
}
|
|
||||||
cbDataId, _ := strconv.Atoi(args[2])
|
|
||||||
storageName, err := dao.GetCallbackData(uint(cbDataId))
|
|
||||||
if err != nil {
|
|
||||||
common.Log.Errorf("获取回调数据失败: %s", err)
|
|
||||||
ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{
|
|
||||||
QueryID: update.CallbackQuery.QueryID,
|
|
||||||
Alert: true,
|
|
||||||
Message: "获取回调数据失败",
|
|
||||||
CacheTime: 5,
|
|
||||||
})
|
|
||||||
return dispatcher.EndGroups
|
|
||||||
}
|
|
||||||
|
|
||||||
selectedStorage, err := storage.GetStorageByName(storageName)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
common.Log.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 {
|
|
||||||
common.Log.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 {
|
|
||||||
common.Log.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
|
|
||||||
}
|
|
||||||
@@ -1,114 +0,0 @@
|
|||||||
package bot
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"net/url"
|
|
||||||
"regexp"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/celestix/gotgproto/dispatcher"
|
|
||||||
"github.com/celestix/gotgproto/ext"
|
|
||||||
"github.com/celestix/telegraph-go/v2"
|
|
||||||
"github.com/gotd/td/tg"
|
|
||||||
"github.com/krau/SaveAny-Bot/common"
|
|
||||||
"github.com/krau/SaveAny-Bot/config"
|
|
||||||
"github.com/krau/SaveAny-Bot/dao"
|
|
||||||
"github.com/krau/SaveAny-Bot/storage"
|
|
||||||
"github.com/krau/SaveAny-Bot/types"
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
TelegraphClient *telegraph.TelegraphClient
|
|
||||||
TelegraphUrlRegexString = `https://telegra.ph/.*`
|
|
||||||
TelegraphUrlRegex = regexp.MustCompile(TelegraphUrlRegexString)
|
|
||||||
)
|
|
||||||
|
|
||||||
func InitTelegraphClient() {
|
|
||||||
var httpClient *http.Client
|
|
||||||
if config.Cfg.Telegram.Proxy.Enable {
|
|
||||||
proxyUrl, err := url.Parse(config.Cfg.Telegram.Proxy.URL)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Println("Error parsing proxy URL:", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
proxy := http.ProxyURL(proxyUrl)
|
|
||||||
httpClient = &http.Client{
|
|
||||||
Transport: &http.Transport{
|
|
||||||
Proxy: proxy,
|
|
||||||
},
|
|
||||||
Timeout: 30 * time.Second,
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
httpClient = &http.Client{
|
|
||||||
Timeout: 30 * time.Second,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
TelegraphClient = telegraph.GetTelegraphClient(&telegraph.ClientOpt{HttpClient: httpClient})
|
|
||||||
}
|
|
||||||
|
|
||||||
func handleTelegraph(ctx *ext.Context, update *ext.Update) error {
|
|
||||||
common.Log.Trace("Got telegraph link")
|
|
||||||
tgphUrl := TelegraphUrlRegex.FindString(update.EffectiveMessage.Text)
|
|
||||||
if tgphUrl == "" {
|
|
||||||
return dispatcher.ContinueGroups
|
|
||||||
}
|
|
||||||
replied, err := ctx.Reply(update, ext.ReplyTextString("正在获取文件..."), nil)
|
|
||||||
if err != nil {
|
|
||||||
common.Log.Errorf("回复失败: %s", err)
|
|
||||||
return dispatcher.EndGroups
|
|
||||||
}
|
|
||||||
user, err := dao.GetUserByChatID(update.GetUserChat().GetID())
|
|
||||||
if err != nil {
|
|
||||||
common.Log.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
|
|
||||||
}
|
|
||||||
|
|
||||||
tgphPath := strings.Split(tgphUrl, "/")[len(strings.Split(tgphUrl, "/"))-1]
|
|
||||||
fileName, err := url.PathUnescape(tgphPath)
|
|
||||||
if err != nil {
|
|
||||||
common.Log.Errorf("解析 Telegraph 路径失败: %s", err)
|
|
||||||
fileName = tgphPath
|
|
||||||
}
|
|
||||||
|
|
||||||
record := &dao.ReceivedFile{
|
|
||||||
Processing: false,
|
|
||||||
FileName: fileName,
|
|
||||||
ChatID: update.EffectiveChat().GetID(),
|
|
||||||
MessageID: update.EffectiveMessage.GetID(),
|
|
||||||
ReplyMessageID: replied.ID,
|
|
||||||
ReplyChatID: update.EffectiveChat().GetID(),
|
|
||||||
IsTelegraph: true,
|
|
||||||
TelegraphURL: tgphUrl,
|
|
||||||
}
|
|
||||||
if _, err := dao.SaveReceivedFile(record); err != nil {
|
|
||||||
common.Log.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, fileName, update.EffectiveChat().GetID(), update.EffectiveMessage.GetID(), replied.ID)
|
|
||||||
}
|
|
||||||
return HandleSilentAddTask(ctx, update, user, &types.Task{
|
|
||||||
Ctx: ctx,
|
|
||||||
Status: types.Pending,
|
|
||||||
StorageName: user.DefaultStorage,
|
|
||||||
UserID: user.ChatID,
|
|
||||||
ReplyMessageID: replied.ID,
|
|
||||||
ReplyChatID: update.GetUserChat().GetID(),
|
|
||||||
IsTelegraph: true,
|
|
||||||
TelegraphURL: tgphUrl,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
package bot
|
|
||||||
|
|
||||||
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/common"
|
|
||||||
)
|
|
||||||
|
|
||||||
func RegisterHandlers(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", start))
|
|
||||||
disp.AddHandler(handlers.NewCommand("help", help))
|
|
||||||
disp.AddHandler(handlers.NewCommand("silent", silent))
|
|
||||||
disp.AddHandler(handlers.NewCommand("storage", storageCmd))
|
|
||||||
disp.AddHandler(handlers.NewCommand("save", saveCmd))
|
|
||||||
disp.AddHandler(handlers.NewCommand("dir", dirCmd))
|
|
||||||
disp.AddHandler(handlers.NewCommand("rule", ruleCmd))
|
|
||||||
linkRegexFilter, err := filters.Message.Regex(linkRegexString)
|
|
||||||
if err != nil {
|
|
||||||
common.Log.Panicf("创建正则表达式过滤器失败: %s", err)
|
|
||||||
}
|
|
||||||
disp.AddHandler(handlers.NewMessage(linkRegexFilter, handleLinkMessage))
|
|
||||||
telegraphUrlRegexFilter, err := filters.Message.Regex(TelegraphUrlRegexString)
|
|
||||||
if err != nil {
|
|
||||||
common.Log.Panicf("创建 Telegraph URL 正则表达式过滤器失败: %s", err)
|
|
||||||
}
|
|
||||||
disp.AddHandler(handlers.NewMessage(telegraphUrlRegexFilter, handleTelegraph))
|
|
||||||
disp.AddHandler(handlers.NewCallbackQuery(filters.CallbackQuery.Prefix("add"), AddToQueue))
|
|
||||||
disp.AddHandler(handlers.NewCallbackQuery(filters.CallbackQuery.Prefix("set_default"), setDefaultStorage))
|
|
||||||
disp.AddHandler(handlers.NewCallbackQuery(filters.CallbackQuery.Prefix("cancel"), cancelTask))
|
|
||||||
disp.AddHandler(handlers.NewCallbackQuery(filters.CallbackQuery.Prefix("send_here"), sendFileToTelegram))
|
|
||||||
disp.AddHandler(handlers.NewMessage(filters.Message.Media, handleFileMessage))
|
|
||||||
}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
package bot
|
|
||||||
|
|
||||||
import (
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/celestix/gotgproto/dispatcher"
|
|
||||||
"github.com/celestix/gotgproto/ext"
|
|
||||||
"github.com/duke-git/lancet/v2/slice"
|
|
||||||
"github.com/gotd/contrib/middleware/floodwait"
|
|
||||||
"github.com/gotd/contrib/middleware/ratelimit"
|
|
||||||
"github.com/gotd/td/telegram"
|
|
||||||
"github.com/krau/SaveAny-Bot/config"
|
|
||||||
"golang.org/x/time/rate"
|
|
||||||
)
|
|
||||||
|
|
||||||
func FloodWaitMiddleware() []telegram.Middleware {
|
|
||||||
waiter := floodwait.NewSimpleWaiter().WithMaxRetries(uint(config.Cfg.Telegram.FloodRetry))
|
|
||||||
ratelimiter := ratelimit.New(rate.Every(time.Millisecond*100), 5)
|
|
||||||
return []telegram.Middleware{
|
|
||||||
waiter,
|
|
||||||
ratelimiter,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
450
bot/utils.go
450
bot/utils.go
@@ -1,450 +0,0 @@
|
|||||||
package bot
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/celestix/gotgproto/dispatcher"
|
|
||||||
"github.com/celestix/gotgproto/ext"
|
|
||||||
"github.com/gabriel-vasile/mimetype"
|
|
||||||
"github.com/gotd/td/telegram/message/entity"
|
|
||||||
"github.com/gotd/td/telegram/message/styling"
|
|
||||||
"github.com/gotd/td/telegram/query"
|
|
||||||
"github.com/gotd/td/tg"
|
|
||||||
"github.com/krau/SaveAny-Bot/common"
|
|
||||||
"github.com/krau/SaveAny-Bot/dao"
|
|
||||||
"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")
|
|
||||||
ErrEmptyMessage = errors.New("message is empty")
|
|
||||||
)
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func getSelectStorageMarkup(userChatID int64, fileChatID, fileMessageID int) (*tg.ReplyInlineMarkup, error) {
|
|
||||||
user, err := dao.GetUserByChatID(userChatID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to get user by chat ID: %d, error: %w", userChatID, err)
|
|
||||||
}
|
|
||||||
storages := storage.GetUserStorages(user.ChatID)
|
|
||||||
// if len(storages) == 0 {
|
|
||||||
// return nil, ErrNoStorages
|
|
||||||
// }
|
|
||||||
|
|
||||||
buttons := make([]tg.KeyboardButtonClass, 0)
|
|
||||||
for _, storage := range storages {
|
|
||||||
cbData := fmt.Sprintf("%d %d %s 0", fileChatID, fileMessageID, storage.Name()) // 0 for empty dir id
|
|
||||||
cbDataId, err := dao.CreateCallbackData(cbData)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to create callback data: %w", err)
|
|
||||||
}
|
|
||||||
buttons = append(buttons, &tg.KeyboardButtonCallback{
|
|
||||||
Text: storage.Name(),
|
|
||||||
Data: fmt.Appendf(nil, "add %d", cbDataId),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
markup.Rows = append(markup.Rows, tg.KeyboardButtonRow{
|
|
||||||
Buttons: []tg.KeyboardButtonClass{
|
|
||||||
&tg.KeyboardButtonCallback{
|
|
||||||
Text: "发送到当前聊天",
|
|
||||||
Data: []byte(fmt.Sprintf("send_here %d %d", fileChatID, fileMessageID)),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
return markup, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func getSelectDirMarkup(fileChatID, fileMessageID int, storageName string, dirs []dao.Dir) (*tg.ReplyInlineMarkup, error) {
|
|
||||||
buttons := make([]tg.KeyboardButtonClass, 0)
|
|
||||||
for _, dir := range dirs {
|
|
||||||
if dir.ID == 0 || dir.StorageName != storageName {
|
|
||||||
return nil, fmt.Errorf("unexpected dir: %v", dir)
|
|
||||||
}
|
|
||||||
cbDataId, err := dao.CreateCallbackData(fmt.Sprintf("%d %d %s %d", fileChatID, fileMessageID, storageName, dir.ID))
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to create callback data: %w", err)
|
|
||||||
}
|
|
||||||
buttons = append(buttons, &tg.KeyboardButtonCallback{
|
|
||||||
Text: dir.Path,
|
|
||||||
Data: []byte(fmt.Sprintf("add_to_dir %d", cbDataId)),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
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, error) {
|
|
||||||
buttons := make([]tg.KeyboardButtonClass, 0)
|
|
||||||
for _, storage := range storages {
|
|
||||||
cbDataId, err := dao.CreateCallbackData(storage.Name())
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to create callback data: %w", err)
|
|
||||||
}
|
|
||||||
buttons = append(buttons, &tg.KeyboardButtonCallback{
|
|
||||||
Text: storage.Name(),
|
|
||||||
Data: []byte(fmt.Sprintf("set_default %d %d", userChatID, cbDataId)),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
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 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)
|
|
||||||
cachedFile, err := common.CacheGet[*types.File](ctx, key)
|
|
||||||
if err == nil {
|
|
||||||
if customFileName != "" {
|
|
||||||
cachedFile.FileName = customFileName
|
|
||||||
}
|
|
||||||
return cachedFile, nil
|
|
||||||
}
|
|
||||||
common.Log.Debugf("Getting file: %s", key)
|
|
||||||
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.CacheSet(ctx, key, file); err != nil {
|
|
||||||
common.Log.Errorf("Failed to cache file: %s", err)
|
|
||||||
}
|
|
||||||
return file, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func GetTGMessage(ctx *ext.Context, chatId int64, messageID int) (*tg.Message, error) {
|
|
||||||
key := fmt.Sprintf("message:%d:%d", chatId, messageID)
|
|
||||||
cacheMessage, err := common.CacheGet[*tg.Message](ctx, key)
|
|
||||||
if err == nil {
|
|
||||||
return cacheMessage, nil
|
|
||||||
}
|
|
||||||
common.Log.Debugf("Fetching message: %d:%d", chatId, messageID)
|
|
||||||
messages, err := ctx.GetMessages(chatId, []tg.InputMessageClass{&tg.InputMessageID{ID: messageID}})
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if len(messages) == 0 {
|
|
||||||
return nil, ErrEmptyMessage
|
|
||||||
}
|
|
||||||
msg := messages[0]
|
|
||||||
tgMessage, ok := msg.(*tg.Message)
|
|
||||||
if !ok {
|
|
||||||
return nil, fmt.Errorf("unexpected message type: %T", msg)
|
|
||||||
}
|
|
||||||
if err := common.CacheSet(ctx, key, tgMessage); err != nil {
|
|
||||||
common.Log.Errorf("Failed to cache message: %s", err)
|
|
||||||
}
|
|
||||||
return tgMessage, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Userbot only
|
|
||||||
//
|
|
||||||
// https://github.com/iyear/tdl/blob/fbb396da774ba544e527c3ef41c44921ad74ee98/core/util/tutil/tutil.go#L174
|
|
||||||
func GetSingleHistoryMessage(ctx context.Context, client *tg.Client, peer tg.InputPeerClass, msg int) (*tg.Message, error) {
|
|
||||||
it := query.Messages(client).GetHistory(peer).OffsetID(msg + 1).BatchSize(1).Iter()
|
|
||||||
|
|
||||||
if !it.Next(ctx) {
|
|
||||||
return nil, fmt.Errorf("failed to get message %d from %s: %w", msg, peer, it.Err())
|
|
||||||
}
|
|
||||||
|
|
||||||
m, ok := it.Value().Msg.(*tg.Message)
|
|
||||||
if !ok {
|
|
||||||
return nil, fmt.Errorf("invalid message %d", msg)
|
|
||||||
}
|
|
||||||
|
|
||||||
if m.GetID() != msg {
|
|
||||||
return nil, fmt.Errorf("the message %d/%d may be deleted", GetInputPeerID(peer), msg)
|
|
||||||
}
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Userbot only
|
|
||||||
func GetHistoryMessages(ctx context.Context, client *tg.Client, peer tg.InputPeerClass, startID, limit int) ([]*tg.Message, error) {
|
|
||||||
endID := startID + limit - 1
|
|
||||||
msgs, err := client.MessagesGetHistory(ctx, &tg.MessagesGetHistoryRequest{
|
|
||||||
Peer: peer,
|
|
||||||
OffsetID: startID,
|
|
||||||
Limit: limit,
|
|
||||||
AddOffset: startID - endID,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to get history messages: %w", err)
|
|
||||||
}
|
|
||||||
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:
|
|
||||||
return nil, fmt.Errorf("unexpected messages type: %T", msgs)
|
|
||||||
}
|
|
||||||
|
|
||||||
messageBatch := make([]*tg.Message, 0, 100)
|
|
||||||
|
|
||||||
for _, msg := range msgClass {
|
|
||||||
msgNotEmpty, ok := msg.AsNotEmpty()
|
|
||||||
if !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
switch msgNotEmptyV := msgNotEmpty.(type) {
|
|
||||||
case *tg.Message:
|
|
||||||
messageBatch = append(messageBatch, msgNotEmptyV)
|
|
||||||
default:
|
|
||||||
common.Log.Warnf("Unexpected message type: %T, skipping", msgNotEmptyV)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(messageBatch) == 0 {
|
|
||||||
return nil, fmt.Errorf("no messages found for peer %s with startID %d and limit %d", peer, startID, limit)
|
|
||||||
}
|
|
||||||
return messageBatch, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func GetMediaGroup(ctx *ext.Context, chatID int64, messageID int, groupID int64) ([]*tg.Message, error) {
|
|
||||||
peer := ctx.PeerStorage.GetInputPeerById(chatID)
|
|
||||||
if peer == nil {
|
|
||||||
return nil, fmt.Errorf("无法获取聊天 %d 的输入 Peer", chatID)
|
|
||||||
}
|
|
||||||
messages, err := GetHistoryMessages(ctx, ctx.Raw, peer, messageID-9, 20)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("获取消息失败: %s", err)
|
|
||||||
}
|
|
||||||
var groupMessages []*tg.Message
|
|
||||||
for _, msg := range messages {
|
|
||||||
gID, isGroup := msg.GetGroupedID()
|
|
||||||
if isGroup && gID == groupID {
|
|
||||||
groupMessages = append(groupMessages, msg)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(groupMessages) == 0 || (len(groupMessages) == 1 && groupMessages[0].ID == messageID) {
|
|
||||||
return nil, fmt.Errorf("未找到媒体组 %d 中的消息", groupID)
|
|
||||||
}
|
|
||||||
return groupMessages, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func GetInputPeerID(peer tg.InputPeerClass) int64 {
|
|
||||||
switch p := peer.(type) {
|
|
||||||
case *tg.InputPeerUser:
|
|
||||||
return p.UserID
|
|
||||||
case *tg.InputPeerChat:
|
|
||||||
return p.ChatID
|
|
||||||
case *tg.InputPeerChannel:
|
|
||||||
return p.ChannelID
|
|
||||||
}
|
|
||||||
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
func ProvideSelectMessage(ctx *ext.Context, update *ext.Update, fileName string, chatID int64, fileMsgID, toEditMsgID int) error {
|
|
||||||
entityBuilder := entity.Builder{}
|
|
||||||
var entities []tg.MessageEntityClass
|
|
||||||
text := fmt.Sprintf("文件名: %s\n请选择存储位置", fileName)
|
|
||||||
if err := styling.Perform(&entityBuilder,
|
|
||||||
styling.Plain("文件名: "),
|
|
||||||
styling.Code(fileName),
|
|
||||||
styling.Plain("\n请选择存储位置"),
|
|
||||||
); err != nil {
|
|
||||||
common.Log.Errorf("Failed to build entity: %s", err)
|
|
||||||
} else {
|
|
||||||
text, entities = entityBuilder.Complete()
|
|
||||||
}
|
|
||||||
markup, err := getSelectStorageMarkup(update.GetUserChat().GetID(), int(chatID), fileMsgID)
|
|
||||||
if errors.Is(err, ErrNoStorages) {
|
|
||||||
common.Log.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 {
|
|
||||||
common.Log.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 {
|
|
||||||
common.Log.Errorf("Failed to reply: %s", err)
|
|
||||||
}
|
|
||||||
return dispatcher.EndGroups
|
|
||||||
}
|
|
||||||
|
|
||||||
func HandleSilentAddTask(ctx *ext.Context, update *ext.Update, user *dao.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
|
|
||||||
}
|
|
||||||
|
|
||||||
func GenFileNameFromMessage(message tg.Message, file *types.File) string {
|
|
||||||
if file.FileName != "" {
|
|
||||||
return file.FileName
|
|
||||||
}
|
|
||||||
fileName := genFileNameFromMessageText(message, file)
|
|
||||||
media, ok := message.GetMedia()
|
|
||||||
if !ok {
|
|
||||||
return fileName
|
|
||||||
}
|
|
||||||
ext, ok := extraMediaExt(media)
|
|
||||||
if ok {
|
|
||||||
return fileName + ext
|
|
||||||
}
|
|
||||||
return fileName
|
|
||||||
}
|
|
||||||
|
|
||||||
func genFileNameFromMessageText(message tg.Message, file *types.File) string {
|
|
||||||
text := strings.TrimSpace(message.GetMessage())
|
|
||||||
if text == "" {
|
|
||||||
return file.Hash()
|
|
||||||
}
|
|
||||||
tags := common.ExtractTagsFromText(text)
|
|
||||||
if len(tags) > 0 {
|
|
||||||
return fmt.Sprintf("%s_%s", strings.Join(tags, "_"), strconv.Itoa(message.GetID()))
|
|
||||||
}
|
|
||||||
// 删除换行和特殊字符
|
|
||||||
text = strings.Map(func(r rune) rune {
|
|
||||||
if r == '\n' || r == '\r' || r == '\t' || r == ' ' {
|
|
||||||
return '_'
|
|
||||||
}
|
|
||||||
return r
|
|
||||||
}, text)
|
|
||||||
runes := []rune(text)
|
|
||||||
return string(runes[:min(128, len(runes))])
|
|
||||||
}
|
|
||||||
|
|
||||||
func extraMediaExt(media tg.MessageMediaClass) (string, bool) {
|
|
||||||
switch media := media.(type) {
|
|
||||||
case *tg.MessageMediaDocument:
|
|
||||||
doc, ok := media.Document.AsNotEmpty()
|
|
||||||
if !ok {
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
ext := mimetype.Lookup(doc.MimeType).Extension()
|
|
||||||
if ext == "" {
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
return ext, true
|
|
||||||
case *tg.MessageMediaPhoto:
|
|
||||||
return ".jpg", true
|
|
||||||
}
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
103
client/bot/bot.go
Normal file
103
client/bot/bot.go
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
package bot
|
||||||
|
|
||||||
|
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/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/ncruces/go-sqlite3/gormlite"
|
||||||
|
"golang.org/x/net/proxy"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Init(ctx context.Context) <-chan struct{} {
|
||||||
|
log.FromContext(ctx).Info("初始化 Bot...")
|
||||||
|
resultChan := make(chan struct {
|
||||||
|
client *gotgproto.Client
|
||||||
|
err error
|
||||||
|
})
|
||||||
|
shouldRestart := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
var resolver dcs.Resolver
|
||||||
|
if config.C().Telegram.Proxy.Enable && config.C().Telegram.Proxy.URL != "" {
|
||||||
|
dialer, err := netutil.NewProxyDialer(config.C().Telegram.Proxy.URL)
|
||||||
|
if err != nil {
|
||||||
|
resultChan <- struct {
|
||||||
|
client *gotgproto.Client
|
||||||
|
err error
|
||||||
|
}{nil, err}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resolver = dcs.Plain(dcs.PlainOptions{
|
||||||
|
Dial: dialer.(proxy.ContextDialer).DialContext,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
resolver = dcs.DefaultResolver()
|
||||||
|
}
|
||||||
|
client, err := gotgproto.NewClient(
|
||||||
|
config.C().Telegram.AppID,
|
||||||
|
config.C().Telegram.AppHash,
|
||||||
|
gotgproto.ClientTypeBot(config.C().Telegram.Token),
|
||||||
|
&gotgproto.ClientOpts{
|
||||||
|
Session: sessionMaker.SqlSession(gormlite.Open(config.C().DB.Session)),
|
||||||
|
DisableCopyright: true,
|
||||||
|
Middlewares: middleware.NewDefaultMiddlewares(ctx, 5*time.Minute),
|
||||||
|
Resolver: resolver,
|
||||||
|
Context: ctx,
|
||||||
|
MaxRetries: config.C().Telegram.RpcRetry,
|
||||||
|
AutoFetchReply: true,
|
||||||
|
ErrorHandler: func(ctx *ext.Context, u *ext.Update, s string) error {
|
||||||
|
if s == "SAVEANTBOT-RESTART" {
|
||||||
|
shouldRestart <- struct{}{}
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
log.FromContext(ctx).Errorf("unhandled error: %s", s)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
resultChan <- struct {
|
||||||
|
client *gotgproto.Client
|
||||||
|
err error
|
||||||
|
}{nil, err}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
client.API().BotsSetBotCommands(ctx, &tg.BotsSetBotCommandsRequest{
|
||||||
|
Scope: &tg.BotCommandScopeDefault{},
|
||||||
|
})
|
||||||
|
commands := make([]tg.BotCommand, 0, len(handlers.CommandHandlers))
|
||||||
|
for _, info := range handlers.CommandHandlers {
|
||||||
|
commands = append(commands, tg.BotCommand{Command: info.Cmd, Description: info.Desc})
|
||||||
|
}
|
||||||
|
_, err = client.API().BotsSetBotCommands(ctx, &tg.BotsSetBotCommandsRequest{
|
||||||
|
Scope: &tg.BotCommandScopeDefault{},
|
||||||
|
Commands: commands,
|
||||||
|
})
|
||||||
|
resultChan <- struct {
|
||||||
|
client *gotgproto.Client
|
||||||
|
err error
|
||||||
|
}{client, err}
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
log.FromContext(ctx).Errorf("已取消 Bot 初始化: %s", ctx.Err())
|
||||||
|
case result := <-resultChan:
|
||||||
|
if result.err != nil {
|
||||||
|
log.FromContext(ctx).Fatalf("初始化 Bot 失败: %s", result.err)
|
||||||
|
}
|
||||||
|
handlers.Register(result.client.Dispatcher)
|
||||||
|
log.FromContext(ctx).Info("Bot 初始化完成")
|
||||||
|
}
|
||||||
|
return shouldRestart
|
||||||
|
}
|
||||||
87
client/bot/handlers/add_task.go
Normal file
87
client/bot/handlers/add_task.go
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"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/shortcut"
|
||||||
|
"github.com/krau/SaveAny-Bot/common/utils/fsutil"
|
||||||
|
"github.com/krau/SaveAny-Bot/database"
|
||||||
|
"github.com/krau/SaveAny-Bot/pkg/enums/tasktype"
|
||||||
|
"github.com/krau/SaveAny-Bot/pkg/tcbdata"
|
||||||
|
"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.BuildSetDirMarkupForAdd(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.CreateAndAddtelegraphWithEdit(ctx, userID, data.TphPageNode, data.TphDirPath, data.TphPics, selectedStorage, msgID)
|
||||||
|
case tasktype.TaskTypeParseditem:
|
||||||
|
if len(data.ParsedItem.Resources) > 1 {
|
||||||
|
dirPath = path.Join(dirPath, fsutil.NormalizePathname(data.ParsedItem.Title))
|
||||||
|
}
|
||||||
|
shortcut.CreateAndAddParsedTaskWithEdit(ctx, selectedStorage, dirPath, data.ParsedItem, msgID, userID)
|
||||||
|
default:
|
||||||
|
log.FromContext(ctx).Errorf("Unsupported task type: %s", data.TaskType)
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
144
client/bot/handlers/config.go
Normal file
144
client/bot/handlers/config.go
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"text/template"
|
||||||
|
|
||||||
|
"github.com/celestix/gotgproto/dispatcher"
|
||||||
|
"github.com/celestix/gotgproto/ext"
|
||||||
|
"github.com/gotd/td/tg"
|
||||||
|
"github.com/krau/SaveAny-Bot/database"
|
||||||
|
"github.com/krau/SaveAny-Bot/pkg/enums/fnamest"
|
||||||
|
"github.com/krau/SaveAny-Bot/pkg/tcbdata"
|
||||||
|
)
|
||||||
|
|
||||||
|
func handleConfigCmd(ctx *ext.Context, update *ext.Update) error {
|
||||||
|
ctx.Reply(update, ext.ReplyTextString("请选择要配置的选项"), &ext.ReplyOpts{
|
||||||
|
Markup: &tg.ReplyInlineMarkup{
|
||||||
|
Rows: []tg.KeyboardButtonRow{
|
||||||
|
{
|
||||||
|
Buttons: []tg.KeyboardButtonClass{
|
||||||
|
&tg.KeyboardButtonCallback{
|
||||||
|
Text: "文件名策略",
|
||||||
|
Data: fmt.Appendf(nil, "%s %s", tcbdata.TypeConfig, "fnamest"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleConfigCallback(ctx *ext.Context, update *ext.Update) error {
|
||||||
|
args := strings.Fields(string(update.CallbackQuery.Data))
|
||||||
|
invaildDataAnswer := func() error {
|
||||||
|
ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{
|
||||||
|
QueryID: update.CallbackQuery.GetQueryID(),
|
||||||
|
Alert: true,
|
||||||
|
Message: "无效的回调数据",
|
||||||
|
CacheTime: 5,
|
||||||
|
})
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
if len(args) < 2 {
|
||||||
|
return invaildDataAnswer()
|
||||||
|
}
|
||||||
|
switch args[1] {
|
||||||
|
case "fnamest":
|
||||||
|
return handleConfigFnameSTCallback(ctx, update)
|
||||||
|
default:
|
||||||
|
return invaildDataAnswer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleConfigFnameSTCallback(ctx *ext.Context, update *ext.Update) error {
|
||||||
|
userID := update.CallbackQuery.GetUserID()
|
||||||
|
user, err := database.GetUserByChatID(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
args := strings.Fields(string(update.CallbackQuery.Data))
|
||||||
|
if len(args) == 3 {
|
||||||
|
selected := args[2]
|
||||||
|
st, err := fnamest.ParseFnameST(selected)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
user.FilenameStrategy = st.String()
|
||||||
|
if err := database.UpdateUser(ctx, user); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
|
||||||
|
ID: update.CallbackQuery.GetMsgID(),
|
||||||
|
Message: fmt.Sprintf("已将文件名策略设置为: %s", fnamest.FnameSTDisplay[st]),
|
||||||
|
})
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
opts := fnamest.FnameSTValues()
|
||||||
|
buttons := make([]tg.KeyboardButtonClass, 0, len(opts))
|
||||||
|
for _, opt := range opts {
|
||||||
|
buttons = append(buttons, &tg.KeyboardButtonCallback{
|
||||||
|
Text: fnamest.FnameSTDisplay[opt],
|
||||||
|
Data: fmt.Appendf(nil, "%s %s %s", tcbdata.TypeConfig, "fnamest", opt),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
markup := &tg.ReplyInlineMarkup{Rows: []tg.KeyboardButtonRow{
|
||||||
|
{Buttons: buttons},
|
||||||
|
}}
|
||||||
|
currentStStr := user.FilenameStrategy
|
||||||
|
if currentStStr == "" {
|
||||||
|
currentStStr = fnamest.Default.String()
|
||||||
|
}
|
||||||
|
currentSt, err := fnamest.ParseFnameST(currentStStr)
|
||||||
|
if err != nil {
|
||||||
|
currentSt = fnamest.Default
|
||||||
|
}
|
||||||
|
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
|
||||||
|
ID: update.CallbackQuery.GetMsgID(),
|
||||||
|
Message: fmt.Sprintf("请选择文件名策略, 当前策略: %s", fnamest.FnameSTDisplay[currentSt]),
|
||||||
|
ReplyMarkup: markup,
|
||||||
|
})
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleConfigFnameTmpl(ctx *ext.Context, update *ext.Update) error {
|
||||||
|
userID := update.GetUserChat().GetID()
|
||||||
|
user, err := database.GetUserByChatID(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
args := strings.Fields(string(update.EffectiveMessage.Text))
|
||||||
|
if len(args) <= 1 {
|
||||||
|
text := `使用该命令设置文件名模板, 示例:
|
||||||
|
/fnametmpl 图片_{{.msgid}}_{{.msgdate}}.jpg
|
||||||
|
|
||||||
|
可用变量:
|
||||||
|
- {{.msgid}}: 消息ID
|
||||||
|
- {{.msgtags}}: 消息中的标签, 将以下划线分隔输出
|
||||||
|
- {{.msggen}}: 根据消息生成的文件名
|
||||||
|
- {{.msgdate}}: 消息日期, 格式 YYYY-MM-DD_HH-MM-SS
|
||||||
|
- {{.origname}}: 媒体的原始文件名 (如果有)
|
||||||
|
- {{.chatid}}: 消息的聊天ID
|
||||||
|
`
|
||||||
|
if user.FilenameTemplate != "" {
|
||||||
|
text += fmt.Sprintf("\n\n当前模板: %s", user.FilenameTemplate)
|
||||||
|
}
|
||||||
|
text += "\n\n模板仅在文件名策略设置为 '自定义模板' 时生效, 且模板解析错误时会回退到默认文件名"
|
||||||
|
ctx.Reply(update, ext.ReplyTextString(text), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
newTmpl := strings.Join(args[1:], " ")
|
||||||
|
_, err = template.New("filename").Parse(newTmpl)
|
||||||
|
if err != nil {
|
||||||
|
ctx.Reply(update, ext.ReplyTextString("无效的模板, 请检查语法\n"+err.Error()), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
user.FilenameTemplate = newTmpl
|
||||||
|
if err := database.UpdateUser(ctx, user); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
ctx.Reply(update, ext.ReplyTextString("已更新文件名模板"), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
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
|
||||||
|
}
|
||||||
20
client/bot/handlers/help.go
Normal file
20
client/bot/handlers/help.go
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/celestix/gotgproto/dispatcher"
|
||||||
|
"github.com/celestix/gotgproto/ext"
|
||||||
|
"github.com/krau/SaveAny-Bot/common/i18n"
|
||||||
|
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
|
||||||
|
"github.com/krau/SaveAny-Bot/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func handleHelpCmd(ctx *ext.Context, update *ext.Update) error {
|
||||||
|
shortHash := config.GitCommit
|
||||||
|
if len(shortHash) > 7 {
|
||||||
|
shortHash = shortHash[:7]
|
||||||
|
}
|
||||||
|
ctx.Reply(update, ext.ReplyTextString(fmt.Sprintf(i18n.T(i18nk.BotMsgHelpTextFmt), config.Version, shortHash)), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
63
client/bot/handlers/link.go
Normal file
63
client/bot/handlers/link.go
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
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/dirutil"
|
||||||
|
"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, dirutil.PathFromContext(ctx), files[0], replied.ID)
|
||||||
|
}
|
||||||
|
return shortcut.CreateAndAddBatchTGFileTaskWithEdit(ctx, userId, stor, dirutil.PathFromContext(ctx), files, replied.ID)
|
||||||
|
}
|
||||||
70
client/bot/handlers/media.go
Normal file
70
client/bot/handlers/media.go
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/celestix/gotgproto/dispatcher"
|
||||||
|
"github.com/celestix/gotgproto/ext"
|
||||||
|
"github.com/charmbracelet/log"
|
||||||
|
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/dirutil"
|
||||||
|
"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/database"
|
||||||
|
"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())
|
||||||
|
userId := update.GetUserChat().GetID()
|
||||||
|
userDB, err := database.GetUserByChatID(ctx, userId)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tfOpts := mediautil.TfileOptions(ctx, userDB, message)
|
||||||
|
msg, file, err := shortcut.GetFileFromMessageWithReply(ctx, update, message, tfOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
stors := storage.GetUserStorages(ctx, userId)
|
||||||
|
req, err := msgelem.BuildAddOneSelectStorageMessage(ctx, stors, file, msg.ID)
|
||||||
|
if err != nil {
|
||||||
|
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()
|
||||||
|
userDB, err := database.GetUserByChatID(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tfOpts := mediautil.TfileOptions(ctx, userDB, message)
|
||||||
|
msg, file, err := shortcut.GetFileFromMessageWithReply(ctx, update, message, tfOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return shortcut.CreateAndAddTGFileTaskWithEdit(ctx, userID, stor, dirutil.PathFromContext(ctx), file, msg.ID)
|
||||||
|
}
|
||||||
126
client/bot/handlers/media_group.go
Normal file
126
client/bot/handlers/media_group.go
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
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/config"
|
||||||
|
"github.com/krau/SaveAny-Bot/pkg/tcbdata"
|
||||||
|
"github.com/krau/SaveAny-Bot/pkg/tfile"
|
||||||
|
"github.com/krau/SaveAny-Bot/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
type MediaGroupHandler struct {
|
||||||
|
groups map[int64][]tfile.TGFileMessage
|
||||||
|
timers map[int64]*time.Timer
|
||||||
|
mu sync.Mutex
|
||||||
|
timeout time.Duration
|
||||||
|
setupOnce sync.Once
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MediaGroupHandler) SetupTimeout(timeoutSec int) {
|
||||||
|
m.setupOnce.Do(func() {
|
||||||
|
if timeoutSec < 1 {
|
||||||
|
timeoutSec = 1
|
||||||
|
}
|
||||||
|
m.timeout = time.Duration(timeoutSec) * time.Second
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
mediaGroupHandler = &MediaGroupHandler{
|
||||||
|
groups: make(map[int64][]tfile.TGFileMessage),
|
||||||
|
timers: make(map[int64]*time.Timer),
|
||||||
|
mu: sync.Mutex{},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
func handleGroupMediaMessage(ctx *ext.Context, update *ext.Update, message *tg.Message, groupID int64) error {
|
||||||
|
mediaGroupHandler.SetupTimeout(max(config.C().Telegram.MediaGroupTimeout, 1))
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
}
|
||||||
58
client/bot/handlers/middleware.go
Normal file
58
client/bot/handlers/middleware.go
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
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/client/bot/handlers/utils/dirutil"
|
||||||
|
"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.C().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
|
||||||
|
}
|
||||||
|
if user.DefaultDir != 0 {
|
||||||
|
dir, err := database.GetDirByID(ctx, user.DefaultDir)
|
||||||
|
if err != nil {
|
||||||
|
ctx.Reply(update, ext.ReplyTextString("获取默认文件夹失败: "+err.Error()), nil)
|
||||||
|
return next(ctx, update)
|
||||||
|
}
|
||||||
|
ctx.Context = dirutil.WithContext(ctx.Context, dir)
|
||||||
|
}
|
||||||
|
ctx.Context = storage.WithContext(ctx.Context, stor)
|
||||||
|
return handler(ctx, update)
|
||||||
|
}
|
||||||
|
}
|
||||||
126
client/bot/handlers/parse.go
Normal file
126
client/bot/handlers/parse.go
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
// 处理任意文本消息, 用于通用地从外部源下载文件
|
||||||
|
|
||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"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/dirutil"
|
||||||
|
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/msgelem"
|
||||||
|
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/shortcut"
|
||||||
|
"github.com/krau/SaveAny-Bot/common/utils/fsutil"
|
||||||
|
"github.com/krau/SaveAny-Bot/common/utils/tgutil"
|
||||||
|
"github.com/krau/SaveAny-Bot/parsers"
|
||||||
|
"github.com/krau/SaveAny-Bot/pkg/enums/tasktype"
|
||||||
|
"github.com/krau/SaveAny-Bot/pkg/tcbdata"
|
||||||
|
"github.com/krau/SaveAny-Bot/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
func handleTextMessage(ctx *ext.Context, u *ext.Update) error {
|
||||||
|
logger := log.FromContext(ctx)
|
||||||
|
text := u.EffectiveMessage.Text
|
||||||
|
entityUrls := tgutil.ExtractMessageEntityUrls(u.EffectiveMessage.Message)
|
||||||
|
if len(entityUrls) > 0 {
|
||||||
|
text += "\n" + strings.Join(entityUrls, "\n")
|
||||||
|
}
|
||||||
|
ok, pser := parsers.CanHandle(text)
|
||||||
|
if !ok {
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
msg, err := ctx.Reply(u, ext.ReplyTextString("正在解析..."), nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
item, err := pser.Parse(ctx, text)
|
||||||
|
if errors.Is(err, parsers.ErrNoParserFound) {
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
logger.Error("Failed to parse text", "error", err)
|
||||||
|
ctx.Reply(u, ext.ReplyTextString("Failed to parse text: "+err.Error()), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
logger.Debug("Parsed item from text message", "title", item.Title, "url", item.URL)
|
||||||
|
userID := u.GetUserChat().GetID()
|
||||||
|
markup, err := msgelem.BuildAddSelectStorageKeyboard(storage.GetUserStorages(ctx, userID), tcbdata.Add{
|
||||||
|
TaskType: tasktype.TaskTypeParseditem,
|
||||||
|
ParsedItem: item,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("Failed to build storage selection keyboard: %s", err)
|
||||||
|
ctx.Reply(u, ext.ReplyTextString("Failed to build storage selection keyboard: "+err.Error()), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
text, entities, err := msgelem.BuildParsedTextEntity(*item)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("Failed to build parsed text entity: %s", err)
|
||||||
|
ctx.Reply(u, ext.ReplyTextString("Failed to build parsed text entity: "+err.Error()), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
|
||||||
|
Message: text,
|
||||||
|
ReplyMarkup: markup,
|
||||||
|
Entities: entities,
|
||||||
|
ID: msg.ID,
|
||||||
|
})
|
||||||
|
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleSilentSaveText(ctx *ext.Context, u *ext.Update) error {
|
||||||
|
logger := log.FromContext(ctx)
|
||||||
|
stor := storage.FromContext(ctx)
|
||||||
|
if stor == nil {
|
||||||
|
logger.Warn("Context storage is nil")
|
||||||
|
ctx.Reply(u, ext.ReplyTextString("未找到存储"), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
text := u.EffectiveMessage.Text
|
||||||
|
if text == "" {
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
item, err := parsers.ParseWithContext(ctx, text)
|
||||||
|
if errors.Is(err, parsers.ErrNoParserFound) {
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
logger.Error("Failed to parse text", "error", err)
|
||||||
|
ctx.Reply(u, ext.ReplyTextString("Failed to parse text: "+err.Error()), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
logger.Debug("Parsed item from text message", "title", item.Title, "url", item.URL)
|
||||||
|
userID := u.GetUserChat().GetID()
|
||||||
|
text, entities, err := msgelem.BuildParsedTextEntity(*item)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("Failed to build parsed text entity: %s", err)
|
||||||
|
ctx.Reply(u, ext.ReplyTextString("Failed to build parsed text entity: "+err.Error()), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
msg, err := ctx.SendMessage(userID, &tg.MessagesSendMessageRequest{
|
||||||
|
Message: text,
|
||||||
|
Entities: entities,
|
||||||
|
ReplyTo: &tg.InputReplyToMessage{
|
||||||
|
ReplyToMsgID: u.EffectiveMessage.ID,
|
||||||
|
ReplyToPeerID: u.GetUserChat().AsInputPeer(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("Failed to send message: %s", err)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
dirPath := ""
|
||||||
|
if len(item.Resources) > 1 {
|
||||||
|
dirPath = fsutil.NormalizePathname(item.Title)
|
||||||
|
}
|
||||||
|
if p := dirutil.PathFromContext(ctx); p != "" {
|
||||||
|
dirPath = path.Join(p, dirPath)
|
||||||
|
}
|
||||||
|
return shortcut.CreateAndAddParsedTaskWithEdit(ctx, stor, dirPath, item, msg.ID, userID)
|
||||||
|
}
|
||||||
97
client/bot/handlers/parser.go
Normal file
97
client/bot/handlers/parser.go
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/celestix/gotgproto/dispatcher"
|
||||||
|
"github.com/celestix/gotgproto/ext"
|
||||||
|
"github.com/gotd/td/tg"
|
||||||
|
"github.com/krau/SaveAny-Bot/config"
|
||||||
|
"github.com/krau/SaveAny-Bot/parsers"
|
||||||
|
)
|
||||||
|
|
||||||
|
func handleParserCmd(ctx *ext.Context, u *ext.Update) error {
|
||||||
|
args := strings.Split(u.EffectiveMessage.Text, " ")
|
||||||
|
help := `
|
||||||
|
用法:
|
||||||
|
|
||||||
|
/parser install <回复一个文件> - 安装解析器
|
||||||
|
`
|
||||||
|
if len(args) < 2 {
|
||||||
|
ctx.Reply(u, ext.ReplyTextString(help), nil)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
switch args[1] {
|
||||||
|
// case "list":
|
||||||
|
// return handleParserListCmd(ctx, u)
|
||||||
|
case "install":
|
||||||
|
return handleParserInstallCmd(ctx, u)
|
||||||
|
// case "uninstall":
|
||||||
|
// return handleParserUninstallCmd(ctx, u)
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleParserInstallCmd(ctx *ext.Context, u *ext.Update) error {
|
||||||
|
if !config.C().Parser.PluginEnable {
|
||||||
|
ctx.Reply(u, ext.ReplyTextString("解析器插件功能未启用"), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
if u.EffectiveMessage.ReplyToMessage == nil || u.EffectiveMessage.ReplyToMessage.Media == nil {
|
||||||
|
ctx.Reply(u, ext.ReplyTextString("请回复一个包含解析器文件的消息"), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
media := u.EffectiveMessage.ReplyToMessage.Media
|
||||||
|
document, ok := media.(*tg.MessageMediaDocument)
|
||||||
|
if !ok {
|
||||||
|
ctx.Reply(u, ext.ReplyTextString("回复的消息不包含有效的文件"), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
value, ok := document.GetDocument()
|
||||||
|
if !ok {
|
||||||
|
ctx.Reply(u, ext.ReplyTextString("回复的消息不包含有效的文件"), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
doc, ok := value.AsNotEmpty()
|
||||||
|
if !ok {
|
||||||
|
ctx.Reply(u, ext.ReplyTextString("回复的消息不包含有效的文件"), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(doc.MimeType, "text/") {
|
||||||
|
ctx.Reply(u, ext.ReplyTextString("错误的文件类型"), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
if doc.Size > 1024*1024*10 {
|
||||||
|
ctx.Reply(u, ext.ReplyTextString("文件过大"), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
var fileName string
|
||||||
|
for _, attr := range doc.Attributes {
|
||||||
|
if fileNameAttr, ok := attr.(*tg.DocumentAttributeFilename); ok {
|
||||||
|
fileName = fileNameAttr.FileName
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if fileName == "" {
|
||||||
|
ctx.Reply(u, ext.ReplyTextString("无法获取文件名"), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
if !strings.HasSuffix(fileName, ".js") {
|
||||||
|
ctx.Reply(u, ext.ReplyTextString("仅支持 .js 文件作为解析器"), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
data := bytes.NewBuffer(nil)
|
||||||
|
_, err := ctx.DownloadMedia(media, ext.DownloadOutputStream{Writer: data}, nil)
|
||||||
|
if err != nil {
|
||||||
|
ctx.Reply(u, ext.ReplyTextString("文件下载失败: "+err.Error()), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
if err := parsers.AddPlugin(ctx, data.String(), fileName); err != nil {
|
||||||
|
ctx.Reply(u, ext.ReplyTextString("插件安装失败: "+err.Error()), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
ctx.Reply(u, ext.ReplyTextString("插件安装成功: "+fileName), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
64
client/bot/handlers/register.go
Normal file
64
client/bot/handlers/register.go
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"regexp"
|
||||||
|
|
||||||
|
"github.com/celestix/gotgproto/dispatcher"
|
||||||
|
"github.com/celestix/gotgproto/dispatcher/handlers"
|
||||||
|
"github.com/celestix/gotgproto/dispatcher/handlers/filters"
|
||||||
|
"github.com/celestix/gotgproto/ext"
|
||||||
|
sabotfilters "github.com/krau/SaveAny-Bot/client/bot/handlers/utils/filters"
|
||||||
|
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/re"
|
||||||
|
userclient "github.com/krau/SaveAny-Bot/client/user"
|
||||||
|
"github.com/krau/SaveAny-Bot/config"
|
||||||
|
"github.com/krau/SaveAny-Bot/pkg/tcbdata"
|
||||||
|
)
|
||||||
|
|
||||||
|
type DescCommandHandler struct {
|
||||||
|
Cmd string
|
||||||
|
Desc string
|
||||||
|
handler func(ctx *ext.Context, u *ext.Update) error
|
||||||
|
}
|
||||||
|
|
||||||
|
var CommandHandlers = []DescCommandHandler{
|
||||||
|
{"start", "开始使用", handleHelpCmd},
|
||||||
|
{"silent", "切换静默模式", handleSilentCmd},
|
||||||
|
{"storage", "设置默认存储端", handleStorageCmd},
|
||||||
|
{"dir", "管理存储文件夹", handleDirCmd},
|
||||||
|
{"rule", "管理自动存储规则", handleRuleCmd},
|
||||||
|
{"watch", "监听聊天(UserBot)", handleWatchCmd},
|
||||||
|
{"unwatch", "取消监听聊天(UserBot)", handleUnwatchCmd},
|
||||||
|
{"lswatch", "列出监听的聊天(UserBot)", handleLswatchCmd},
|
||||||
|
{"save", "保存文件", handleSilentMode(handleSaveCmd, handleSilentSaveReplied)},
|
||||||
|
{"config", "修改配置", handleConfigCmd},
|
||||||
|
{"fnametmpl", "设置文件命名模板", handleConfigFnameTmpl},
|
||||||
|
{"update", "检查更新", handleUpdateCmd},
|
||||||
|
{"help", "显示帮助", handleHelpCmd},
|
||||||
|
{"parser", "管理解析器", handleParserCmd},
|
||||||
|
}
|
||||||
|
|
||||||
|
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))
|
||||||
|
for _, info := range CommandHandlers {
|
||||||
|
disp.AddHandler(handlers.NewCommand(info.Cmd, info.handler))
|
||||||
|
}
|
||||||
|
disp.AddHandler(handlers.NewCallbackQuery(filters.CallbackQuery.Prefix("update"), handleUpdateCallback))
|
||||||
|
disp.AddHandler(handlers.NewCallbackQuery(filters.CallbackQuery.Prefix(tcbdata.TypeAdd), handleAddCallback))
|
||||||
|
disp.AddHandler(handlers.NewCallbackQuery(filters.CallbackQuery.Prefix(tcbdata.TypeSetDefault), handleSetDefaultCallback))
|
||||||
|
disp.AddHandler(handlers.NewCallbackQuery(filters.CallbackQuery.Prefix(tcbdata.TypeCancel), handleCancelCallback))
|
||||||
|
disp.AddHandler(handlers.NewCallbackQuery(filters.CallbackQuery.Prefix(tcbdata.TypeConfig), handleConfigCallback))
|
||||||
|
disp.AddHandler(handlers.NewMessage(sabotfilters.RegexUrl(regexp.MustCompile(re.TgMessageLinkRegexString)), handleSilentMode(handleMessageLink, handleSilentSaveLink)))
|
||||||
|
disp.AddHandler(handlers.NewMessage(sabotfilters.RegexUrl(regexp.MustCompile(re.TelegraphUrlRegexString)), handleSilentMode(handleTelegraphUrlMessage, handleSilentSaveTelegraph)))
|
||||||
|
disp.AddHandler(handlers.NewMessage(filters.Message.Media, handleSilentMode(handleMediaMessage, handleSilentSaveMedia)))
|
||||||
|
disp.AddHandler(handlers.NewMessage(filters.Message.Text, handleSilentMode(handleTextMessage, handleSilentSaveText)))
|
||||||
|
|
||||||
|
if config.C().Telegram.Userbot.Enable {
|
||||||
|
go listenMediaMessageEvent(userclient.GetMediaMessageCh())
|
||||||
|
}
|
||||||
|
}
|
||||||
102
client/bot/handlers/rule.go
Normal file
102
client/bot/handlers/rule.go
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
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/common/utils/strutil"
|
||||||
|
"github.com/krau/SaveAny-Bot/database"
|
||||||
|
"github.com/krau/SaveAny-Bot/pkg/rule"
|
||||||
|
)
|
||||||
|
|
||||||
|
func handleRuleCmd(ctx *ext.Context, update *ext.Update) error {
|
||||||
|
logger := log.FromContext(ctx)
|
||||||
|
args := strutil.ParseArgsRespectQuotes(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
|
||||||
|
}
|
||||||
206
client/bot/handlers/save.go
Normal file
206
client/bot/handlers/save.go
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"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/dirutil"
|
||||||
|
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/mediautil"
|
||||||
|
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/msgelem"
|
||||||
|
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/shortcut"
|
||||||
|
"github.com/krau/SaveAny-Bot/common/i18n"
|
||||||
|
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
|
||||||
|
"github.com/krau/SaveAny-Bot/common/utils/strutil"
|
||||||
|
"github.com/krau/SaveAny-Bot/common/utils/tgutil"
|
||||||
|
"github.com/krau/SaveAny-Bot/database"
|
||||||
|
"github.com/krau/SaveAny-Bot/pkg/tcbdata"
|
||||||
|
"github.com/krau/SaveAny-Bot/pkg/tfile"
|
||||||
|
|
||||||
|
"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(i18n.T(i18nk.BotMsgSaveHelpText)), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
// genFilename := func() string {
|
||||||
|
// if len(args) > 1 {
|
||||||
|
// return args[1]
|
||||||
|
// }
|
||||||
|
// filename := tgutil.GenFileNameFromMessage(*replyTo.Message)
|
||||||
|
// return filename
|
||||||
|
// }()
|
||||||
|
// option := tfile.WithNameIfEmpty(genFilename)
|
||||||
|
// if len(args) > 1 {
|
||||||
|
// option = tfile.WithName(genFilename)
|
||||||
|
// }
|
||||||
|
userDB, err := database.GetUserByChatID(ctx, update.GetUserChat().GetID())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
opts := mediautil.TfileOptions(ctx, userDB, replyTo.Message)
|
||||||
|
if len(args) > 1 {
|
||||||
|
// custom filename via command arg
|
||||||
|
opts = append(opts, tfile.WithName(strings.Join(args[1:], " ")))
|
||||||
|
}
|
||||||
|
msg, file, err := shortcut.GetFileFromMessageWithReply(ctx, update, replyTo.Message, opts...)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
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(i18n.T(i18nk.BotMsgSaveHelpText)), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
userDB, err := database.GetUserByChatID(ctx, update.GetUserChat().GetID())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
opts := mediautil.TfileOptions(ctx, userDB, replyTo.Message)
|
||||||
|
if len(args) > 1 {
|
||||||
|
// custom filename via command arg
|
||||||
|
opts = append(opts, tfile.WithName(strings.Join(args[1:], " ")))
|
||||||
|
}
|
||||||
|
msg, file, err := shortcut.GetFileFromMessageWithReply(ctx, update, replyTo.Message, opts...)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return shortcut.CreateAndAddTGFileTaskWithEdit(ctx, update.GetUserChat().GetID(), stor, dirutil.PathFromContext(ctx), 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, _ := tgutil.GetMediaFileName(media)
|
||||||
|
sb.WriteString(fn)
|
||||||
|
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)
|
||||||
|
}
|
||||||
125
client/bot/handlers/silent.go
Normal file
125
client/bot/handlers/silent.go
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/celestix/gotgproto/dispatcher"
|
||||||
|
"github.com/celestix/gotgproto/ext"
|
||||||
|
"github.com/gotd/td/tg"
|
||||||
|
"github.com/krau/SaveAny-Bot/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)
|
||||||
|
|
||||||
|
failedAnswer := func(message string) error {
|
||||||
|
ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{
|
||||||
|
QueryID: update.CallbackQuery.GetQueryID(),
|
||||||
|
Alert: true,
|
||||||
|
Message: message,
|
||||||
|
CacheTime: 5,
|
||||||
|
})
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
|
||||||
|
if !ok {
|
||||||
|
return failedAnswer("数据已过期")
|
||||||
|
}
|
||||||
|
userID := update.CallbackQuery.GetUserID()
|
||||||
|
|
||||||
|
storageName := data.StorageName
|
||||||
|
selectedStorage, err := storage.GetStorageByUserIDAndName(ctx, userID, storageName)
|
||||||
|
if err != nil {
|
||||||
|
return failedAnswer("存储获取失败: " + err.Error())
|
||||||
|
}
|
||||||
|
user, err := database.GetUserByChatID(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
return failedAnswer("获取用户信息失败: " + err.Error())
|
||||||
|
}
|
||||||
|
var dir *database.Dir
|
||||||
|
if data.DirID != 0 {
|
||||||
|
// 已经选择了文件夹
|
||||||
|
var err error
|
||||||
|
dir, err = database.GetDirByID(ctx, data.DirID)
|
||||||
|
if err != nil {
|
||||||
|
return failedAnswer("获取文件夹信息失败: " + err.Error())
|
||||||
|
}
|
||||||
|
user.DefaultDir = dir.ID
|
||||||
|
} else {
|
||||||
|
// 检查是否有可用的文件夹
|
||||||
|
dirs, err := database.GetDirsByUserIDAndStorageName(ctx, user.ID, storageName)
|
||||||
|
if err != nil {
|
||||||
|
return failedAnswer("获取目录失败: " + err.Error())
|
||||||
|
}
|
||||||
|
if len(dirs) > 0 {
|
||||||
|
// 要求选择文件夹
|
||||||
|
markup, err := msgelem.BuildSetDefaultDirMarkup(ctx, storageName, dirs)
|
||||||
|
if err != nil {
|
||||||
|
return failedAnswer("构建目录选择失败: " + err.Error())
|
||||||
|
}
|
||||||
|
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
|
||||||
|
ID: update.CallbackQuery.GetMsgID(),
|
||||||
|
Message: "请选择要保存到的默认文件夹",
|
||||||
|
ReplyMarkup: markup,
|
||||||
|
})
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
}
|
||||||
|
user.DefaultStorage = selectedStorage.Name()
|
||||||
|
if err := database.UpdateUser(ctx, user); err != nil {
|
||||||
|
return failedAnswer("更新用户信息失败: " + err.Error())
|
||||||
|
}
|
||||||
|
msg := fmt.Sprintf("已将默认存储位置设为: %s", selectedStorage.Name())
|
||||||
|
if dir != nil {
|
||||||
|
msg += fmt.Sprintf(":/%s", strings.TrimPrefix(dir.Path, "/"))
|
||||||
|
}
|
||||||
|
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
|
||||||
|
ID: update.CallbackQuery.GetMsgID(),
|
||||||
|
Message: msg,
|
||||||
|
})
|
||||||
|
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, 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
|
||||||
|
}
|
||||||
82
client/bot/handlers/telegraph.go
Normal file
82
client/bot/handlers/telegraph.go
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"path"
|
||||||
|
|
||||||
|
"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/dirutil"
|
||||||
|
"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()
|
||||||
|
dirpath := result.TphDir
|
||||||
|
if p := dirutil.PathFromContext(ctx); p != "" {
|
||||||
|
dirpath = path.Join(p, dirpath)
|
||||||
|
}
|
||||||
|
return shortcut.CreateAndAddtelegraphWithEdit(ctx, userID, result.Page, dirpath, result.Pics, stor, msg.ID)
|
||||||
|
|
||||||
|
}
|
||||||
102
client/bot/handlers/update.go
Normal file
102
client/bot/handlers/update.go
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/blang/semver"
|
||||||
|
"github.com/celestix/gotgproto/dispatcher"
|
||||||
|
"github.com/celestix/gotgproto/ext"
|
||||||
|
"github.com/gotd/td/telegram/message/html"
|
||||||
|
"github.com/gotd/td/tg"
|
||||||
|
"github.com/krau/SaveAny-Bot/config"
|
||||||
|
"github.com/rhysd/go-github-selfupdate/selfupdate"
|
||||||
|
)
|
||||||
|
|
||||||
|
func handleUpdateCmd(ctx *ext.Context, u *ext.Update) error {
|
||||||
|
currentV, err := semver.Parse(config.Version)
|
||||||
|
if err != nil {
|
||||||
|
ctx.Reply(u, ext.ReplyTextString(fmt.Sprintf("You are in dev or the version var failed to inject: %v", err)), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
latest, ok, err := selfupdate.DetectLatest(config.GitRepo)
|
||||||
|
if err != nil {
|
||||||
|
ctx.Reply(u, ext.ReplyTextString(fmt.Sprintf("检测最新版本失败: %v", err)), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
ctx.Reply(u, ext.ReplyTextString("没有找到版本信息"), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
if latest.Version.LT(currentV) || latest.Version.Equals(currentV) {
|
||||||
|
ctx.Reply(u, ext.ReplyTextString(fmt.Sprintf("当前已经是最新版本: %s", config.Version)), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
ctx.Sender.To(u.GetUserChat().AsInputPeer()).StyledText(ctx, html.String(nil, func() string {
|
||||||
|
md := latest.ReleaseNotes
|
||||||
|
md = regexp.MustCompile(`(?m)^###\s+ (.+)$`).ReplaceAllString(md, "<b>$1</b>")
|
||||||
|
md = regexp.MustCompile(`(?m)^#####\s+ (.+)$`).ReplaceAllString(md, "<i>$1</i>")
|
||||||
|
|
||||||
|
md = regexp.MustCompile(`(?m)^- `).ReplaceAllString(md, "• ")
|
||||||
|
|
||||||
|
md = regexp.MustCompile(`\[\((\w{6,})\)\]\((https?://[^\s)]+)\)`).ReplaceAllString(md, `(<a href="$2">$1</a>)`)
|
||||||
|
|
||||||
|
md = regexp.MustCompile(`\[(.+?)\]\((https?://[^\s)]+)\)`).ReplaceAllString(md, `<a href="$2">$1</a>`)
|
||||||
|
|
||||||
|
md = strings.ReplaceAll(md, " ", " ")
|
||||||
|
|
||||||
|
return `<blockquote expandable>` + md + `</blockquote>`
|
||||||
|
}()))
|
||||||
|
text := fmt.Sprintf(`发现新版本: %s
|
||||||
|
当前版本: %s
|
||||||
|
|
||||||
|
文件大小: %.2f MB
|
||||||
|
下载链接: %s
|
||||||
|
发布时间: %s
|
||||||
|
|
||||||
|
升级将重启 Bot , 是否升级?`, latest.Version, config.Version,
|
||||||
|
float64(latest.AssetByteSize)/(1024*1024), latest.AssetURL,
|
||||||
|
latest.PublishedAt.Format("2006-01-02 15:04:05"),
|
||||||
|
)
|
||||||
|
ctx.Reply(u, ext.ReplyTextString(text), &ext.ReplyOpts{
|
||||||
|
Markup: &tg.ReplyInlineMarkup{
|
||||||
|
Rows: []tg.KeyboardButtonRow{
|
||||||
|
{
|
||||||
|
Buttons: []tg.KeyboardButtonClass{
|
||||||
|
&tg.KeyboardButtonCallback{
|
||||||
|
Text: "升级",
|
||||||
|
Data: []byte("update"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleUpdateCallback(ctx *ext.Context, u *ext.Update) error {
|
||||||
|
currentV, err := semver.Parse(config.Version)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
ctx.EditMessage(u.GetUserChat().GetID(), &tg.MessagesEditMessageRequest{
|
||||||
|
ID: u.CallbackQuery.GetMsgID(),
|
||||||
|
Message: fmt.Sprintf("正在升级中, 当前版本: %s", config.Version),
|
||||||
|
})
|
||||||
|
latest, err := selfupdate.UpdateSelf(currentV, config.GitRepo)
|
||||||
|
if err != nil {
|
||||||
|
ctx.EditMessage(u.GetUserChat().GetID(), &tg.MessagesEditMessageRequest{
|
||||||
|
ID: u.CallbackQuery.GetMsgID(),
|
||||||
|
Message: fmt.Sprintf("升级失败: %v", err),
|
||||||
|
})
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
ctx.EditMessage(u.GetUserChat().GetID(), &tg.MessagesEditMessageRequest{
|
||||||
|
ID: u.CallbackQuery.GetMsgID(),
|
||||||
|
Message: fmt.Sprintf("已升级至版本 %s\n若 Bot 未自动重启请手动启动", latest.Version),
|
||||||
|
})
|
||||||
|
return errors.New("SAVEANTBOT-RESTART")
|
||||||
|
}
|
||||||
37
client/bot/handlers/utils/dirutil/context.go
Normal file
37
client/bot/handlers/utils/dirutil/context.go
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
package dirutil
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/krau/SaveAny-Bot/database"
|
||||||
|
)
|
||||||
|
|
||||||
|
type contextKey struct{}
|
||||||
|
|
||||||
|
var dirContextKey = contextKey{}
|
||||||
|
|
||||||
|
func WithContext(ctx context.Context, dir *database.Dir) context.Context {
|
||||||
|
if dir == nil {
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
|
return context.WithValue(ctx, dirContextKey, dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
func FromContext(ctx context.Context) *database.Dir {
|
||||||
|
dir, ok := ctx.Value(dirContextKey).(*database.Dir)
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return dir
|
||||||
|
}
|
||||||
|
|
||||||
|
// PathFromContext returns the directory path stored in the context.
|
||||||
|
//
|
||||||
|
// If no directory is found, an empty string is returned.
|
||||||
|
func PathFromContext(ctx context.Context) string {
|
||||||
|
dir := FromContext(ctx)
|
||||||
|
if dir == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return dir.Path
|
||||||
|
}
|
||||||
26
client/bot/handlers/utils/filters/url.go
Normal file
26
client/bot/handlers/utils/filters/url.go
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
package filters
|
||||||
|
|
||||||
|
import (
|
||||||
|
"regexp"
|
||||||
|
"slices"
|
||||||
|
|
||||||
|
"github.com/celestix/gotgproto/dispatcher/handlers/filters"
|
||||||
|
"github.com/celestix/gotgproto/types"
|
||||||
|
"github.com/krau/SaveAny-Bot/common/utils/tgutil"
|
||||||
|
)
|
||||||
|
|
||||||
|
func RegexUrl(r *regexp.Regexp) filters.MessageFilter {
|
||||||
|
return func(m *types.Message) bool {
|
||||||
|
if m.Text == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if r.MatchString(m.Text) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
urls := tgutil.ExtractMessageEntityUrls(m.Message)
|
||||||
|
if len(urls) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return slices.ContainsFunc(urls, r.MatchString)
|
||||||
|
}
|
||||||
|
}
|
||||||
141
client/bot/handlers/utils/mediautil/media.go
Normal file
141
client/bot/handlers/utils/mediautil/media.go
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
package mediautil
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"text/template"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/log"
|
||||||
|
"github.com/gotd/td/tg"
|
||||||
|
"github.com/krau/SaveAny-Bot/common/utils/strutil"
|
||||||
|
"github.com/krau/SaveAny-Bot/common/utils/tgutil"
|
||||||
|
"github.com/krau/SaveAny-Bot/database"
|
||||||
|
"github.com/krau/SaveAny-Bot/pkg/enums/fnamest"
|
||||||
|
"github.com/krau/SaveAny-Bot/pkg/tfile"
|
||||||
|
)
|
||||||
|
|
||||||
|
func IsSupported(media tg.MessageMediaClass) bool {
|
||||||
|
switch media.(type) {
|
||||||
|
case *tg.MessageMediaDocument, *tg.MessageMediaPhoto:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type FilenameTemplateData struct {
|
||||||
|
MsgID string `json:"msgid,omitempty"`
|
||||||
|
MsgTags string `json:"msgtags,omitempty"`
|
||||||
|
MsgGen string `json:"msggen,omitempty"`
|
||||||
|
MsgDate string `json:"msgdate,omitempty"`
|
||||||
|
OrigName string `json:"origname,omitempty"`
|
||||||
|
ChatID string `json:"chatid,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f FilenameTemplateData) ToMap() map[string]string {
|
||||||
|
return map[string]string{
|
||||||
|
"msgid": f.MsgID,
|
||||||
|
"msgtags": f.MsgTags,
|
||||||
|
"msggen": f.MsgGen,
|
||||||
|
"msgdate": f.MsgDate,
|
||||||
|
"origname": f.OrigName,
|
||||||
|
"chatid": f.ChatID,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TfileOptions(ctx context.Context, user *database.User, message *tg.Message) []tfile.TGFileOption {
|
||||||
|
opts := make([]tfile.TGFileOption, 0)
|
||||||
|
var fnameOpt tfile.TGFileOption
|
||||||
|
switch user.FilenameStrategy {
|
||||||
|
case fnamest.Message.String():
|
||||||
|
fnameOpt = tfile.WithName(tgutil.GenFileNameFromMessage(*message))
|
||||||
|
case fnamest.Template.String():
|
||||||
|
if user.FilenameTemplate == "" {
|
||||||
|
log.FromContext(ctx).Warnf("empty filename template")
|
||||||
|
fnameOpt = tfile.WithNameIfEmpty(tgutil.GenFileNameFromMessage(*message))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
tmpl, err := template.New("filename").Parse(user.FilenameTemplate)
|
||||||
|
if err != nil {
|
||||||
|
log.FromContext(ctx).Errorf("failed to parse filename template: %s", err)
|
||||||
|
fnameOpt = tfile.WithNameIfEmpty(tgutil.GenFileNameFromMessage(*message))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
data := BuildFilenameTemplateData(message)
|
||||||
|
var sb strings.Builder
|
||||||
|
err = tmpl.Execute(&sb, data)
|
||||||
|
if err != nil {
|
||||||
|
log.FromContext(ctx).Errorf("failed to execute filename template: %s", err)
|
||||||
|
fnameOpt = tfile.WithNameIfEmpty(tgutil.GenFileNameFromMessage(*message))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
fnameOpt = tfile.WithName(sb.String())
|
||||||
|
default:
|
||||||
|
fnameOpt = tfile.WithNameIfEmpty(tgutil.GenFileNameFromMessage(*message))
|
||||||
|
}
|
||||||
|
opts = append(opts, fnameOpt, tfile.WithMessage(message))
|
||||||
|
return opts
|
||||||
|
}
|
||||||
|
|
||||||
|
func BuildFilenameTemplateData(message *tg.Message) map[string]string {
|
||||||
|
data := FilenameTemplateData{
|
||||||
|
MsgID: func() string {
|
||||||
|
id := message.GetID()
|
||||||
|
if id == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%d", id)
|
||||||
|
}(),
|
||||||
|
MsgTags: func() string {
|
||||||
|
tags := strutil.ExtractTagsFromText(message.GetMessage())
|
||||||
|
if len(tags) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.Join(tags, "_")
|
||||||
|
}(),
|
||||||
|
MsgGen: tgutil.GenFileNameFromMessage(*message),
|
||||||
|
OrigName: func() string {
|
||||||
|
f, _ := tgutil.GetMediaFileName(message.Media)
|
||||||
|
return f
|
||||||
|
}(),
|
||||||
|
MsgDate: func() string {
|
||||||
|
date := message.GetDate()
|
||||||
|
if date == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
t := time.Unix(int64(date), 0)
|
||||||
|
return t.Format("2006-01-02_15-04-05")
|
||||||
|
}(),
|
||||||
|
ChatID: func() string {
|
||||||
|
// 如果消息是频道的(从消息链接中fetch的) 直接使用其chat id, 无论它是否是从其他来源转发的
|
||||||
|
if message.GetPost() {
|
||||||
|
peer := message.GetPeerID()
|
||||||
|
switch p := peer.(type) {
|
||||||
|
case *tg.PeerChannel:
|
||||||
|
return intToStringOmitZero(p.ChannelID)
|
||||||
|
default: // impossible case
|
||||||
|
return intToStringOmitZero(tgutil.ChatIdFromPeer(peer))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fwdHeader, ok := message.GetFwdFrom()
|
||||||
|
if !ok {
|
||||||
|
return intToStringOmitZero(tgutil.ChatIdFromPeer(message.GetPeerID()))
|
||||||
|
}
|
||||||
|
fwdFrom, ok := fwdHeader.GetFromID()
|
||||||
|
if !ok {
|
||||||
|
return intToStringOmitZero(tgutil.ChatIdFromPeer(message.GetPeerID()))
|
||||||
|
}
|
||||||
|
return intToStringOmitZero(tgutil.ChatIdFromPeer(fwdFrom))
|
||||||
|
}(),
|
||||||
|
}.ToMap()
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
func intToStringOmitZero(i int64) string {
|
||||||
|
if i == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%d", i)
|
||||||
|
}
|
||||||
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),
|
||||||
|
}
|
||||||
|
}
|
||||||
39
client/bot/handlers/utils/msgelem/parse.go
Normal file
39
client/bot/handlers/utils/msgelem/parse.go
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
package msgelem
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/duke-git/lancet/v2/strutil"
|
||||||
|
"github.com/gotd/td/telegram/message/entity"
|
||||||
|
"github.com/gotd/td/telegram/message/styling"
|
||||||
|
"github.com/gotd/td/tg"
|
||||||
|
"github.com/krau/SaveAny-Bot/pkg/parser"
|
||||||
|
)
|
||||||
|
|
||||||
|
func BuildParsedTextEntity(item parser.Item) (string, []tg.MessageEntityClass, error) {
|
||||||
|
eb := entity.Builder{}
|
||||||
|
if err := styling.Perform(&eb,
|
||||||
|
styling.Bold(fmt.Sprintf("[%s]%s", item.Site, item.Title)),
|
||||||
|
styling.Plain("\n链接: "),
|
||||||
|
styling.Code(item.URL),
|
||||||
|
styling.Plain("\n作者: "),
|
||||||
|
styling.Code(item.Author),
|
||||||
|
styling.Plain("\n描述: "),
|
||||||
|
styling.Code(strutil.Ellipsis(item.Description, 233)),
|
||||||
|
styling.Plain("\n文件数量: "),
|
||||||
|
styling.Code(fmt.Sprintf("%d", len(item.Resources))),
|
||||||
|
styling.Plain("\n预计总大小: "),
|
||||||
|
styling.Code(fmt.Sprintf("%.2f MB", func() float64 {
|
||||||
|
var totalSize int64
|
||||||
|
for _, res := range item.Resources {
|
||||||
|
totalSize += res.Size
|
||||||
|
}
|
||||||
|
return float64(totalSize) / 1024 / 1024
|
||||||
|
}())),
|
||||||
|
styling.Plain("\n请选择存储位置"),
|
||||||
|
); err != nil {
|
||||||
|
return "", nil, fmt.Errorf("构建消息失败: %w", err)
|
||||||
|
}
|
||||||
|
text, entities := eb.Complete()
|
||||||
|
return text, entities, nil
|
||||||
|
}
|
||||||
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),
|
||||||
|
}
|
||||||
|
}
|
||||||
196
client/bot/handlers/utils/msgelem/storage.go
Normal file
196
client/bot/handlers/utils/msgelem/storage.go
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
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 if adddata.ParsedItem != nil {
|
||||||
|
taskType = tasktype.TaskTypeParseditem
|
||||||
|
} 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,
|
||||||
|
|
||||||
|
ParsedItem: adddata.ParsedItem,
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// Builds the inline keyboard for setting default storage
|
||||||
|
func BuildSetDefaultStorageMarkup(
|
||||||
|
ctx context.Context,
|
||||||
|
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 BuildSetDefaultDirMarkup(ctx context.Context,
|
||||||
|
seletedStorage string,
|
||||||
|
dirs []database.Dir) (*tg.ReplyInlineMarkup, error) {
|
||||||
|
buttons := make([]tg.KeyboardButtonClass, 0)
|
||||||
|
for _, dir := range dirs {
|
||||||
|
dataid := xid.New().String()
|
||||||
|
data := tcbdata.SetDefaultStorage{
|
||||||
|
StorageName: seletedStorage,
|
||||||
|
DirID: dir.ID,
|
||||||
|
}
|
||||||
|
err := cache.Set(dataid, data)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
buttons = append(buttons, &tg.KeyboardButtonCallback{
|
||||||
|
Text: dir.Path,
|
||||||
|
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 BuildSetDirMarkupForAdd(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)
|
||||||
|
)
|
||||||
113
client/bot/handlers/utils/ruleutil/rule.go
Normal file
113
client/bot/handlers/utils/ruleutil/rule.go
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
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/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 != rule.RuleStorNameChosen
|
||||||
|
}
|
||||||
|
|
||||||
|
type MatchedDirPath string
|
||||||
|
|
||||||
|
func (m MatchedDirPath) String() string {
|
||||||
|
return string(m)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m MatchedDirPath) NeedNewForAlbum() bool {
|
||||||
|
return m != "" && m == rule.RuleDirPathNewForAlbum
|
||||||
|
}
|
||||||
|
|
||||||
|
func ApplyRule(ctx context.Context, rules []database.Rule, inputs *ruleInput) (matched bool, matchedStorageName matchedStorName, dirPath MatchedDirPath) {
|
||||||
|
if inputs == nil || len(rules) == 0 {
|
||||||
|
return false, "", ""
|
||||||
|
}
|
||||||
|
logger := log.FromContext(ctx)
|
||||||
|
for _, ur := range rules {
|
||||||
|
switch ur.Type {
|
||||||
|
case rule.FileNameRegex.String():
|
||||||
|
ru, err := rule.NewRuleFileNameRegex(ur.StorageName, ur.DirPath, ur.Data)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("Failed to create rule: %s", err)
|
||||||
|
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 rule.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 rule.IsAlbum.String():
|
||||||
|
matchAlbum, err := convertor.ToBool(ur.Data)
|
||||||
|
if err != nil {
|
||||||
|
matchAlbum = false
|
||||||
|
}
|
||||||
|
ru, err := rule.NewRuleMediaType(ur.StorageName, ur.DirPath, matchAlbum)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("Failed to create rule: %s", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ok, err := ru.Match(inputs.File.Message().GroupedID != 0)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("Failed to match rule: %s", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if ok {
|
||||||
|
dirPath = MatchedDirPath(ru.StoragePath())
|
||||||
|
matchedStorageName = matchedStorName(ru.StorageName())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if matchedStorageName != "" || dirPath != "" {
|
||||||
|
return true, matchedStorageName, dirPath
|
||||||
|
}
|
||||||
|
return false, "", ""
|
||||||
|
}
|
||||||
238
client/bot/handlers/utils/shortcut/message.go
Normal file
238
client/bot/handlers/utils/shortcut/message.go
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
// 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/telegram/downloader"
|
||||||
|
"github.com/gotd/td/tg"
|
||||||
|
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/mediautil"
|
||||||
|
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/msgelem"
|
||||||
|
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/re"
|
||||||
|
uc "github.com/krau/SaveAny-Bot/client/user"
|
||||||
|
"github.com/krau/SaveAny-Bot/common/cache"
|
||||||
|
"github.com/krau/SaveAny-Bot/common/utils/tgutil"
|
||||||
|
"github.com/krau/SaveAny-Bot/common/utils/tphutil"
|
||||||
|
"github.com/krau/SaveAny-Bot/config"
|
||||||
|
"github.com/krau/SaveAny-Bot/database"
|
||||||
|
"github.com/krau/SaveAny-Bot/pkg/telegraph"
|
||||||
|
"github.com/krau/SaveAny-Bot/pkg/tfile"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 获取消息中的文件并回复等待消息, 返回等待消息, 获取到的文件
|
||||||
|
func GetFileFromMessageWithReply(ctx *ext.Context, update *ext.Update, message *tg.Message, tfileopts ...tfile.TGFileOption) (replied *types.Message,
|
||||||
|
file tfile.TGFileMessage, err error,
|
||||||
|
) {
|
||||||
|
logger := log.FromContext(ctx)
|
||||||
|
media := message.Media
|
||||||
|
supported := mediautil.IsSupported(media)
|
||||||
|
if !supported {
|
||||||
|
return nil, nil, dispatcher.ContinueGroups
|
||||||
|
}
|
||||||
|
|
||||||
|
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.TGFileOption{
|
||||||
|
// tfile.WithMessage(message),
|
||||||
|
// }
|
||||||
|
// if len(tfileopts) > 0 {
|
||||||
|
// options = append(options, tfileopts...)
|
||||||
|
// } else {
|
||||||
|
// options = append(options, tfile.WithNameIfEmpty(tgutil.GenFileNameFromMessage(*message)))
|
||||||
|
// }
|
||||||
|
file, err = tfile.FromMediaMessage(media, ctx.Raw, message, tfileopts...)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("Failed to get file from media: %s", err)
|
||||||
|
ctx.Reply(update, ext.ReplyTextString("获取文件失败: "+err.Error()), nil)
|
||||||
|
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(tgutil.ExtractMessageEntityUrlsText(update.EffectiveMessage.Message), -1)
|
||||||
|
if len(msgLinks) == 0 {
|
||||||
|
logger.Warn("no matched message links but called handleMessageLink")
|
||||||
|
return nil, nil, nil, dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
user, err := database.GetUserByChatID(ctx, update.GetUserChat().GetID())
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("failed to get user from db: %s", err)
|
||||||
|
editReplied("获取用户信息失败: "+err.Error(), nil)
|
||||||
|
return nil, nil, nil, dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
files = make([]tfile.TGFileMessage, 0, len(msgLinks))
|
||||||
|
addFile := func(client downloader.Client, 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
|
||||||
|
}
|
||||||
|
opts := mediautil.TfileOptions(ctx, user, msg)
|
||||||
|
file, err := tfile.FromMediaMessage(media, client, msg, opts...)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("failed to create file from media: %s", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
files = append(files, file)
|
||||||
|
}
|
||||||
|
|
||||||
|
tctx := ctx
|
||||||
|
if config.C().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(tctx, 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(tgutil.ExtractMessageEntityUrlsText(update.EffectiveMessage.Message))
|
||||||
|
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
|
||||||
|
}
|
||||||
|
tphdir = strings.TrimSpace(tphdir)
|
||||||
|
msg, err := ctx.Reply(update, ext.ReplyTextString("正在获取 telegraph 页面..."), nil)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("Failed to reply to update: %s", err)
|
||||||
|
return nil, nil, dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
logger.Debugf("Fetching telegraph page: %s", pagepath)
|
||||||
|
page, err := tphutil.DefaultClient().GetPage(ctx, pagepath)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("Failed to get telegraph page: %s", err)
|
||||||
|
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 {
|
||||||
|
if strings.HasPrefix(src, "/file/") {
|
||||||
|
// handle images on telegra.ph server
|
||||||
|
src = "https://telegra.ph" + src
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
35
client/bot/handlers/utils/shortcut/parsed.go
Normal file
35
client/bot/handlers/utils/shortcut/parsed.go
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
package shortcut
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/celestix/gotgproto/dispatcher"
|
||||||
|
"github.com/celestix/gotgproto/ext"
|
||||||
|
"github.com/charmbracelet/log"
|
||||||
|
"github.com/gotd/td/tg"
|
||||||
|
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/msgelem"
|
||||||
|
"github.com/krau/SaveAny-Bot/common/utils/tgutil"
|
||||||
|
"github.com/krau/SaveAny-Bot/core"
|
||||||
|
"github.com/krau/SaveAny-Bot/core/tasks/parsed"
|
||||||
|
"github.com/krau/SaveAny-Bot/pkg/parser"
|
||||||
|
"github.com/krau/SaveAny-Bot/storage"
|
||||||
|
"github.com/rs/xid"
|
||||||
|
)
|
||||||
|
|
||||||
|
func CreateAndAddParsedTaskWithEdit(ctx *ext.Context, stor storage.Storage, dirPath string, item *parser.Item, msgID int, userID int64) error {
|
||||||
|
injectCtx := tgutil.ExtWithContext(ctx.Context, ctx)
|
||||||
|
task := parsed.NewTask(xid.New().String(), injectCtx, stor, stor.JoinStoragePath(dirPath), item, parsed.NewProgress(msgID, userID))
|
||||||
|
if err := core.AddTask(injectCtx, task); err != nil {
|
||||||
|
log.FromContext(ctx).Errorf("Failed to add task: %s", err)
|
||||||
|
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
|
||||||
|
ID: msgID,
|
||||||
|
Message: "任务添加失败: " + err.Error(),
|
||||||
|
})
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
text, entities := msgelem.BuildTaskAddedEntities(ctx, item.Title, core.GetLength(ctx))
|
||||||
|
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
|
||||||
|
ID: msgID,
|
||||||
|
Message: text,
|
||||||
|
Entities: entities,
|
||||||
|
})
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
207
client/bot/handlers/utils/shortcut/tftask.go
Normal file
207
client/bot/handlers/utils/shortcut/tftask.go
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
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/tasks/batchtfile"
|
||||||
|
tftask "github.com/krau/SaveAny-Bot/core/tasks/tfile"
|
||||||
|
"github.com/krau/SaveAny-Bot/database"
|
||||||
|
"github.com/krau/SaveAny-Bot/pkg/tfile"
|
||||||
|
"github.com/krau/SaveAny-Bot/storage"
|
||||||
|
"github.com/rs/xid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 创建一个 tfile.TGFileTask 并添加到任务队列中, 以编辑消息的方式反馈结果
|
||||||
|
func CreateAndAddTGFileTaskWithEdit(ctx *ext.Context, userID int64, stor storage.Storage, dirPath string, file tfile.TGFileMessage, trackMsgID int) error {
|
||||||
|
logger := log.FromContext(ctx)
|
||||||
|
user, err := database.GetUserByChatID(ctx, userID)
|
||||||
|
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 {
|
||||||
|
matched, matchedStorageName, matchedDirPath := ruleutil.ApplyRule(ctx, user.Rules, ruleutil.NewInput(file))
|
||||||
|
if !matched {
|
||||||
|
goto startCreateTask
|
||||||
|
}
|
||||||
|
if matchedDirPath != "" {
|
||||||
|
dirPath = matchedDirPath.String()
|
||||||
|
}
|
||||||
|
if matchedStorageName.IsUsable() {
|
||||||
|
stor, err = storage.GetStorageByUserIDAndName(ctx, user.ChatID, matchedStorageName.String())
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("Failed to get storage by user ID and name: %s", err)
|
||||||
|
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
|
||||||
|
ID: trackMsgID,
|
||||||
|
Message: "获取存储失败: " + err.Error(),
|
||||||
|
})
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
startCreateTask:
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建一个 batchtfile.BatchTGFileTask 并添加到任务队列中, 以编辑消息的方式反馈结果
|
||||||
|
func CreateAndAddBatchTGFileTaskWithEdit(ctx *ext.Context, userID int64, stor storage.Storage, dirPath string, files []tfile.TGFileMessage, trackMsgID int) error {
|
||||||
|
logger := log.FromContext(ctx)
|
||||||
|
user, err := database.GetUserByChatID(ctx, userID)
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
matched, storName, dirP := ruleutil.ApplyRule(ctx, user.Rules, ruleutil.NewInput(file))
|
||||||
|
if !matched {
|
||||||
|
return stor.Name(), ruleutil.MatchedDirPath(dirPath)
|
||||||
|
}
|
||||||
|
storname := storName.String()
|
||||||
|
if !storName.IsUsable() {
|
||||||
|
storname = stor.Name()
|
||||||
|
}
|
||||||
|
return storname, dirP
|
||||||
|
}
|
||||||
|
|
||||||
|
elems := make([]batchtfile.TaskElement, 0, len(files))
|
||||||
|
type albumFile struct {
|
||||||
|
file tfile.TGFileMessage
|
||||||
|
storage storage.Storage
|
||||||
|
}
|
||||||
|
albumFiles := make(map[int64][]albumFile, 0)
|
||||||
|
for _, file := range files {
|
||||||
|
storName, dirPath := applyRule(file)
|
||||||
|
fileStor := stor
|
||||||
|
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 := batchtfile.NewTaskElement(fileStor, storPath, file)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("Failed to create task element: %s", err)
|
||||||
|
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
|
||||||
|
ID: trackMsgID,
|
||||||
|
Message: "任务创建失败: " + err.Error(),
|
||||||
|
})
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
elems = append(elems, *elem)
|
||||||
|
} else {
|
||||||
|
groupId, isGroup := file.Message().GetGroupedID()
|
||||||
|
if !isGroup || groupId == 0 {
|
||||||
|
logger.Warnf("File %s is not in a group, skipping album handling", file.Name())
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := albumFiles[groupId]; !ok {
|
||||||
|
albumFiles[groupId] = make([]albumFile, 0)
|
||||||
|
}
|
||||||
|
albumFiles[groupId] = append(albumFiles[groupId], albumFile{
|
||||||
|
file: file,
|
||||||
|
storage: fileStor,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, afiles := range albumFiles {
|
||||||
|
if len(afiles) <= 1 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// 对于需要新建目录的文件, 将第一个文件的文件名(去除扩展名)作为目录名
|
||||||
|
// 存储以第一个文件的存储为准
|
||||||
|
albumDir := strings.TrimSuffix(path.Base(afiles[0].file.Name()), path.Ext(afiles[0].file.Name()))
|
||||||
|
albumStor := afiles[0].storage
|
||||||
|
for _, af := range afiles {
|
||||||
|
afstorPath := af.storage.JoinStoragePath(path.Join(dirPath, albumDir, af.file.Name()))
|
||||||
|
elem, err := batchtfile.NewTaskElement(albumStor, afstorPath, af.file)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("Failed to create task element for album file: %s", err)
|
||||||
|
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
|
||||||
|
ID: trackMsgID,
|
||||||
|
Message: "任务创建失败: " + err.Error(),
|
||||||
|
})
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
elems = append(elems, *elem)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
injectCtx := tgutil.ExtWithContext(ctx.Context, ctx)
|
||||||
|
taskid := xid.New().String()
|
||||||
|
task := batchtfile.NewBatchTGFileTask(taskid, injectCtx, elems, batchtfile.NewProgressTracker(trackMsgID, userID), true)
|
||||||
|
if err := core.AddTask(injectCtx, task); err != nil {
|
||||||
|
logger.Errorf("Failed to add batch task: %s", err)
|
||||||
|
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
|
||||||
|
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
|
||||||
|
}
|
||||||
52
client/bot/handlers/utils/shortcut/tphtask.go
Normal file
52
client/bot/handlers/utils/shortcut/tphtask.go
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
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"
|
||||||
|
tphtask "github.com/krau/SaveAny-Bot/core/tasks/telegraph"
|
||||||
|
"github.com/krau/SaveAny-Bot/pkg/telegraph"
|
||||||
|
"github.com/krau/SaveAny-Bot/storage"
|
||||||
|
"github.com/rs/xid"
|
||||||
|
)
|
||||||
|
|
||||||
|
func CreateAndAddtelegraphWithEdit(
|
||||||
|
ctx *ext.Context,
|
||||||
|
userID int64,
|
||||||
|
tphpage *telegraph.Page,
|
||||||
|
dirPath string, // unescaped ph path for file storage
|
||||||
|
pics []string,
|
||||||
|
stor storage.Storage,
|
||||||
|
trackMsgID int) error {
|
||||||
|
|
||||||
|
injectCtx := tgutil.ExtWithContext(ctx.Context, ctx)
|
||||||
|
task := tphtask.NewTask(xid.New().String(),
|
||||||
|
injectCtx,
|
||||||
|
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
|
||||||
|
}
|
||||||
255
client/bot/handlers/watch.go
Normal file
255
client/bot/handlers/watch.go
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"path"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"text/template"
|
||||||
|
|
||||||
|
"github.com/celestix/gotgproto/dispatcher"
|
||||||
|
"github.com/celestix/gotgproto/ext"
|
||||||
|
"github.com/charmbracelet/log"
|
||||||
|
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/mediautil"
|
||||||
|
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/ruleutil"
|
||||||
|
userclient "github.com/krau/SaveAny-Bot/client/user"
|
||||||
|
"github.com/krau/SaveAny-Bot/common/i18n"
|
||||||
|
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
|
||||||
|
"github.com/krau/SaveAny-Bot/common/utils/tgutil"
|
||||||
|
"github.com/krau/SaveAny-Bot/core"
|
||||||
|
"github.com/krau/SaveAny-Bot/core/tasks/tfile"
|
||||||
|
"github.com/krau/SaveAny-Bot/database"
|
||||||
|
"github.com/krau/SaveAny-Bot/pkg/enums/fnamest"
|
||||||
|
"github.com/krau/SaveAny-Bot/storage"
|
||||||
|
"github.com/rs/xid"
|
||||||
|
)
|
||||||
|
|
||||||
|
func handleWatchCmd(ctx *ext.Context, update *ext.Update) error {
|
||||||
|
logger := log.FromContext(ctx)
|
||||||
|
args := strings.Split(update.EffectiveMessage.Text, " ")
|
||||||
|
if len(args) < 2 {
|
||||||
|
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgWatchHelpText)), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
userChatID := update.GetUserChat().GetID()
|
||||||
|
user, err := database.GetUserByChatID(ctx, userChatID)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("获取用户失败: %s", err)
|
||||||
|
ctx.Reply(update, ext.ReplyTextString("获取用户失败"), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
if user.DefaultStorage == "" {
|
||||||
|
ctx.Reply(update, ext.ReplyTextString("请先设置默认存储, 使用 /storage 命令"), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
chatArg := args[1]
|
||||||
|
chatID, err := tgutil.ParseChatID(ctx, chatArg)
|
||||||
|
if err != nil {
|
||||||
|
ctx.Reply(update, ext.ReplyTextString("无效的ID或用户名: "+err.Error()), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
watching, err := user.WatchingChat(ctx, chatID)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("Failed to check if user is watching chat %d: %s", chatID, err)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
if watching {
|
||||||
|
ctx.Reply(update, ext.ReplyTextString("已经在监听此聊天"), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
filter := ""
|
||||||
|
if len(args) > 2 {
|
||||||
|
filterArg := strings.Join(args[2:], " ")
|
||||||
|
filterType := strings.Split(filterArg, ":")[0]
|
||||||
|
filterData := strings.Split(filterArg, ":")[1]
|
||||||
|
if filterType == "" || filterData == "" {
|
||||||
|
ctx.Reply(update, ext.ReplyTextString("过滤器格式错误, 请使用 <过滤器类型>:<表达式>"), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
switch filterType {
|
||||||
|
case "msgre":
|
||||||
|
_, err := regexp.Compile(filterData)
|
||||||
|
if err != nil {
|
||||||
|
ctx.Reply(update, ext.ReplyTextString("正则表达式格式错误: "+err.Error()), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
filter = filterType + ":" + filterData
|
||||||
|
default:
|
||||||
|
ctx.Reply(update, ext.ReplyTextString("不支持的过滤器类型, 请参阅文档"), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := user.WatchChat(ctx, database.WatchChat{
|
||||||
|
UserID: user.ID,
|
||||||
|
ChatID: chatID,
|
||||||
|
Filter: filter,
|
||||||
|
}); err != nil {
|
||||||
|
logger.Errorf("Failed to watch chat %d: %s", chatID, err)
|
||||||
|
ctx.Reply(update, ext.ReplyTextString("监听聊天失败: "+err.Error()), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
ctx.Reply(update, ext.ReplyTextString("已开始监听聊天: "+chatArg), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleLswatchCmd(ctx *ext.Context, update *ext.Update) error {
|
||||||
|
logger := log.FromContext(ctx)
|
||||||
|
userChatID := update.GetUserChat().GetID()
|
||||||
|
user, err := database.GetUserByChatID(ctx, userChatID)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("获取用户失败: %s", err)
|
||||||
|
ctx.Reply(update, ext.ReplyTextString("获取用户失败"), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
chats := user.WatchChats
|
||||||
|
if len(chats) == 0 {
|
||||||
|
ctx.Reply(update, ext.ReplyTextString("当前没有监听任何聊天"), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString("当前监听的聊天:\n")
|
||||||
|
for _, chat := range chats {
|
||||||
|
sb.WriteString("- ")
|
||||||
|
sb.WriteString(fmt.Sprintf("%d", chat.ChatID))
|
||||||
|
if chat.Filter != "" {
|
||||||
|
sb.WriteString(" (过滤器: ")
|
||||||
|
sb.WriteString(chat.Filter)
|
||||||
|
sb.WriteString(")")
|
||||||
|
}
|
||||||
|
sb.WriteString("\n")
|
||||||
|
}
|
||||||
|
ctx.Reply(update, ext.ReplyTextString(sb.String()), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleUnwatchCmd(ctx *ext.Context, update *ext.Update) error {
|
||||||
|
logger := log.FromContext(ctx)
|
||||||
|
args := strings.Split(update.EffectiveMessage.Text, " ")
|
||||||
|
if len(args) < 2 {
|
||||||
|
ctx.Reply(update, ext.ReplyTextString("请提供要取消监听的聊天ID或用户名"), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
userChatID := update.GetUserChat().GetID()
|
||||||
|
user, err := database.GetUserByChatID(ctx, userChatID)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("获取用户失败: %s", err)
|
||||||
|
ctx.Reply(update, ext.ReplyTextString("获取用户失败"), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
chatArg := args[1]
|
||||||
|
chatID, err := tgutil.ParseChatID(ctx, chatArg)
|
||||||
|
if err != nil {
|
||||||
|
ctx.Reply(update, ext.ReplyTextString("无效的ID或用户名: "+err.Error()), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
if err := user.UnwatchChat(ctx, chatID); err != nil {
|
||||||
|
logger.Errorf("Failed to unwatch chat %d: %s", chatID, err)
|
||||||
|
ctx.Reply(update, ext.ReplyTextString("取消监听聊天失败: "+err.Error()), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
ctx.Reply(update, ext.ReplyTextString("已取消监听聊天: "+chatArg), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
|
||||||
|
func listenMediaMessageEvent(ch chan userclient.MediaMessageEvent) {
|
||||||
|
logger := log.FromContext(userclient.GetCtx())
|
||||||
|
for event := range ch {
|
||||||
|
logger.Debug("Received media message event", "chat_id", event.ChatID, "file_name", event.File.Name())
|
||||||
|
ctx := event.Ctx
|
||||||
|
file := event.File
|
||||||
|
chats, err := database.GetWatchChatsByChatID(ctx, event.ChatID)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("Failed to get watch chats for chat ID %d: %v", event.ChatID, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
msgText := event.File.Message().GetMessage()
|
||||||
|
for _, chat := range chats {
|
||||||
|
if chat.Filter != "" {
|
||||||
|
filter := strings.Split(chat.Filter, ":")
|
||||||
|
if len(filter) != 2 {
|
||||||
|
logger.Warnf("Invalid filter format in chat %d, skipping", chat.ChatID)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
filterType := filter[0]
|
||||||
|
filterData := filter[1]
|
||||||
|
switch filterType {
|
||||||
|
case "msgre": // [TODO] enums for filter types
|
||||||
|
if ok, err := regexp.MatchString(filterData, msgText); err != nil {
|
||||||
|
continue
|
||||||
|
} else if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
logger.Warnf("Unsupported filter type %s in chat %d, skipping", filterType, chat.ChatID)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
user, err := database.GetUserByID(ctx, chat.UserID)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("Failed to get user by ID %d: %v", chat.UserID, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if user.DefaultStorage == "" {
|
||||||
|
logger.Warnf("User %d has no default storage set, skipping media message handling", chat.UserID)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
stor, err := storage.GetStorageByUserIDAndName(ctx, user.ChatID, user.DefaultStorage)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("Failed to get storage by user ID %d and name %s: %v", user.ChatID, user.DefaultStorage, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch user.FilenameStrategy {
|
||||||
|
case fnamest.Message.String():
|
||||||
|
file.SetName(tgutil.GenFileNameFromMessage(*file.Message()))
|
||||||
|
case fnamest.Template.String():
|
||||||
|
if user.FilenameTemplate == "" {
|
||||||
|
logger.Warnf("Empty filename template for user %d, using default filename", user.ChatID)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
message := file.Message()
|
||||||
|
tmpl, err := template.New("filename").Parse(user.FilenameTemplate)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("Failed to parse filename template for user %d: %s", user.ChatID, err)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
data := mediautil.BuildFilenameTemplateData(message)
|
||||||
|
var sb strings.Builder
|
||||||
|
err = tmpl.Execute(&sb, data)
|
||||||
|
if err != nil {
|
||||||
|
log.FromContext(ctx).Errorf("failed to execute filename template: %s", err)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
file.SetName(sb.String())
|
||||||
|
}
|
||||||
|
var dirPath string
|
||||||
|
if user.ApplyRule && user.Rules != nil {
|
||||||
|
matched, matchedStorageName, matchedDirPath := ruleutil.ApplyRule(ctx, user.Rules, ruleutil.NewInput(file))
|
||||||
|
if !matched {
|
||||||
|
goto startCreateTask
|
||||||
|
}
|
||||||
|
dirPath = matchedDirPath.String()
|
||||||
|
if matchedStorageName.IsUsable() {
|
||||||
|
stor, err = storage.GetStorageByUserIDAndName(ctx, user.ChatID, matchedStorageName.String())
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("Failed to get storage by user ID and name: %s", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
startCreateTask:
|
||||||
|
storagePath := stor.JoinStoragePath(path.Join(dirPath, file.Name()))
|
||||||
|
injectCtx := tgutil.ExtWithContext(ctx.Context, ctx)
|
||||||
|
taskid := xid.New().String()
|
||||||
|
task, err := tfile.NewTGFileTask(taskid, injectCtx, file, stor, storagePath, nil)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("create task failed: %s", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := core.AddTask(injectCtx, task); err != nil {
|
||||||
|
logger.Errorf("add task failed: %s", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
logger.Infof("Added media message task for user %d in chat %d: %s", chat.UserID, event.ChatID, file.Name())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package middlewares
|
package middleware
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
@@ -7,21 +7,22 @@ import (
|
|||||||
"github.com/cenkalti/backoff/v4"
|
"github.com/cenkalti/backoff/v4"
|
||||||
"github.com/gotd/contrib/middleware/floodwait"
|
"github.com/gotd/contrib/middleware/floodwait"
|
||||||
"github.com/gotd/td/telegram"
|
"github.com/gotd/td/telegram"
|
||||||
"github.com/krau/SaveAny-Bot/userclient/middlewares/recovery"
|
"github.com/krau/SaveAny-Bot/client/middleware/recovery"
|
||||||
"github.com/krau/SaveAny-Bot/userclient/middlewares/retry"
|
"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 {
|
func NewDefaultMiddlewares(ctx context.Context, timeout time.Duration) []telegram.Middleware {
|
||||||
return []telegram.Middleware{
|
return []telegram.Middleware{
|
||||||
recovery.New(ctx, newBackoff(timeout)),
|
recovery.New(ctx, newBackoff(timeout)),
|
||||||
retry.New(5),
|
retry.New(config.C().Telegram.RpcRetry),
|
||||||
floodwait.NewSimpleWaiter(),
|
floodwait.NewSimpleWaiter(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func newBackoff(timeout time.Duration) backoff.BackOff {
|
func newBackoff(timeout time.Duration) backoff.BackOff {
|
||||||
b := backoff.NewExponentialBackOff()
|
b := backoff.NewExponentialBackOff()
|
||||||
|
|
||||||
b.Multiplier = 1.1
|
b.Multiplier = 1.1
|
||||||
b.MaxElapsedTime = timeout
|
b.MaxElapsedTime = timeout
|
||||||
b.MaxInterval = 10 * time.Second
|
b.MaxInterval = 10 * time.Second
|
||||||
19
client/middleware/floodwait.go
Normal file
19
client/middleware/floodwait.go
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gotd/contrib/middleware/floodwait"
|
||||||
|
"github.com/gotd/contrib/middleware/ratelimit"
|
||||||
|
"github.com/gotd/td/telegram"
|
||||||
|
"golang.org/x/time/rate"
|
||||||
|
)
|
||||||
|
|
||||||
|
func NewFloodWaitMiddlewares(maxRetries uint) []telegram.Middleware {
|
||||||
|
waiter := floodwait.NewSimpleWaiter().WithMaxRetries(maxRetries)
|
||||||
|
ratelimiter := ratelimit.New(rate.Every(time.Millisecond*100), 5)
|
||||||
|
return []telegram.Middleware{
|
||||||
|
waiter,
|
||||||
|
ratelimiter,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,15 +2,15 @@ package recovery
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/cenkalti/backoff/v4"
|
"github.com/cenkalti/backoff/v4"
|
||||||
"github.com/go-faster/errors"
|
"github.com/charmbracelet/log"
|
||||||
"github.com/gotd/td/bin"
|
"github.com/gotd/td/bin"
|
||||||
"github.com/gotd/td/telegram"
|
"github.com/gotd/td/telegram"
|
||||||
"github.com/gotd/td/tg"
|
"github.com/gotd/td/tg"
|
||||||
"github.com/gotd/td/tgerr"
|
"github.com/gotd/td/tgerr"
|
||||||
"github.com/krau/SaveAny-Bot/common"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type recovery struct {
|
type recovery struct {
|
||||||
@@ -31,7 +31,7 @@ func (r *recovery) Handle(next tg.Invoker) telegram.InvokeFunc {
|
|||||||
return backoff.RetryNotify(func() error {
|
return backoff.RetryNotify(func() error {
|
||||||
if err := next.Invoke(ctx, input, output); err != nil {
|
if err := next.Invoke(ctx, input, output); err != nil {
|
||||||
if r.shouldRecover(ctx, err) {
|
if r.shouldRecover(ctx, err) {
|
||||||
return errors.Wrap(err, "recover")
|
return fmt.Errorf("recovery: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return backoff.Permanent(err)
|
return backoff.Permanent(err)
|
||||||
@@ -39,7 +39,7 @@ func (r *recovery) Handle(next tg.Invoker) telegram.InvokeFunc {
|
|||||||
|
|
||||||
return nil
|
return nil
|
||||||
}, r.backoff, func(err error, duration time.Duration) {
|
}, r.backoff, func(err error, duration time.Duration) {
|
||||||
common.Log.Debug("Wait for connection recovery", "error", err, "duration", duration)
|
log.FromContext(ctx).Debug("Wait for connection recovery", "error", err, "duration", duration)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4,12 +4,11 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"github.com/go-faster/errors"
|
"github.com/charmbracelet/log"
|
||||||
"github.com/gotd/td/bin"
|
"github.com/gotd/td/bin"
|
||||||
"github.com/gotd/td/telegram"
|
"github.com/gotd/td/telegram"
|
||||||
"github.com/gotd/td/tg"
|
"github.com/gotd/td/tg"
|
||||||
"github.com/gotd/td/tgerr"
|
"github.com/gotd/td/tgerr"
|
||||||
"github.com/krau/SaveAny-Bot/common"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var internalErrors = []string{
|
var internalErrors = []string{
|
||||||
@@ -33,11 +32,12 @@ func (r retry) Handle(next tg.Invoker) telegram.InvokeFunc {
|
|||||||
for retries < r.max {
|
for retries < r.max {
|
||||||
if err := next.Invoke(ctx, input, output); err != nil {
|
if err := next.Invoke(ctx, input, output); err != nil {
|
||||||
if tgerr.Is(err, r.errors...) {
|
if tgerr.Is(err, r.errors...) {
|
||||||
common.Log.Debug("retry middleware", "retries", retries, "error", err)
|
log.FromContext(ctx).Debug("retry middleware", "retries", retries, "error", err)
|
||||||
retries++
|
retries++
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
return errors.Wrap(err, "retry middleware skip")
|
// retry middleware skip
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package userclient
|
package user
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"strings"
|
"strings"
|
||||||
@@ -9,9 +9,9 @@ import (
|
|||||||
"github.com/fatih/color"
|
"github.com/fatih/color"
|
||||||
)
|
)
|
||||||
|
|
||||||
type termialAuthConversator struct{}
|
type terminalAuthConversator struct{}
|
||||||
|
|
||||||
func (t *termialAuthConversator) AskPhoneNumber() (string, error) {
|
func (t *terminalAuthConversator) AskPhoneNumber() (string, error) {
|
||||||
phone := ""
|
phone := ""
|
||||||
err := huh.NewInput().Title("Your Phone Number").
|
err := huh.NewInput().Title("Your Phone Number").
|
||||||
Placeholder("+44 123456").
|
Placeholder("+44 123456").
|
||||||
@@ -29,7 +29,7 @@ func (t *termialAuthConversator) AskPhoneNumber() (string, error) {
|
|||||||
return strings.TrimSpace(phone), nil
|
return strings.TrimSpace(phone), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *termialAuthConversator) AskCode() (string, error) {
|
func (t *terminalAuthConversator) AskCode() (string, error) {
|
||||||
code := ""
|
code := ""
|
||||||
err := huh.NewInput().Title("Your Code").
|
err := huh.NewInput().Title("Your Code").
|
||||||
Placeholder("123456").
|
Placeholder("123456").
|
||||||
@@ -45,7 +45,7 @@ func (t *termialAuthConversator) AskCode() (string, error) {
|
|||||||
return strings.TrimSpace(code), nil
|
return strings.TrimSpace(code), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *termialAuthConversator) AskPassword() (string, error) {
|
func (t *terminalAuthConversator) AskPassword() (string, error) {
|
||||||
pwd := ""
|
pwd := ""
|
||||||
|
|
||||||
err := huh.NewInput().Title("Your 2FA Password").
|
err := huh.NewInput().Title("Your 2FA Password").
|
||||||
@@ -61,7 +61,7 @@ func (t *termialAuthConversator) AskPassword() (string, error) {
|
|||||||
return strings.TrimSpace(pwd), nil
|
return strings.TrimSpace(pwd), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *termialAuthConversator) AuthStatus(authStatus gotgproto.AuthStatus) {
|
func (t *terminalAuthConversator) AuthStatus(authStatus gotgproto.AuthStatus) {
|
||||||
switch authStatus.Event {
|
switch authStatus.Event {
|
||||||
case gotgproto.AuthStatusPhoneRetrial:
|
case gotgproto.AuthStatusPhoneRetrial:
|
||||||
color.Red("The phone number you just entered seems to be incorrect,")
|
color.Red("The phone number you just entered seems to be incorrect,")
|
||||||
130
client/user/userclient.go
Normal file
130
client/user/userclient.go
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
package user
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/celestix/gotgproto"
|
||||||
|
"github.com/celestix/gotgproto/dispatcher"
|
||||||
|
"github.com/celestix/gotgproto/dispatcher/handlers"
|
||||||
|
"github.com/celestix/gotgproto/dispatcher/handlers/filters"
|
||||||
|
"github.com/celestix/gotgproto/ext"
|
||||||
|
"github.com/celestix/gotgproto/sessionMaker"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/log"
|
||||||
|
"github.com/gotd/td/telegram/dcs"
|
||||||
|
"github.com/gotd/td/tg"
|
||||||
|
"github.com/krau/SaveAny-Bot/client/middleware"
|
||||||
|
"github.com/krau/SaveAny-Bot/common/utils/netutil"
|
||||||
|
"github.com/krau/SaveAny-Bot/config"
|
||||||
|
"github.com/krau/SaveAny-Bot/database"
|
||||||
|
"github.com/ncruces/go-sqlite3/gormlite"
|
||||||
|
"golang.org/x/net/proxy"
|
||||||
|
)
|
||||||
|
|
||||||
|
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.C().Telegram.Proxy.Enable && config.C().Telegram.Proxy.URL != "" {
|
||||||
|
dialer, err := netutil.NewProxyDialer(config.C().Telegram.Proxy.URL)
|
||||||
|
if err != nil {
|
||||||
|
res <- struct {
|
||||||
|
client *gotgproto.Client
|
||||||
|
err error
|
||||||
|
}{nil, err}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resolver = dcs.Plain(dcs.PlainOptions{
|
||||||
|
Dial: dialer.(proxy.ContextDialer).DialContext,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
resolver = dcs.DefaultResolver()
|
||||||
|
}
|
||||||
|
tclient, err := gotgproto.NewClient(
|
||||||
|
config.C().Telegram.AppID,
|
||||||
|
config.C().Telegram.AppHash,
|
||||||
|
gotgproto.ClientTypePhone(""),
|
||||||
|
&gotgproto.ClientOpts{
|
||||||
|
Session: sessionMaker.SqlSession(gormlite.Open(config.C().Telegram.Userbot.Session)),
|
||||||
|
AuthConversator: &terminalAuthConversator{},
|
||||||
|
Context: ctx,
|
||||||
|
DisableCopyright: true,
|
||||||
|
Resolver: resolver,
|
||||||
|
MaxRetries: config.C().Telegram.RpcRetry,
|
||||||
|
AutoFetchReply: true,
|
||||||
|
Middlewares: middleware.NewDefaultMiddlewares(ctx, 5*time.Minute),
|
||||||
|
ErrorHandler: func(ctx *ext.Context, u *ext.Update, s string) error {
|
||||||
|
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
|
||||||
|
uc.Dispatcher.AddHandler(handlers.NewMessage(filters.Message.Media, func(ctx *ext.Context, u *ext.Update) error {
|
||||||
|
switch u.UpdateClass.(type) {
|
||||||
|
case *tg.UpdateEditChannelMessage, *tg.UpdateEditMessage, *tg.UpdateDeleteChannelMessages, *tg.UpdateDeleteMessages:
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
chatId := u.EffectiveChat().GetID()
|
||||||
|
watchChats, err := database.GetWatchChatsByChatID(ctx, chatId)
|
||||||
|
if err != nil || len(watchChats) == 0 {
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
return dispatcher.ContinueGroups
|
||||||
|
}))
|
||||||
|
uc.Dispatcher.AddHandler(handlers.NewMessage(filters.Message.Media, handleMediaMessage))
|
||||||
|
log.FromContext(ctx).Infof("User client logged in successfully: %s", uc.Self.FirstName+" "+uc.Self.LastName)
|
||||||
|
return uc, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
100
client/user/watch.go
Normal file
100
client/user/watch.go
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
package user
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/celestix/gotgproto/dispatcher"
|
||||||
|
"github.com/celestix/gotgproto/ext"
|
||||||
|
"github.com/gotd/td/tg"
|
||||||
|
"github.com/krau/SaveAny-Bot/common/utils/tgutil"
|
||||||
|
"github.com/krau/SaveAny-Bot/pkg/tfile"
|
||||||
|
)
|
||||||
|
|
||||||
|
type MediaMessageEvent struct {
|
||||||
|
Ctx *ext.Context
|
||||||
|
ChatID int64 // from witch the media message was sent
|
||||||
|
MessageID int
|
||||||
|
File tfile.TGFileMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
type messageKey struct {
|
||||||
|
ChatID int64
|
||||||
|
MessageID int
|
||||||
|
}
|
||||||
|
|
||||||
|
type MediaMessageHandler struct {
|
||||||
|
events map[messageKey]MediaMessageEvent
|
||||||
|
timers map[messageKey]*time.Timer
|
||||||
|
mu sync.Mutex
|
||||||
|
debounce time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
mediaMessageCh = make(chan MediaMessageEvent, 100)
|
||||||
|
mediaMessageHandler = &MediaMessageHandler{
|
||||||
|
events: make(map[messageKey]MediaMessageEvent),
|
||||||
|
timers: make(map[messageKey]*time.Timer),
|
||||||
|
debounce: 5 * time.Second,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
func GetMediaMessageCh() chan MediaMessageEvent {
|
||||||
|
return mediaMessageCh
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendMediaMessageEvent(event MediaMessageEvent) {
|
||||||
|
key := messageKey{ChatID: event.ChatID, MessageID: event.MessageID}
|
||||||
|
|
||||||
|
mediaMessageHandler.mu.Lock()
|
||||||
|
defer mediaMessageHandler.mu.Unlock()
|
||||||
|
|
||||||
|
if timer, exists := mediaMessageHandler.timers[key]; exists {
|
||||||
|
timer.Stop()
|
||||||
|
} else {
|
||||||
|
mediaMessageHandler.events[key] = event
|
||||||
|
}
|
||||||
|
|
||||||
|
mediaMessageHandler.timers[key] = time.AfterFunc(mediaMessageHandler.debounce, func() {
|
||||||
|
mediaMessageHandler.mu.Lock()
|
||||||
|
event := mediaMessageHandler.events[key]
|
||||||
|
delete(mediaMessageHandler.events, key)
|
||||||
|
delete(mediaMessageHandler.timers, key)
|
||||||
|
mediaMessageHandler.mu.Unlock()
|
||||||
|
|
||||||
|
mediaMessageCh <- event
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleMediaMessage(ctx *ext.Context, update *ext.Update) error {
|
||||||
|
message := update.EffectiveMessage
|
||||||
|
media, ok := message.GetMedia()
|
||||||
|
if !ok || media == nil {
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
support := func() bool {
|
||||||
|
switch media.(type) {
|
||||||
|
case *tg.MessageMediaDocument, *tg.MessageMediaPhoto:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
if !support {
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
file, err := tfile.FromMediaMessage(media, ctx.Raw, message.Message, tfile.WithNameIfEmpty(
|
||||||
|
tgutil.GenFileNameFromMessage(*message.Message),
|
||||||
|
))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
chatId := update.EffectiveChat().GetID()
|
||||||
|
sendMediaMessageEvent(MediaMessageEvent{
|
||||||
|
Ctx: ctx,
|
||||||
|
ChatID: chatId,
|
||||||
|
MessageID: message.ID,
|
||||||
|
File: file,
|
||||||
|
})
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
// cmd/gen_i18n/main.go
|
// cmd/geni18n/main.go
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -8,40 +8,40 @@ import (
|
|||||||
"io/fs"
|
"io/fs"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/goccy/go-yaml"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
dir := flag.String("dir", "./i18n/locale", "Locales directory path")
|
dir := flag.String("dir", "./common/i18n/locale", "Locales directory path")
|
||||||
out := flag.String("out", "i18n/i18nk/keys.go", "Output file path")
|
out := flag.String("out", "common/i18n/i18nk/keys.go", "Output file path")
|
||||||
pkg := flag.String("pkg", "i18nk", "Package name for generated file")
|
pkg := flag.String("pkg", "i18nk", "Package name for generated file")
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
keys := make(map[string]struct{})
|
keys := make(map[string]struct{})
|
||||||
re := regexp.MustCompile(`^\s*\[+\s*([^\]\[]+)\s*\]+`)
|
|
||||||
|
|
||||||
err := filepath.WalkDir(*dir, func(path string, d fs.DirEntry, err error) error {
|
err := filepath.WalkDir(*dir, func(path string, d fs.DirEntry, err error) error {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if d.IsDir() || !strings.HasSuffix(d.Name(), ".toml") {
|
if d.IsDir() || !(strings.HasSuffix(d.Name(), ".yaml") || strings.HasSuffix(d.Name(), ".yml")) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
f, err := os.Open(path)
|
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer f.Close()
|
|
||||||
|
|
||||||
s := bufio.NewScanner(f)
|
var content map[string]interface{}
|
||||||
for s.Scan() {
|
if err := yaml.Unmarshal(data, &content); err != nil {
|
||||||
if m := re.FindStringSubmatch(s.Text()); m != nil {
|
return fmt.Errorf("failed to parse yaml %s: %w", path, err)
|
||||||
keys[m[1]] = struct{}{}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return s.Err()
|
|
||||||
|
collectKeys(content, "", keys)
|
||||||
|
return nil
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "Error walking directory: %v\n", err)
|
fmt.Fprintf(os.Stderr, "Error walking directory: %v\n", err)
|
||||||
@@ -62,23 +62,44 @@ func main() {
|
|||||||
defer f.Close()
|
defer f.Close()
|
||||||
|
|
||||||
w := bufio.NewWriter(f)
|
w := bufio.NewWriter(f)
|
||||||
fmt.Fprintf(w, "// Code generated by cmd/gen_i18n. DO NOT EDIT.\n")
|
fmt.Fprintf(w, "// Code generated by cmd/geni18n. DO NOT EDIT.\n")
|
||||||
fmt.Fprintf(w, "package %s\n\n", *pkg)
|
fmt.Fprintf(w, "package %s\n\n", *pkg)
|
||||||
|
fmt.Fprintf(w, "type Key string\n\n")
|
||||||
fmt.Fprintf(w, "const (\n")
|
fmt.Fprintf(w, "const (\n")
|
||||||
for _, key := range list {
|
for _, key := range list {
|
||||||
name := toPascal(key)
|
name := toPascal(key)
|
||||||
fmt.Fprintf(w, "\t%s = %q\n", name, key)
|
fmt.Fprintf(w, "\t%s Key = %q\n", name, key)
|
||||||
}
|
}
|
||||||
fmt.Fprintf(w, ")\n")
|
fmt.Fprintf(w, ")\n")
|
||||||
w.Flush()
|
w.Flush()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func collectKeys(node map[string]interface{}, prefix string, keys map[string]struct{}) {
|
||||||
|
for k, v := range node {
|
||||||
|
fullKey := k
|
||||||
|
if prefix != "" {
|
||||||
|
fullKey = prefix + "." + k
|
||||||
|
}
|
||||||
|
switch val := v.(type) {
|
||||||
|
case map[string]interface{}:
|
||||||
|
collectKeys(val, fullKey, keys)
|
||||||
|
default:
|
||||||
|
keys[fullKey] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 转 PascalCase
|
||||||
func toPascal(key string) string {
|
func toPascal(key string) string {
|
||||||
parts := strings.Split(key, ".")
|
parts := strings.Split(key, ".")
|
||||||
for i, p := range parts {
|
for i, p := range parts {
|
||||||
if len(p) > 0 {
|
subs := strings.Split(p, "_")
|
||||||
parts[i] = strings.ToUpper(string(p[0])) + p[1:]
|
for j, s := range subs {
|
||||||
|
if len(s) > 0 {
|
||||||
|
subs[j] = strings.ToUpper(s[:1]) + s[1:]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
parts[i] = strings.Join(subs, "")
|
||||||
}
|
}
|
||||||
return strings.Join(parts, "")
|
return strings.Join(parts, "")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package cmd
|
package cmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
@@ -12,8 +13,8 @@ var rootCmd = &cobra.Command{
|
|||||||
Run: Run,
|
Run: Run,
|
||||||
}
|
}
|
||||||
|
|
||||||
func Execute() {
|
func Execute(ctx context.Context) {
|
||||||
if err := rootCmd.Execute(); err != nil {
|
if err := rootCmd.ExecuteContext(ctx); err != nil {
|
||||||
fmt.Println(err)
|
fmt.Println(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
129
cmd/run.go
129
cmd/run.go
@@ -4,89 +4,116 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"syscall"
|
"time"
|
||||||
|
|
||||||
"slices"
|
"slices"
|
||||||
|
|
||||||
"github.com/krau/SaveAny-Bot/bot"
|
"github.com/charmbracelet/log"
|
||||||
"github.com/krau/SaveAny-Bot/common"
|
"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/config"
|
||||||
"github.com/krau/SaveAny-Bot/core"
|
"github.com/krau/SaveAny-Bot/core"
|
||||||
"github.com/krau/SaveAny-Bot/dao"
|
"github.com/krau/SaveAny-Bot/database"
|
||||||
"github.com/krau/SaveAny-Bot/i18n"
|
"github.com/krau/SaveAny-Bot/parsers"
|
||||||
"github.com/krau/SaveAny-Bot/i18n/i18nk"
|
|
||||||
"github.com/krau/SaveAny-Bot/storage"
|
"github.com/krau/SaveAny-Bot/storage"
|
||||||
"github.com/krau/SaveAny-Bot/userclient"
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
func Run(_ *cobra.Command, _ []string) {
|
func Run(cmd *cobra.Command, _ []string) {
|
||||||
InitAll()
|
ctx, cancel := context.WithCancel(cmd.Context())
|
||||||
core.Run()
|
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)
|
exitChan, err := initAll(ctx)
|
||||||
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
if err != nil {
|
||||||
sig := <-quit
|
logger.Fatal("Init failed", "error", err)
|
||||||
common.Log.Info(sig, i18n.T(i18nk.Exiting))
|
}
|
||||||
defer common.Log.Info(i18n.T(i18nk.Bye))
|
go func() {
|
||||||
if config.Cfg.NoCleanCache {
|
<-exitChan
|
||||||
|
cancel()
|
||||||
|
}()
|
||||||
|
|
||||||
|
core.Run(ctx)
|
||||||
|
|
||||||
|
<-ctx.Done()
|
||||||
|
logger.Info(i18n.T(i18nk.LifetimeExiting))
|
||||||
|
defer logger.Info(i18n.T(i18nk.LifetimeBye))
|
||||||
|
cleanCache()
|
||||||
|
}
|
||||||
|
|
||||||
|
func initAll(ctx context.Context) (<-chan struct{}, error) {
|
||||||
|
if err := config.Init(ctx); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to load config: %w", err)
|
||||||
|
}
|
||||||
|
cache.Init()
|
||||||
|
logger := log.FromContext(ctx)
|
||||||
|
i18n.Init(config.C().Lang)
|
||||||
|
logger.Info(i18n.T(i18nk.LifetimeIniting))
|
||||||
|
database.Init(ctx)
|
||||||
|
storage.LoadStorages(ctx)
|
||||||
|
if config.C().Parser.PluginEnable {
|
||||||
|
for _, dir := range config.C().Parser.PluginDirs {
|
||||||
|
if err := parsers.LoadPlugins(ctx, dir); err != nil {
|
||||||
|
logger.Error(i18n.T(i18nk.ParserPluginLoadFailed), "dir", dir, "error", err)
|
||||||
|
} else {
|
||||||
|
logger.Debug(i18n.T(i18nk.ParserPluginLoadedDir), "dir", dir)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if config.C().Telegram.Userbot.Enable {
|
||||||
|
_, err := userclient.Login(ctx)
|
||||||
|
if err != nil {
|
||||||
|
logger.Fatal(i18n.T(i18nk.LifetimeUserLoginFailed, map[string]any{
|
||||||
|
"Error": err,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return bot.Init(ctx), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func cleanCache() {
|
||||||
|
if config.C().NoCleanCache {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if config.Cfg.Temp.BasePath != "" && !config.Cfg.Stream {
|
if config.C().Temp.BasePath != "" && !config.C().Stream {
|
||||||
if slices.Contains([]string{"/", ".", "\\", ".."}, filepath.Clean(config.Cfg.Temp.BasePath)) {
|
if slices.Contains([]string{"/", ".", "\\", ".."}, filepath.Clean(config.C().Temp.BasePath)) {
|
||||||
common.Log.Error(i18n.T(i18nk.InvalidCacheDir, map[string]any{
|
log.Error(i18n.T(i18nk.ConfigErrInvalidCacheDir, map[string]any{
|
||||||
"Path": config.Cfg.Temp.BasePath,
|
"Path": config.C().Temp.BasePath,
|
||||||
}))
|
}))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
currentDir, err := os.Getwd()
|
currentDir, err := os.Getwd()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
common.Log.Error(i18n.T(i18nk.GetWorkdirFailed, map[string]any{
|
log.Error(i18n.T(i18nk.ErrGetWorkdirFailed, map[string]any{
|
||||||
"Error": err,
|
"Error": err,
|
||||||
}))
|
}))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
cachePath := filepath.Join(currentDir, config.Cfg.Temp.BasePath)
|
cachePath := filepath.Join(currentDir, config.C().Temp.BasePath)
|
||||||
cachePath, err = filepath.Abs(cachePath)
|
cachePath, err = filepath.Abs(cachePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
common.Log.Error(i18n.T(i18nk.GetCacheAbsPathFailed, map[string]any{
|
log.Error(i18n.T(i18nk.ErrGetCacheAbsPathFailed, map[string]any{
|
||||||
"Error": err,
|
"Error": err,
|
||||||
}))
|
}))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
common.Log.Info(i18n.T(i18nk.CleaningCache, map[string]any{
|
log.Info(i18n.T(i18nk.LifetimeCleaningCache, map[string]any{
|
||||||
"Path": cachePath,
|
"Path": cachePath,
|
||||||
}))
|
}))
|
||||||
if err := common.RemoveAllInDir(cachePath); err != nil {
|
if err := fsutil.RemoveAllInDir(cachePath); err != nil {
|
||||||
common.Log.Error(i18n.T(i18nk.CleanCacheFailed, map[string]any{
|
log.Error(i18n.T(i18nk.ErrCleanCacheFailed, map[string]any{
|
||||||
"Error": err,
|
"Error": err,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func InitAll() {
|
|
||||||
if err := config.Init(); err != nil {
|
|
||||||
fmt.Println("Failed to load config:", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
common.InitLogger()
|
|
||||||
i18n.Init(config.Cfg.Lang)
|
|
||||||
common.Log.Info(i18n.T(i18nk.Initing))
|
|
||||||
dao.Init()
|
|
||||||
storage.LoadStorages()
|
|
||||||
common.Init()
|
|
||||||
if config.Cfg.Telegram.Userbot.Enable {
|
|
||||||
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
|
||||||
defer cancel()
|
|
||||||
uc, err := userclient.Login(ctx)
|
|
||||||
if err != nil {
|
|
||||||
common.Log.Errorf("User client login failed: %s", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
common.Log.Infof("User client logged in as %s", uc.Self.FirstName)
|
|
||||||
}
|
|
||||||
bot.Init()
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
|
||||||
"github.com/krau/SaveAny-Bot/common"
|
"github.com/krau/SaveAny-Bot/config"
|
||||||
"github.com/rhysd/go-github-selfupdate/selfupdate"
|
"github.com/rhysd/go-github-selfupdate/selfupdate"
|
||||||
|
|
||||||
"github.com/blang/semver"
|
"github.com/blang/semver"
|
||||||
@@ -16,7 +16,7 @@ var VersionCmd = &cobra.Command{
|
|||||||
Aliases: []string{"v"},
|
Aliases: []string{"v"},
|
||||||
Short: "Print the version number of saveany-bot",
|
Short: "Print the version number of saveany-bot",
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
fmt.Printf("saveany-bot version: %s %s/%s\nBuildTime: %s, Commit: %s\n", common.Version, runtime.GOOS, runtime.GOARCH, common.BuildTime, common.GitCommit)
|
fmt.Printf("saveany-bot version: %s %s/%s\nBuildTime: %s, Commit: %s\n", config.Version, runtime.GOOS, runtime.GOARCH, config.BuildTime, config.GitCommit)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,14 +25,14 @@ var upgradeCmd = &cobra.Command{
|
|||||||
Aliases: []string{"up"},
|
Aliases: []string{"up"},
|
||||||
Short: "Upgrade saveany-bot to the latest version",
|
Short: "Upgrade saveany-bot to the latest version",
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
v := semver.MustParse(common.Version)
|
v := semver.MustParse(config.Version)
|
||||||
latest, err := selfupdate.UpdateSelf(v, "krau/SaveAny-Bot")
|
latest, err := selfupdate.UpdateSelf(v, config.GitRepo)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Println("Binary update failed:", err)
|
fmt.Println("Update failed:", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if latest.Version.Equals(v) {
|
if latest.Version.Equals(v) {
|
||||||
fmt.Println("Current binary is the latest version", common.Version)
|
fmt.Println("Current binary is the latest version", config.Version)
|
||||||
} else {
|
} else {
|
||||||
fmt.Println("Successfully updated to version", latest.Version)
|
fmt.Println("Successfully updated to version", latest.Version)
|
||||||
fmt.Println("Release note:\n", latest.ReleaseNotes)
|
fmt.Println("Release note:\n", latest.ReleaseNotes)
|
||||||
|
|||||||
@@ -1,38 +0,0 @@
|
|||||||
package common
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/eko/gocache/lib/v4/cache"
|
|
||||||
gocachestore "github.com/eko/gocache/store/go_cache/v4"
|
|
||||||
gocache "github.com/patrickmn/go-cache"
|
|
||||||
)
|
|
||||||
|
|
||||||
var Cache *cache.Cache[any]
|
|
||||||
|
|
||||||
func initCache() {
|
|
||||||
gocacheClient := gocache.New(time.Hour*1, time.Minute*10)
|
|
||||||
gocacheStore := gocachestore.NewGoCache(gocacheClient)
|
|
||||||
cacheManager := cache.New[any](gocacheStore)
|
|
||||||
Cache = cacheManager
|
|
||||||
}
|
|
||||||
|
|
||||||
func CacheGet[T any](ctx context.Context, key string) (T, error) {
|
|
||||||
data, err := Cache.Get(ctx, key)
|
|
||||||
if err != nil {
|
|
||||||
return *new(T), err
|
|
||||||
}
|
|
||||||
if v, ok := data.(T); ok {
|
|
||||||
return v, nil
|
|
||||||
}
|
|
||||||
return *new(T), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func CacheSet(ctx context.Context, key string, value any) error {
|
|
||||||
return Cache.Set(ctx, key, value)
|
|
||||||
}
|
|
||||||
|
|
||||||
func CacheDelete(ctx context.Context, key string) error {
|
|
||||||
return Cache.Delete(ctx, key)
|
|
||||||
}
|
|
||||||
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.C().Cache.NumCounters,
|
||||||
|
MaxCost: config.C().Cache.MaxCost,
|
||||||
|
BufferItems: 64,
|
||||||
|
OnReject: func(item *ristretto.Item[any]) {
|
||||||
|
log.Warnf("Cache item rejected: key=%d, value=%v", item.Key, item.Value)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
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.C().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()
|
|
||||||
}
|
|
||||||
@@ -5,12 +5,13 @@ import (
|
|||||||
|
|
||||||
"maps"
|
"maps"
|
||||||
|
|
||||||
|
"github.com/goccy/go-yaml"
|
||||||
|
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
|
||||||
"github.com/nicksnyder/go-i18n/v2/i18n"
|
"github.com/nicksnyder/go-i18n/v2/i18n"
|
||||||
"github.com/pelletier/go-toml/v2"
|
|
||||||
"golang.org/x/text/language"
|
"golang.org/x/text/language"
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:embed locale/*.toml
|
//go:embed locale/*
|
||||||
var localesFS embed.FS
|
var localesFS embed.FS
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -20,7 +21,7 @@ var (
|
|||||||
|
|
||||||
func Init(lang string) {
|
func Init(lang string) {
|
||||||
bundle = i18n.NewBundle(language.SimplifiedChinese)
|
bundle = i18n.NewBundle(language.SimplifiedChinese)
|
||||||
bundle.RegisterUnmarshalFunc("toml", toml.Unmarshal)
|
bundle.RegisterUnmarshalFunc("yaml", yaml.Unmarshal)
|
||||||
files, err := localesFS.ReadDir("locale")
|
files, err := localesFS.ReadDir("locale")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic("failed to read locale directory: " + err.Error())
|
panic("failed to read locale directory: " + err.Error())
|
||||||
@@ -39,7 +40,7 @@ func Init(lang string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func T(key string, templateData ...map[string]any) string {
|
func T(key i18nk.Key, templateData ...map[string]any) string {
|
||||||
if localizer == nil || bundle == nil {
|
if localizer == nil || bundle == nil {
|
||||||
panic("localizer or bundle is not initialized, call Init() first")
|
panic("localizer or bundle is not initialized, call Init() first")
|
||||||
}
|
}
|
||||||
@@ -48,11 +49,11 @@ func T(key string, templateData ...map[string]any) string {
|
|||||||
maps.Copy(templateDataMap, data)
|
maps.Copy(templateDataMap, data)
|
||||||
}
|
}
|
||||||
msg, err := localizer.Localize(&i18n.LocalizeConfig{
|
msg, err := localizer.Localize(&i18n.LocalizeConfig{
|
||||||
MessageID: key,
|
MessageID: string(key),
|
||||||
TemplateData: templateDataMap,
|
TemplateData: templateDataMap,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return key
|
return string(key)
|
||||||
}
|
}
|
||||||
return msg
|
return msg
|
||||||
}
|
}
|
||||||
@@ -77,32 +78,32 @@ func TWithLang(lang, key string, templateData ...map[string]any) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Only use in tests or packages that load before i18n
|
// Only use in tests or packages that load before i18n
|
||||||
func TWithoutInit(lang, key string, templateData ...map[string]any) string {
|
func TWithoutInit(lang string, key i18nk.Key, templateData ...map[string]any) string {
|
||||||
bundle := i18n.NewBundle(language.SimplifiedChinese)
|
bundle := i18n.NewBundle(language.SimplifiedChinese)
|
||||||
bundle.RegisterUnmarshalFunc("toml", toml.Unmarshal)
|
bundle.RegisterUnmarshalFunc("yaml", yaml.Unmarshal)
|
||||||
files, err := localesFS.ReadDir("locale")
|
files, err := localesFS.ReadDir("locale")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return key
|
return string(key)
|
||||||
}
|
}
|
||||||
for _, file := range files {
|
for _, file := range files {
|
||||||
if _, err := bundle.LoadMessageFileFS(localesFS, "locale/"+file.Name()); err != nil {
|
if _, err := bundle.LoadMessageFileFS(localesFS, "locale/"+file.Name()); err != nil {
|
||||||
return key
|
return string(key)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
localizer := i18n.NewLocalizer(bundle, lang)
|
localizer := i18n.NewLocalizer(bundle, lang)
|
||||||
if localizer == nil {
|
if localizer == nil {
|
||||||
return key
|
return string(key)
|
||||||
}
|
}
|
||||||
templateDataMap := make(map[string]any)
|
templateDataMap := make(map[string]any)
|
||||||
for _, data := range templateData {
|
for _, data := range templateData {
|
||||||
maps.Copy(templateDataMap, data)
|
maps.Copy(templateDataMap, data)
|
||||||
}
|
}
|
||||||
msg, err := localizer.Localize(&i18n.LocalizeConfig{
|
msg, err := localizer.Localize(&i18n.LocalizeConfig{
|
||||||
MessageID: key,
|
MessageID: string(key),
|
||||||
TemplateData: templateDataMap,
|
TemplateData: templateDataMap,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return key
|
return string(key)
|
||||||
}
|
}
|
||||||
return msg
|
return msg
|
||||||
}
|
}
|
||||||
24
common/i18n/i18nk/keys.go
Normal file
24
common/i18n/i18nk/keys.go
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
// Code generated by cmd/geni18n. DO NOT EDIT.
|
||||||
|
package i18nk
|
||||||
|
|
||||||
|
type Key string
|
||||||
|
|
||||||
|
const (
|
||||||
|
BotMsgHelpTextFmt Key = "bot.msg.help_text_fmt"
|
||||||
|
BotMsgSaveHelpText Key = "bot.msg.save_help_text"
|
||||||
|
BotMsgWatchHelpText Key = "bot.msg.watch_help_text"
|
||||||
|
ConfigErrDuplicateStorageName Key = "config.err.duplicate_storage_name"
|
||||||
|
ConfigErrInvalidCacheDir Key = "config.err.invalid_cache_dir"
|
||||||
|
ConfigLoadedStorages Key = "config.loaded_storages"
|
||||||
|
ErrCleanCacheFailed Key = "err.clean_cache_failed"
|
||||||
|
ErrGetCacheAbsPathFailed Key = "err.get_cache_abs_path_failed"
|
||||||
|
ErrGetWorkdirFailed Key = "err.get_workdir_failed"
|
||||||
|
LifetimeBye Key = "lifetime.bye"
|
||||||
|
LifetimeCleaningCache Key = "lifetime.cleaning_cache"
|
||||||
|
LifetimeExiting Key = "lifetime.exiting"
|
||||||
|
LifetimeInitfailed Key = "lifetime.initfailed"
|
||||||
|
LifetimeIniting Key = "lifetime.initing"
|
||||||
|
LifetimeUserLoginFailed Key = "lifetime.user_login_failed"
|
||||||
|
ParserPluginLoadFailed Key = "parser.plugin.load_failed"
|
||||||
|
ParserPluginLoadedDir Key = "parser.plugin.loaded_dir"
|
||||||
|
)
|
||||||
62
common/i18n/locale/zh-Hans.yaml
Normal file
62
common/i18n/locale/zh-Hans.yaml
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
lifetime:
|
||||||
|
initing: 正在启动
|
||||||
|
initfailed: 初始化失败
|
||||||
|
exiting: 正在退出
|
||||||
|
user_login_failed: "用户登录失败: {{.Error}}"
|
||||||
|
cleaning_cache: "正在清理缓存 {{.Path}}"
|
||||||
|
bye: 已退出
|
||||||
|
config:
|
||||||
|
loaded_storages: "已加载 {{.Count}} 个存储后端"
|
||||||
|
err:
|
||||||
|
invalid_cache_dir: "无效的缓存目录: {{.Path}},请检查配置文件"
|
||||||
|
duplicate_storage_name: "存储名称 '{{.Name}}' 重复,请检查配置文件"
|
||||||
|
err:
|
||||||
|
get_workdir_failed: "获取工作目录失败: {{.Error}}"
|
||||||
|
get_cache_abs_path_failed: "获取缓存绝对路径失败: {{.Error}}"
|
||||||
|
clean_cache_failed: "清理缓存失败: {{.Error}}"
|
||||||
|
parser:
|
||||||
|
plugin:
|
||||||
|
load_failed: 加载解析器插件失败
|
||||||
|
loaded_dir: 解析器插件已加载
|
||||||
|
bot:
|
||||||
|
msg:
|
||||||
|
help_text_fmt: |
|
||||||
|
Save Any Bot - 转存你的 Telegram 文件
|
||||||
|
版本: %s , 提交: %s
|
||||||
|
|
||||||
|
命令:
|
||||||
|
/start - 开始使用
|
||||||
|
/help - 显示帮助
|
||||||
|
/silent - 开关静默模式
|
||||||
|
/storage - 设置默认存储位置
|
||||||
|
/save [自定义文件名] - 保存文件
|
||||||
|
/dir - 管理存储目录
|
||||||
|
/rule - 管理规则
|
||||||
|
/update - 检查更新并升级
|
||||||
|
|
||||||
|
使用帮助: https://sabot.unv.app/usage
|
||||||
|
反馈群组: https://t.me/ProjectSaveAny
|
||||||
|
save_help_text: |
|
||||||
|
使用方法:
|
||||||
|
|
||||||
|
1. 使用该命令回复要保存的文件, 可选文件名参数.
|
||||||
|
示例:
|
||||||
|
/save custom_file_name.mp4
|
||||||
|
|
||||||
|
2. 设置默认存储后, 发送 /save <频道ID/用户名> <消息ID范围> 来批量保存文件. 遵从存储规则, 若未匹配到任何规则则使用默认存储.
|
||||||
|
示例:
|
||||||
|
/save @acherkrau 114-514
|
||||||
|
watch_help_text: |
|
||||||
|
使用 /watch 命令监听一个聊天的消息, 并自动保存到默认存储中, 遵从存储规则.
|
||||||
|
|
||||||
|
命令语法:
|
||||||
|
/watch <chat_id> [filter]
|
||||||
|
|
||||||
|
参数:
|
||||||
|
- <chat_id>: 聊天的 ID 或用户名
|
||||||
|
- [filter]: 可选, 格式为 过滤器类型:表达式 , 所有支持类型的过滤器请查看文档
|
||||||
|
|
||||||
|
命令示例:
|
||||||
|
/watch 2229835658 msgre:.*plana.*
|
||||||
|
|
||||||
|
这将监听 ID 为 2229835658 的聊天, 并转存所有包含 "plana" 的媒体消息
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
package common
|
|
||||||
|
|
||||||
import (
|
|
||||||
"github.com/gookit/slog"
|
|
||||||
"github.com/gookit/slog/handler"
|
|
||||||
"github.com/gookit/slog/rotatefile"
|
|
||||||
"github.com/krau/SaveAny-Bot/config"
|
|
||||||
)
|
|
||||||
|
|
||||||
var Log *slog.Logger
|
|
||||||
|
|
||||||
func InitLogger() {
|
|
||||||
if Log != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
Log = slog.New()
|
|
||||||
logLevel := slog.LevelByName(config.Cfg.Log.Level)
|
|
||||||
logFilePath := config.Cfg.Log.File
|
|
||||||
logBackupNum := config.Cfg.Log.BackupCount
|
|
||||||
var logLevels []slog.Level
|
|
||||||
for _, level := range slog.AllLevels {
|
|
||||||
if level <= logLevel {
|
|
||||||
logLevels = append(logLevels, level)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
tem := "[{{datetime}}] [{{level}}] [{{caller}}] {{message}} {{data}} {{extra}}\n"
|
|
||||||
consoleH := handler.NewConsoleHandler(logLevels)
|
|
||||||
consoleH.Formatter().(*slog.TextFormatter).SetTemplate(tem)
|
|
||||||
Log.AddHandler(consoleH)
|
|
||||||
if logFilePath != "" && logBackupNum > 0 {
|
|
||||||
fileH, err := handler.NewTimeRotateFile(
|
|
||||||
logFilePath,
|
|
||||||
rotatefile.EveryDay,
|
|
||||||
handler.WithLogLevels(slog.AllLevels),
|
|
||||||
handler.WithBackupNum(logBackupNum),
|
|
||||||
)
|
|
||||||
fileH.Formatter().(*slog.TextFormatter).SetTemplate(tem)
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
Log.AddHandler(fileH)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
48
common/os.go
48
common/os.go
@@ -1,48 +0,0 @@
|
|||||||
package common
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/krau/SaveAny-Bot/i18n"
|
|
||||||
"github.com/krau/SaveAny-Bot/i18n/i18nk"
|
|
||||||
)
|
|
||||||
|
|
||||||
func RmFileAfter(path string, td time.Duration) {
|
|
||||||
_, err := os.Stat(path)
|
|
||||||
if err != nil {
|
|
||||||
Log.Errorf(i18n.T(i18nk.CreateRmTimerFailed, map[string]any{
|
|
||||||
"Path": path,
|
|
||||||
"Error": err,
|
|
||||||
}))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
Log.Debugf(i18n.T(i18nk.RemoveFileAfter, map[string]any{
|
|
||||||
"Duration": td.String(),
|
|
||||||
"Path": path,
|
|
||||||
}))
|
|
||||||
time.AfterFunc(td, func() {
|
|
||||||
if err := os.Remove(path); err != nil {
|
|
||||||
Log.Errorf(i18n.T(i18nk.RemoveFileFailed, map[string]any{
|
|
||||||
"Path": path,
|
|
||||||
"Error": err,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// 删除目录下的所有内容, 但不删除目录本身
|
|
||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
package common
|
|
||||||
|
|
||||||
import (
|
|
||||||
"crypto/md5"
|
|
||||||
"encoding/hex"
|
|
||||||
"regexp"
|
|
||||||
)
|
|
||||||
|
|
||||||
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 tags
|
|
||||||
}
|
|
||||||
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
|
||||||
|
}
|
||||||
77
common/utils/fsutil/fs.go
Normal file
77
common/utils/fsutil/fs.go
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
package fsutil
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
|
||||||
|
func NormalizePathname(s string) string {
|
||||||
|
specials := `\/:*?"<>|` + "\n\r\t"
|
||||||
|
var builder strings.Builder
|
||||||
|
for _, ch := range s {
|
||||||
|
if strings.ContainsRune(specials, ch) || unicode.IsControl(ch) {
|
||||||
|
builder.WriteRune('_')
|
||||||
|
} else {
|
||||||
|
builder.WriteRune(ch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result := strings.TrimRightFunc(builder.String(), func(r rune) bool {
|
||||||
|
return r == '.' || r == '_' || unicode.IsSpace(r)
|
||||||
|
})
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
46
common/utils/fsutil/normalize_pathname_test.go
Normal file
46
common/utils/fsutil/normalize_pathname_test.go
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
package fsutil_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/krau/SaveAny-Bot/common/utils/fsutil"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNormalizePathname(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
input string
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
input: "hello/world?.txt ",
|
||||||
|
expected: "hello_world_.txt",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: "bad|name:\nfile\r.",
|
||||||
|
expected: "bad_name__file",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: "normal.txt",
|
||||||
|
expected: "normal.txt",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: "test.... ",
|
||||||
|
expected: "test",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: "abc<>def",
|
||||||
|
expected: "abc__def",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: "with\tcontrol",
|
||||||
|
expected: "with_control",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
got := fsutil.NormalizePathname(tc.input)
|
||||||
|
if got != tc.expected {
|
||||||
|
t.Errorf("NormalizePathname(%q) = %q; want %q", tc.input, got, tc.expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
78
common/utils/netutil/proxy.go
Normal file
78
common/utils/netutil/proxy.go
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
package netutil
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/log"
|
||||||
|
"github.com/krau/SaveAny-Bot/config"
|
||||||
|
"golang.org/x/net/proxy"
|
||||||
|
)
|
||||||
|
|
||||||
|
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 NewProxyHTTPClient(proxyUrl string) (*http.Client, error) {
|
||||||
|
if proxyUrl == "" {
|
||||||
|
return &http.Client{
|
||||||
|
Transport: &http.Transport{
|
||||||
|
Proxy: http.ProxyFromEnvironment,
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
u, err := url.Parse(proxyUrl)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
switch u.Scheme {
|
||||||
|
case "http", "https":
|
||||||
|
return &http.Client{
|
||||||
|
Transport: &http.Transport{
|
||||||
|
Proxy: http.ProxyURL(u),
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
case "socks5":
|
||||||
|
dialer, err := proxy.FromURL(u, proxy.Direct)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &http.Client{
|
||||||
|
Transport: &http.Transport{
|
||||||
|
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||||
|
return dialer.Dial(network, addr)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unsupported proxy scheme: %s", u.Scheme)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
defaultProxyHttpClient *http.Client
|
||||||
|
onceLoadDefaultProxyHttpClient sync.Once
|
||||||
|
)
|
||||||
|
|
||||||
|
func DefaultParserHTTPClient() *http.Client {
|
||||||
|
onceLoadDefaultProxyHttpClient.Do(func() {
|
||||||
|
client, err := NewProxyHTTPClient(config.C().Parser.Proxy)
|
||||||
|
if err != nil {
|
||||||
|
log.Warn("Failed to create default proxy HTTP client, using http.DefaultClient", "error", err)
|
||||||
|
defaultProxyHttpClient = http.DefaultClient
|
||||||
|
} else {
|
||||||
|
defaultProxyHttpClient = client
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return defaultProxyHttpClient
|
||||||
|
}
|
||||||
93
common/utils/strutil/string.go
Normal file
93
common/utils/strutil/string.go
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
func ParseArgsRespectQuotes(input string) []string {
|
||||||
|
var args []string
|
||||||
|
var current strings.Builder
|
||||||
|
inQuotes := false
|
||||||
|
escaped := false
|
||||||
|
|
||||||
|
for _, r := range input {
|
||||||
|
switch {
|
||||||
|
case escaped:
|
||||||
|
if r == '"' || r == '\\' {
|
||||||
|
current.WriteRune(r)
|
||||||
|
} else {
|
||||||
|
current.WriteRune('\\')
|
||||||
|
current.WriteRune(r)
|
||||||
|
}
|
||||||
|
escaped = false
|
||||||
|
|
||||||
|
case r == '\\':
|
||||||
|
escaped = true
|
||||||
|
|
||||||
|
case r == '"':
|
||||||
|
inQuotes = !inQuotes
|
||||||
|
|
||||||
|
case r == ' ' || r == '\t':
|
||||||
|
if inQuotes {
|
||||||
|
current.WriteRune(r)
|
||||||
|
} else if current.Len() > 0 {
|
||||||
|
args = append(args, current.String())
|
||||||
|
current.Reset()
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
current.WriteRune(r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if current.Len() > 0 {
|
||||||
|
args = append(args, current.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
return args
|
||||||
|
}
|
||||||
148
common/utils/strutil/string_test.go
Normal file
148
common/utils/strutil/string_test.go
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
package strutil_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/krau/SaveAny-Bot/common/utils/strutil"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestExtractTagsFromText(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
text string
|
||||||
|
expected []string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
text: `初音ミクHappy 16th Birthday -Dear Creators-
|
||||||
|
✨エンドイラスト公開!✨
|
||||||
|
https://piapro.net/miku16thbd/
|
||||||
|
#初音ミク #miku16th`,
|
||||||
|
expected: []string{"初音ミク", "miku16th"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: `ひっつきむし
|
||||||
|
#創作百合`,
|
||||||
|
expected: []string{"創作百合"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: `#創作百合 #原创`,
|
||||||
|
expected: []string{"創作百合", "原创"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: `プラニャ #ブルアカ`,
|
||||||
|
expected: []string{"ブルアカ"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: `原神是一款#开放世界#冒险游戏,由中国著名游戏公司#miHoYo开发。`,
|
||||||
|
expected: []string{},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
result := strutil.ExtractTagsFromText(test.text)
|
||||||
|
if !reflect.DeepEqual(result, test.expected) {
|
||||||
|
t.Fatalf("ExtractTagsFromText(%s) = %v, expected %v", test.text, result, test.expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseIntStrRange(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
sep string
|
||||||
|
wantMin int64
|
||||||
|
wantMax int64
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "normal range",
|
||||||
|
input: "10-20",
|
||||||
|
sep: "-",
|
||||||
|
wantMin: 10,
|
||||||
|
wantMax: 20,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "reverse order",
|
||||||
|
input: "30 - 10",
|
||||||
|
sep: "-",
|
||||||
|
wantMin: 10,
|
||||||
|
wantMax: 30,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid format",
|
||||||
|
input: "10",
|
||||||
|
sep: "-",
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid number",
|
||||||
|
input: "a-b",
|
||||||
|
sep: "-",
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
min, max, err := strutil.ParseIntStrRange(tt.input, tt.sep)
|
||||||
|
if (err != nil) != tt.wantErr {
|
||||||
|
t.Errorf("ParseIntStrRange(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !tt.wantErr {
|
||||||
|
if min != tt.wantMin || max != tt.wantMax {
|
||||||
|
t.Errorf("ParseIntStrRange(%q) = (%d, %d), want (%d, %d)", tt.input, min, max, tt.wantMin, tt.wantMax)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseArgsRespectQuotes(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
want []string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "simple split",
|
||||||
|
input: `/rule add FILENAME-REGEX (?i)\.(mp4|mkv)$ "我的 Alist" /视频`,
|
||||||
|
want: []string{"/rule", "add", "FILENAME-REGEX", "(?i)\\.(mp4|mkv)$", "我的 Alist", "/视频"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "escaped quotes",
|
||||||
|
input: `/rule add "My \"Awesome\" Folder"`,
|
||||||
|
want: []string{"/rule", "add", `My "Awesome" Folder`},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "escaped backslash",
|
||||||
|
input: `/cmd "C:\\Users\\Admin" test`,
|
||||||
|
want: []string{"/cmd", `C:\Users\Admin`, "test"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "multiple quoted parts",
|
||||||
|
input: `"Hello World" "你好 世界"`,
|
||||||
|
want: []string{"Hello World", "你好 世界"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unquoted words",
|
||||||
|
input: "a b c",
|
||||||
|
want: []string{"a", "b", "c"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "mixed quotes and plain",
|
||||||
|
input: `cmd "quoted arg" plain`,
|
||||||
|
want: []string{"cmd", "quoted arg", "plain"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := strutil.ParseArgsRespectQuotes(tt.input)
|
||||||
|
if !reflect.DeepEqual(got, tt.want) {
|
||||||
|
t.Errorf("ParseArgsRespectQuotes(%q) = %#v, want %#v", tt.input, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
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)
|
||||||
|
}
|
||||||
40
common/utils/tgutil/media.go
Normal file
40
common/utils/tgutil/media.go
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
package tgutil
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/gabriel-vasile/mimetype"
|
||||||
|
"github.com/gotd/td/tg"
|
||||||
|
)
|
||||||
|
|
||||||
|
func GetMediaFileName(media tg.MessageMediaClass) (string, error) {
|
||||||
|
switch v := media.(type) {
|
||||||
|
case *tg.MessageMediaPhoto:
|
||||||
|
f, ok := v.Photo.AsNotEmpty()
|
||||||
|
if !ok {
|
||||||
|
return "", fmt.Errorf("unknown type media: %T", media)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%d.png", f.ID), nil
|
||||||
|
case *tg.MessageMediaDocument:
|
||||||
|
f, ok := v.Document.AsNotEmpty()
|
||||||
|
if !ok {
|
||||||
|
return "", fmt.Errorf("unknown type media: %T", media)
|
||||||
|
}
|
||||||
|
fileName := ""
|
||||||
|
for _, attribute := range f.Attributes {
|
||||||
|
if name, ok := attribute.(*tg.DocumentAttributeFilename); ok {
|
||||||
|
fileName = name.GetFileName()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if fileName == "" {
|
||||||
|
mmt := mimetype.Lookup(f.GetMimeType())
|
||||||
|
if mmt != nil {
|
||||||
|
fileName = fmt.Sprintf("%d.%s", f.GetID(), mmt.Extension())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fileName, nil
|
||||||
|
default:
|
||||||
|
return "", fmt.Errorf("unsupported type media: %T", media)
|
||||||
|
}
|
||||||
|
}
|
||||||
355
common/utils/tgutil/message.go
Normal file
355
common/utils/tgutil/message.go
Normal file
@@ -0,0 +1,355 @@
|
|||||||
|
package tgutil
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
"unicode/utf16"
|
||||||
|
|
||||||
|
"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"
|
||||||
|
)
|
||||||
|
|
||||||
|
// generate a file name from the message content and media type
|
||||||
|
//
|
||||||
|
// it will never return an empty string
|
||||||
|
func GenFileNameFromMessage(message tg.Message) string {
|
||||||
|
ext := func(media tg.MessageMediaClass) string {
|
||||||
|
switch media := media.(type) {
|
||||||
|
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 {
|
||||||
|
switch r {
|
||||||
|
case '/', '\\',
|
||||||
|
':', '*', '?', '"', '<', '>', '|':
|
||||||
|
return '_'
|
||||||
|
}
|
||||||
|
if unicode.IsControl(r) || unicode.IsSpace(r) {
|
||||||
|
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 == "" {
|
||||||
|
mname, err := GetMediaFileName(message.Media)
|
||||||
|
if err != nil {
|
||||||
|
filename = fmt.Sprintf("%d_%s", message.GetID(), xid.New().String())
|
||||||
|
} else {
|
||||||
|
filename = mname
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
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, 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
|
||||||
|
}
|
||||||
|
|
||||||
|
func ExtractMessageEntityUrls(msg *tg.Message) []string {
|
||||||
|
if len(msg.Entities) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
msgText := msg.GetMessage()
|
||||||
|
if msgText == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
runes := []rune(msgText)
|
||||||
|
utf16Codes := utf16.Encode(runes)
|
||||||
|
|
||||||
|
var urls []string
|
||||||
|
for _, entity := range msg.Entities {
|
||||||
|
switch ent := entity.(type) {
|
||||||
|
case *tg.MessageEntityTextURL:
|
||||||
|
urls = append(urls, ent.GetURL())
|
||||||
|
case *tg.MessageEntityURL:
|
||||||
|
start := ent.Offset
|
||||||
|
end := ent.Offset + ent.Length
|
||||||
|
if start < 0 || end > len(utf16Codes) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
subRunes := utf16.Decode(utf16Codes[start:end])
|
||||||
|
urls = append(urls, string(subRunes))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return urls
|
||||||
|
}
|
||||||
|
|
||||||
|
func ExtractMessageEntityUrlsText(msg *tg.Message) string {
|
||||||
|
if msg == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
urls := ExtractMessageEntityUrls(msg)
|
||||||
|
if len(urls) == 0 {
|
||||||
|
return msg.GetMessage()
|
||||||
|
}
|
||||||
|
var sb strings.Builder
|
||||||
|
for _, url := range urls {
|
||||||
|
sb.WriteString(url)
|
||||||
|
sb.WriteString(" ")
|
||||||
|
}
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
16
common/utils/tgutil/peer.go
Normal file
16
common/utils/tgutil/peer.go
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
package tgutil
|
||||||
|
|
||||||
|
import "github.com/gotd/td/tg"
|
||||||
|
|
||||||
|
func ChatIdFromPeer(peer tg.PeerClass) int64 {
|
||||||
|
switch peer := peer.(type) {
|
||||||
|
case *tg.PeerChannel:
|
||||||
|
return peer.ChannelID
|
||||||
|
case *tg.PeerUser:
|
||||||
|
return peer.UserID
|
||||||
|
case *tg.PeerChat:
|
||||||
|
return peer.ChatID
|
||||||
|
default:
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
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 , 123: topic 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)
|
||||||
|
}
|
||||||
65
common/utils/tphutil/tph.go
Normal file
65
common/utils/tphutil/tph.go
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
package tphutil
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/krau/SaveAny-Bot/config"
|
||||||
|
"github.com/krau/SaveAny-Bot/pkg/telegraph"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
tphClient *telegraph.Client
|
||||||
|
once sync.Once
|
||||||
|
)
|
||||||
|
|
||||||
|
func DefaultClient() *telegraph.Client {
|
||||||
|
once.Do(func() {
|
||||||
|
tphClient = initDefault()
|
||||||
|
})
|
||||||
|
return tphClient
|
||||||
|
}
|
||||||
|
|
||||||
|
func initDefault() *telegraph.Client {
|
||||||
|
var client *telegraph.Client
|
||||||
|
if config.C().Telegram.Proxy.Enable && config.C().Telegram.Proxy.URL != "" {
|
||||||
|
proxyUrl := config.C().Telegram.Proxy.URL
|
||||||
|
var err error
|
||||||
|
client, err = telegraph.NewClientWithProxy(proxyUrl)
|
||||||
|
if err != nil {
|
||||||
|
client = telegraph.NewClient()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
client = telegraph.NewClient()
|
||||||
|
}
|
||||||
|
return client
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
if strings.HasPrefix(src, "/file/") {
|
||||||
|
// handle images on telegra.ph server
|
||||||
|
src = "https://telegra.ph" + src
|
||||||
|
}
|
||||||
|
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,70 +1,34 @@
|
|||||||
#创建文件时,若需要保留中文注释,请务必确保本文件编码为 UTF-8 ,否则会无法读取。
|
# 创建文件时,若需要保留中文注释,请务必确保本文件编码为 UTF-8 ,否则会无法读取。
|
||||||
workers = 4 # 同时下载文件数
|
# 更详细的配置请在 https://sabot.unv.app/deployment/configuration 查看
|
||||||
retry = 3 # 下载失败重试次数
|
workers = 4 # 同时下载文件数
|
||||||
threads = 4 # 单个任务下载最大线程数
|
retry = 3 # 下载失败重试次数
|
||||||
stream = false # 使用stream模式, 详情请查看文档
|
threads = 4 # 单个任务下载使用的最大线程数
|
||||||
|
stream = false # 使用流式传输模式, 建议仅在硬盘空间十分有限时使用.
|
||||||
|
|
||||||
[telegram]
|
[telegram]
|
||||||
# Bot Token
|
# Bot Token
|
||||||
# 更换 Bot Token 后请删除数据库文件和 session.db
|
# 更换 Bot Token 后请删除会话数据库文件 (默认路径为 data/session.db )
|
||||||
token = ""
|
token = ""
|
||||||
# Telegram API 配置, 若不配置也可运行, 将使用默认的 API ID 和 API HASH
|
# Telegram API 配置, 若不配置也可运行, 将使用默认的 API ID 和 API HASH
|
||||||
# 推荐使用自己的 API ID 和 API HASH (https://my.telegram.org)
|
# 推荐使用自己的 API ID 和 API HASH (https://my.telegram.org)
|
||||||
# app_id = 1025907
|
# app_id = 1025907
|
||||||
# app_hash = "452b0359b988148995f22ff0f4229750"
|
# app_hash = "452b0359b988148995f22ff0f4229750"
|
||||||
|
|
||||||
# 初始化超时时间, 单位: 秒
|
|
||||||
timeout = 60
|
|
||||||
|
|
||||||
# flood_retry = 5
|
|
||||||
# rpc_retry = 5
|
|
||||||
|
|
||||||
[telegram.proxy]
|
[telegram.proxy]
|
||||||
# 启用代理连接 telegram, 只支持 socks5
|
# 启用代理连接 telegram, 只支持 socks5
|
||||||
enable = false
|
enable = false
|
||||||
url = "socks5://127.0.0.1:7890"
|
url = "socks5://127.0.0.1:7890"
|
||||||
|
|
||||||
# 用户列表
|
|
||||||
[[users]]
|
|
||||||
# telegram user id
|
|
||||||
id = 114514
|
|
||||||
# 使用黑名单模式,开启后下方留空以使用所有存储,反之则为白名单,白名单请在下方输入允许的存储名
|
|
||||||
blacklist = true
|
|
||||||
# 将列表留空并开启黑名单模式以允许使用所有存储,此处示例为黑名单模式,用户 114514 可使用所有存储
|
|
||||||
storages = []
|
|
||||||
|
|
||||||
[[users]]
|
|
||||||
id = 123456
|
|
||||||
blacklist = false # 使用白名单模式,此时,用户123456 仅可使用下方列表中的存储
|
|
||||||
# 此时该用户只能使用名为 本机1 的存储
|
|
||||||
storages = ["本机1"]
|
|
||||||
|
|
||||||
# 存储列表
|
# 存储列表
|
||||||
[[storages]]
|
[[storages]]
|
||||||
# 标识名, 需要唯一
|
# 标识名, 需要唯一
|
||||||
name = "本机1"
|
name = "本机1"
|
||||||
# 存储类型, 目前可用: local, alist, webdav, minio
|
# 存储类型, 目前可用: local, alist, webdav, minio, telegram
|
||||||
type = "local"
|
type = "local"
|
||||||
# 启用存储
|
# 启用存储
|
||||||
enable = true
|
enable = true
|
||||||
# 文件保存根路径
|
# 文件保存根路径
|
||||||
base_path = "./downloads"
|
base_path = "./downloads"
|
||||||
|
|
||||||
[[storages]]
|
|
||||||
name = "MyAlist"
|
|
||||||
type = "alist"
|
|
||||||
enable = false #记得启用
|
|
||||||
base_path = '/'
|
|
||||||
url = 'https://alist.com'
|
|
||||||
username = 'admin'
|
|
||||||
password = 'password'
|
|
||||||
# alist token 刷新时间
|
|
||||||
# 86400--1天 604800--7天 1296000--15天 2592000--30天 15552000--180天
|
|
||||||
token_exp = 86400
|
|
||||||
# alist 可直接使用 token 登录, 此时 username, password, token_exp 将被忽略
|
|
||||||
# 请自行在 alist 侧配置合理的 token 过期时间
|
|
||||||
# token = ""
|
|
||||||
|
|
||||||
[[storages]]
|
[[storages]]
|
||||||
name = "MyWebdav"
|
name = "MyWebdav"
|
||||||
type = "webdav"
|
type = "webdav"
|
||||||
@@ -74,31 +38,17 @@ url = 'https://example.com/dav'
|
|||||||
username = 'username'
|
username = 'username'
|
||||||
password = 'password'
|
password = 'password'
|
||||||
|
|
||||||
[[storages]]
|
# 用户列表
|
||||||
name = "MyMinio"
|
[[users]]
|
||||||
type = "minio"
|
# telegram user id
|
||||||
enable = true
|
id = 114514
|
||||||
endpoint = 'play.min.io'
|
# 存储过滤列表, 元素为存储标识名.
|
||||||
use_ssl = true
|
# 将该列表留空并开启黑名单过滤模式以允许使用所有存储,此处示例为黑名单模式,用户 114514 可使用所有存储
|
||||||
access_key_id = 'Q3AM3UQ867SPQQA43P2F'
|
storages = []
|
||||||
secret_access_key = 'zuf+tfteSlswRu7BJ86wekitnifILbZam1KYY3TG'
|
# 使用列表过滤黑名单模式,反之则为白名单,白名单请在列表中指定可用的存储.
|
||||||
bucket_name = 'saveanybot'
|
blacklist = true
|
||||||
base_path = '/path/telegram'
|
|
||||||
|
|
||||||
|
|
||||||
# 其他配置
|
|
||||||
|
|
||||||
# [log]
|
|
||||||
# # 日志等级
|
|
||||||
# level = "DEBUG"
|
|
||||||
|
|
||||||
# [temp]
|
|
||||||
# # 下载文件临时目录, 请不要在此目录下存放任何其他文件
|
|
||||||
# base_path = "cache/"
|
|
||||||
# # 临时文件保存时间, 单位: 秒
|
|
||||||
# cache_ttl = 30
|
|
||||||
|
|
||||||
# [db]
|
|
||||||
# path = "data/data.db" # 数据库文件路径
|
|
||||||
# session = "data/session.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"`
|
||||||
|
}
|
||||||
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"`
|
||||||
|
// }
|
||||||
15
config/parser.go
Normal file
15
config/parser.go
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
type parserConfig struct {
|
||||||
|
PluginEnable bool `toml:"plugin_enable" mapstructure:"plugin_enable" json:"plugin_enable"`
|
||||||
|
PluginDirs []string `toml:"plugin_dirs" mapstructure:"plugin_dirs" json:"plugin_dirs"`
|
||||||
|
Proxy string `toml:"proxy" mapstructure:"proxy" json:"proxy"`
|
||||||
|
ParserCfgs map[string]map[string]any `mapstructure:",remain"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c Config) GetParserConfigByName(name string) map[string]any {
|
||||||
|
if c.Parser.ParserCfgs == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return c.Parser.ParserCfgs[name]
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ package storage
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"github.com/krau/SaveAny-Bot/types"
|
storenum "github.com/krau/SaveAny-Bot/pkg/enums/storage"
|
||||||
)
|
)
|
||||||
|
|
||||||
type AlistStorageConfig struct {
|
type AlistStorageConfig struct {
|
||||||
@@ -29,8 +29,8 @@ func (a *AlistStorageConfig) Validate() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *AlistStorageConfig) GetType() types.StorageType {
|
func (a *AlistStorageConfig) GetType() storenum.StorageType {
|
||||||
return types.StorageTypeAlist
|
return storenum.Alist
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *AlistStorageConfig) GetName() string {
|
func (a *AlistStorageConfig) GetName() string {
|
||||||
|
|||||||
@@ -4,16 +4,17 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"reflect"
|
"reflect"
|
||||||
|
|
||||||
"github.com/krau/SaveAny-Bot/types"
|
storenum "github.com/krau/SaveAny-Bot/pkg/enums/storage"
|
||||||
"github.com/mitchellh/mapstructure"
|
"github.com/mitchellh/mapstructure"
|
||||||
"github.com/spf13/viper"
|
"github.com/spf13/viper"
|
||||||
)
|
)
|
||||||
|
|
||||||
var storageFactories = map[types.StorageType]func(cfg *BaseConfig) (StorageConfig, error){
|
var storageFactories = map[storenum.StorageType]func(cfg *BaseConfig) (StorageConfig, error){
|
||||||
types.StorageTypeLocal: createStorageConfig(&LocalStorageConfig{}),
|
storenum.Local: createStorageConfig(&LocalStorageConfig{}),
|
||||||
types.StorageTypeAlist: createStorageConfig(&AlistStorageConfig{}),
|
storenum.Alist: createStorageConfig(&AlistStorageConfig{}),
|
||||||
types.StorageTypeWebdav: createStorageConfig(&WebdavStorageConfig{}),
|
storenum.Webdav: createStorageConfig(&WebdavStorageConfig{}),
|
||||||
types.StorageTypeMinio: createStorageConfig(&MinioStorageConfig{}),
|
storenum.Minio: createStorageConfig(&MinioStorageConfig{}),
|
||||||
|
storenum.Telegram: createStorageConfig(&TelegramStorageConfig{}),
|
||||||
}
|
}
|
||||||
|
|
||||||
func createStorageConfig(configType StorageConfig) func(cfg *BaseConfig) (StorageConfig, error) {
|
func createStorageConfig(configType StorageConfig) func(cfg *BaseConfig) (StorageConfig, error) {
|
||||||
@@ -41,8 +42,12 @@ func LoadStorageConfigs(v *viper.Viper) ([]StorageConfig, error) {
|
|||||||
if !baseCfg.Enable {
|
if !baseCfg.Enable {
|
||||||
continue
|
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[types.StorageType(baseCfg.Type)]
|
factory, ok := storageFactories[st]
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, fmt.Errorf("unsupported storage type: %s", baseCfg.Type)
|
return nil, fmt.Errorf("unsupported storage type: %s", baseCfg.Type)
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user