Compare commits
113 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
9d3a3a8dcd | ||
|
|
19535d0438 | ||
|
|
693e20b066 | ||
|
|
56ea1d6f36 | ||
|
|
95522d03f9 | ||
|
|
2bc290b57d | ||
|
|
c7c458f147 | ||
|
|
481427683e | ||
|
|
c798c7ae99 | ||
|
|
0422c1ac3e | ||
|
|
f0445fe26f | ||
|
|
a3628be024 | ||
|
|
a9c56892c3 | ||
|
|
015539c009 | ||
|
|
71844deab1 | ||
|
|
55fed6389e | ||
|
|
8ce5c2e007 | ||
|
|
6ecfbd8385 | ||
|
|
6c2bfd72cd | ||
|
|
8ea5be5b90 | ||
|
|
7f483056e0 | ||
|
|
a6f88d7f75 | ||
|
|
b757df0b5e | ||
|
|
b017046c8b | ||
|
|
a474fdf6ae | ||
|
|
729e688748 | ||
|
|
9ea4857cd9 | ||
|
|
8bf7bc0e85 | ||
|
|
26e344a6f6 | ||
|
|
8f0744077e | ||
|
|
ed99a37831 | ||
|
|
488d709d85 | ||
|
|
66454b082a | ||
|
|
70e83e62d9 | ||
|
|
d2ddb9193a | ||
|
|
5f78db90c7 | ||
|
|
c3a4702e79 | ||
|
|
e731cfee9e | ||
|
|
75de86fe97 | ||
|
|
6d4e97b4bb | ||
|
|
239d5ad562 | ||
|
|
e76f191922 | ||
|
|
a619ed2f22 | ||
|
|
838dfc35a1 | ||
|
|
6ecee6d561 | ||
|
|
c1827f93a9 | ||
|
|
aaf3f7c35f | ||
|
|
02fbea4eb0 | ||
|
|
bf9aef6eb7 | ||
|
|
230c07fd55 | ||
|
|
18de349dc3 | ||
|
|
cef1a5c194 | ||
|
|
99f8f0cb27 | ||
|
|
789c14134c | ||
|
|
5bb3b595aa | ||
|
|
609289c16a | ||
|
|
c8c348a182 | ||
|
|
725acd0199 | ||
|
|
166c27c70f | ||
|
|
3bdef20e85 | ||
|
|
50fba3f910 | ||
|
|
87d3f14392 | ||
|
|
30452c8d46 |
11
.dockerignore
Normal file
11
.dockerignore
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
*.md
|
||||||
|
.git
|
||||||
|
.github/
|
||||||
|
.gitignore
|
||||||
|
.vscode/
|
||||||
|
downloads/
|
||||||
|
data/
|
||||||
|
cache/
|
||||||
|
docs/
|
||||||
|
config.example.toml
|
||||||
|
docker-compose.*
|
||||||
5
.github/FUNDING.yml
vendored
Normal file
5
.github/FUNDING.yml
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# These are supported funding model platforms
|
||||||
|
|
||||||
|
custom: [
|
||||||
|
"https://afdian.com/a/unvapp"
|
||||||
|
]
|
||||||
39
.github/ISSUE_TEMPLATE/bug.yml
vendored
Normal file
39
.github/ISSUE_TEMPLATE/bug.yml
vendored
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
name: "👾 报告 bug"
|
||||||
|
description: "报告 bug"
|
||||||
|
labels:
|
||||||
|
- "bug"
|
||||||
|
assignees:
|
||||||
|
- krau
|
||||||
|
body:
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: "👾 问题描述"
|
||||||
|
description: "What happened?"
|
||||||
|
placeholder: "When called ... happens ..."
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: "⚡️ 预期行为"
|
||||||
|
description: "What was expected?"
|
||||||
|
placeholder: "It should be ..."
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: "📄 配置文件"
|
||||||
|
description: "Please provide your config file"
|
||||||
|
placeholder: "请自行隐去密钥信息"
|
||||||
|
render: toml
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: "🔍 日志"
|
||||||
|
description: "Please provide logs"
|
||||||
|
placeholder: "可删除隐私信息"
|
||||||
|
render: shell
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
## Thank you for contributing to the project :slightly_smiling_face:
|
||||||
8
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
8
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
blank_issues_enabled: true
|
||||||
|
contact_links:
|
||||||
|
- name: 💬 不知道如何正确使用?
|
||||||
|
url: https://github.com/krau/SaveAny-Bot/discussions
|
||||||
|
about: "前往讨论区提问"
|
||||||
|
- name: 📄 文档
|
||||||
|
url: https://sabot.unv.app
|
||||||
|
about: "查看文档"
|
||||||
33
.github/ISSUE_TEMPLATE/feature.yml
vendored
Normal file
33
.github/ISSUE_TEMPLATE/feature.yml
vendored
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
name: "⭐️ 功能请求"
|
||||||
|
description: "功能请求"
|
||||||
|
labels:
|
||||||
|
- "enhancement"
|
||||||
|
assignees:
|
||||||
|
- krau
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
# 请详细描述你想要的功能
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: "⭐️ Feature description"
|
||||||
|
description: "What new feature you want to see?"
|
||||||
|
placeholder: "Add ... in order to ..."
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: "🌈 Your view"
|
||||||
|
description: "How do you see this feature will be used and/or implemented?"
|
||||||
|
placeholder: "It should be like ..."
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: "🧐 Code example"
|
||||||
|
description: "You can provide code (or pseudocode) example"
|
||||||
|
placeholder: "Cool code that will work ..."
|
||||||
|
render: Go
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
## Thank you for contributing to the project :slightly_smiling_face:
|
||||||
39
.github/workflows/build-docker.yml
vendored
39
.github/workflows/build-docker.yml
vendored
@@ -20,6 +20,17 @@ jobs:
|
|||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Extract metadata for Docker
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||||
|
tags: |
|
||||||
|
type=semver,pattern={{version}}
|
||||||
|
type=semver,pattern={{major}}.{{minor}}
|
||||||
|
type=sha
|
||||||
|
type=raw,value=latest,enable={{is_default_branch}}
|
||||||
|
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v3
|
uses: docker/setup-qemu-action@v3
|
||||||
|
|
||||||
@@ -33,28 +44,20 @@ jobs:
|
|||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Extract metadata for Docker
|
|
||||||
id: meta
|
|
||||||
uses: docker/metadata-action@v5
|
|
||||||
with:
|
|
||||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
|
||||||
tags: |
|
|
||||||
type=semver,pattern={{version}}
|
|
||||||
type=semver,pattern={{major}}.{{minor}}
|
|
||||||
type=sha
|
|
||||||
type=raw,value=latest
|
|
||||||
type=ref,event=branch
|
|
||||||
type=ref,event=tag
|
|
||||||
labels: |
|
|
||||||
org.opencontainers.image.title=${{ env.IMAGE_NAME }}
|
|
||||||
org.opencontainers.image.source=https://github.com/krau/SaveAny-Bot
|
|
||||||
org.opencontainers.image.url=https://github.com/krau/SaveAny-Bot
|
|
||||||
|
|
||||||
- name: Build and push Docker image
|
- 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
|
||||||
push: true
|
push: ${{ github.event_name != 'pull_request' }}
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
cache-from: |
|
||||||
|
type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
|
||||||
|
type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
|
build-args: |
|
||||||
|
VERSION=${{ steps.meta.outputs.version }}
|
||||||
|
GitCommit=${{ github.sha }}
|
||||||
|
BuildTime=${{ fromJson(toJSON(github.event.repository.pushed_at)) }}
|
||||||
|
|||||||
7
.github/workflows/build-release.yml
vendored
7
.github/workflows/build-release.yml
vendored
@@ -58,15 +58,14 @@ jobs:
|
|||||||
goos: ${{ matrix.goos }}
|
goos: ${{ matrix.goos }}
|
||||||
goarch: ${{ matrix.goarch }}
|
goarch: ${{ matrix.goarch }}
|
||||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
executable_compression: upx
|
|
||||||
extra_files: |
|
extra_files: |
|
||||||
LICENSE
|
LICENSE
|
||||||
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/pkg/consts.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/pkg/consts.BuildTime=${{ format(github.event.repository.updated_at, 'yyyy-MM-dd HH:mm:ss') }}"
|
||||||
-X "github.com/krau/SaveAny-Bot/common.GitCommit=${{ github.sha }}"
|
-X "github.com/krau/SaveAny-Bot/pkg/consts.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
|
||||||
17
.github/workflows/update-contributors.yml
vendored
Normal file
17
.github/workflows/update-contributors.yml
vendored
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
name: Update Contributors
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
contrib-readme-job:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
name: A job to automate contrib in readme
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
pull-requests: write
|
||||||
|
steps:
|
||||||
|
- name: Contribute List
|
||||||
|
uses: akhilmhdh/contributors-readme-action@v2.3.10
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -3,6 +3,8 @@ logs/
|
|||||||
tmp/
|
tmp/
|
||||||
data/
|
data/
|
||||||
downloads/
|
downloads/
|
||||||
cache/
|
|
||||||
session.*
|
session.*
|
||||||
cache.db
|
cache.db
|
||||||
|
.vscode/
|
||||||
|
temp/
|
||||||
|
.hugo_build.lock
|
||||||
15
.vscode/launch.json
vendored
15
.vscode/launch.json
vendored
@@ -1,15 +0,0 @@
|
|||||||
{
|
|
||||||
// 使用 IntelliSense 了解相关属性。
|
|
||||||
// 悬停以查看现有属性的描述。
|
|
||||||
// 欲了解更多信息,请访问: https://go.microsoft.com/fwlink/?linkid=830387
|
|
||||||
"version": "0.2.0",
|
|
||||||
"configurations": [
|
|
||||||
{
|
|
||||||
"name": "Launch Package",
|
|
||||||
"type": "go",
|
|
||||||
"request": "launch",
|
|
||||||
"mode": "auto",
|
|
||||||
"program": "main.go",
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
28
Dockerfile
28
Dockerfile
@@ -1,18 +1,36 @@
|
|||||||
FROM golang:alpine AS builder
|
FROM golang:alpine AS builder
|
||||||
|
|
||||||
|
ARG VERSION="dev"
|
||||||
|
ARG GitCommit="Unknown"
|
||||||
|
ARG BuildTime="Unknown"
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY go.* ./
|
COPY go.mod go.sum ./
|
||||||
RUN go mod download
|
RUN --mount=type=cache,target=/go/pkg/mod \
|
||||||
|
go mod download
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
RUN --mount=type=cache,target=/root/.cache/go-build \
|
||||||
RUN CGO_ENABLED=0 GOOS=linux go build -o saveany-bot .
|
--mount=type=cache,target=/go/pkg \
|
||||||
|
CGO_ENABLED=0 \
|
||||||
|
go build -trimpath \
|
||||||
|
-ldflags "-s -w \
|
||||||
|
-X github.com/krau/SaveAny-Bot/common.Version=${VERSION} \
|
||||||
|
-X github.com/krau/SaveAny-Bot/common.GitCommit=${GitCommit} \
|
||||||
|
-X github.com/krau/SaveAny-Bot/common.BuildTime=${BuildTime}" \
|
||||||
|
-o saveany-bot .
|
||||||
|
|
||||||
FROM alpine:latest
|
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 .
|
||||||
|
|
||||||
CMD ["./saveany-bot"]
|
RUN chmod +x /app/saveany-bot && \
|
||||||
|
chmod +x /app/entrypoint.sh
|
||||||
|
|
||||||
|
ENTRYPOINT ["/app/entrypoint.sh"]
|
||||||
161
README.md
161
README.md
@@ -1,119 +1,90 @@
|
|||||||
<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 一样_
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
## 部署
|
## 部署
|
||||||
|
|
||||||
### 从二进制文件部署
|
请参考 [部署文档](https://sabot.unv.app/deployment/installation/)
|
||||||
|
|
||||||
在 [Release](https://github.com/krau/SaveAny-Bot/releases) 页面下载对应平台的二进制文件.
|
## Features
|
||||||
|
|
||||||
在解压后目录新建 `config.toml` 文件, 参考 [config.example.toml](./config.example.toml) 编辑配置文件.
|
- 支持文档/视频/图片/贴纸… 甚至还有 Telegraph
|
||||||
|
- 破解禁止保存的文件
|
||||||
|
- 批量下载
|
||||||
|
- 流式传输
|
||||||
|
- 多用户
|
||||||
|
- 基于存储规则的自动整理
|
||||||
|
- 支持多种存储端:
|
||||||
|
- Alist
|
||||||
|
- Minio (S3 兼容)
|
||||||
|
- WebDAV
|
||||||
|
- Telegram (重传回指定聊天)
|
||||||
|
- 本地磁盘
|
||||||
|
|
||||||
运行:
|
## Sponsors
|
||||||
|
|
||||||
```bash
|
本项目受到 [YxVM](https://yxvm.com/) 与 [NodeSupport](https://github.com/NodeSeekDev/NodeSupport) 的支持.
|
||||||
chmod +x saveany-bot
|
|
||||||
./saveany-bot
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 添加为 systemd 服务
|
如果这个项目对你有帮助, 你可以考虑通过以下方式赞助我:
|
||||||
|
|
||||||
创建文件 `/etc/systemd/system/saveany-bot.service` 并写入以下内容:
|
- [爱发电](https://afdian.com/a/unvapp)
|
||||||
|
|
||||||
```
|
## Contributors
|
||||||
[Unit]
|
|
||||||
Description=SaveAnyBot
|
|
||||||
After=systemd-user-sessions.service
|
|
||||||
|
|
||||||
[Service]
|
<!-- readme: contributors -start -->
|
||||||
Type=simple
|
<table>
|
||||||
WorkingDirectory=/yourpath/
|
<tbody>
|
||||||
ExecStart=/yourpath/saveany-bot
|
<tr>
|
||||||
Restart=on-failure
|
<td align="center">
|
||||||
|
<a href="https://github.com/krau">
|
||||||
[Install]
|
<img src="https://avatars.githubusercontent.com/u/71133316?v=4" width="100;" alt="krau"/>
|
||||||
WantedBy=multi-user.target
|
<br />
|
||||||
```
|
<sub><b>Krau</b></sub>
|
||||||
|
</a>
|
||||||
设为开机启动并启动服务:
|
</td>
|
||||||
|
<td align="center">
|
||||||
```bash
|
<a href="https://github.com/Silentely">
|
||||||
systemctl enable --now saveany-bot
|
<img src="https://avatars.githubusercontent.com/u/22141172?v=4" width="100;" alt="Silentely"/>
|
||||||
```
|
<br />
|
||||||
|
<sub><b>Abner</b></sub>
|
||||||
#### 为OpenWrt及衍生系统添加开机自启动服务
|
</a>
|
||||||
|
</td>
|
||||||
创建文件 ` /etc/init.d/saveanybot` ,参考[saveanybot](./docs/saveanybot)自行修改.
|
<td align="center">
|
||||||
|
<a href="https://github.com/TG-Twilight">
|
||||||
`chmod +x /etc/init.d/saveanybot`
|
<img src="https://avatars.githubusercontent.com/u/121682528?v=4" width="100;" alt="TG-Twilight"/>
|
||||||
|
<br />
|
||||||
完成后,将文件复制到 `/etc/rc.d`并重命名为`S99saveanybot`.
|
<sub><b>Simon Twilight</b></sub>
|
||||||
|
</a>
|
||||||
`chmod +x /etc/rc.d/S99saveanybot`
|
</td>
|
||||||
|
<td align="center">
|
||||||
#### 为OpenWrt及衍生系统添加快捷指令
|
<a href="https://github.com/ysicing">
|
||||||
|
<img src="https://avatars.githubusercontent.com/u/8605565?v=4" width="100;" alt="ysicing"/>
|
||||||
创建文件` /usr/bin/sabot` ,参考[sabot](./docs/sabot)自行配置修改,注意此处文件编码仅支持 ANSI 936 .
|
<br />
|
||||||
|
<sub><b>缘生</b></sub>
|
||||||
`chmod +x /usr/bin/sabot`
|
</a>
|
||||||
|
</td>
|
||||||
之后,终端输入`sabot start|stop|restart|status|enable|disable`即可.
|
<td align="center">
|
||||||
|
<a href="https://github.com/AHCorn">
|
||||||
|
<img src="https://avatars.githubusercontent.com/u/42889600?v=4" width="100;" alt="AHCorn"/>
|
||||||
### 使用 Docker 部署
|
<br />
|
||||||
|
<sub><b>安和</b></sub>
|
||||||
#### Docker Compose
|
</a>
|
||||||
|
</td>
|
||||||
下载 [docker-compose.yml](./docker-compose.yml) 文件, 在同目录下新建 `config.toml` 文件, 参考 [config.example.toml](./config.example.toml) 编辑配置文件.
|
</tr>
|
||||||
|
<tbody>
|
||||||
启动:
|
</table>
|
||||||
|
<!-- readme: contributors -end -->
|
||||||
```bash
|
|
||||||
docker compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Docker
|
|
||||||
|
|
||||||
```shell
|
|
||||||
docker run -d --name saveany-bot \
|
|
||||||
-v /path/to/config.toml:/app/config.toml \
|
|
||||||
-v /path/to/downloads:/app/downloads \
|
|
||||||
ghcr.io/krau/saveany-bot:latest
|
|
||||||
```
|
|
||||||
|
|
||||||
## 更新
|
|
||||||
|
|
||||||
使用 `upgrade` 或 `up` 升级到最新版
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./saveany-bot upgrade
|
|
||||||
```
|
|
||||||
|
|
||||||
如果是 Docker 部署, 使用以下命令更新:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker pull ghcr.io/krau/saveany-bot:latest
|
|
||||||
docker restart saveany-bot
|
|
||||||
```
|
|
||||||
|
|
||||||
## 使用
|
|
||||||
|
|
||||||
向 Bot 发送(转发)文件, 或发送公开频道的消息链接, 按照提示操作.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Thanks
|
## Thanks
|
||||||
|
|
||||||
- [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
|
||||||
|
|||||||
100
README_EN.md
100
README_EN.md
@@ -1,100 +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.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Thanks
|
|
||||||
|
|
||||||
- [gotd](https://github.com/gotd/td)
|
|
||||||
- [TG-FileStreamBot](https://github.com/EverythingSuckz/TG-FileStreamBot)
|
|
||||||
- [gotgproto](https://github.com/celestix/gotgproto)
|
|
||||||
- All the dependencies
|
|
||||||
@@ -1,207 +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"
|
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
|
||||||
|
|
||||||
func AddToQueue(ctx *ext.Context, update *ext.Update) error {
|
|
||||||
// TODO: 回调数据用户独立鉴权 (处理 bot 在群聊中的情况)
|
|
||||||
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 {
|
|
||||||
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,
|
|
||||||
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,75 +0,0 @@
|
|||||||
package bot
|
|
||||||
|
|
||||||
import (
|
|
||||||
"sync"
|
|
||||||
)
|
|
||||||
|
|
||||||
type ConversationType string
|
|
||||||
|
|
||||||
type ConversationState struct {
|
|
||||||
sync.Mutex
|
|
||||||
conversationType ConversationType
|
|
||||||
InConversation bool
|
|
||||||
data map[ConversationType]map[string]interface{}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *ConversationState) Reset() {
|
|
||||||
c.Lock()
|
|
||||||
defer c.Unlock()
|
|
||||||
c.InConversation = false
|
|
||||||
c.conversationType = ""
|
|
||||||
c.data = make(map[ConversationType]map[string]interface{})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *ConversationState) SetConversationType(t ConversationType) {
|
|
||||||
c.Lock()
|
|
||||||
defer c.Unlock()
|
|
||||||
c.conversationType = t
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *ConversationState) GetData(key string) interface{} {
|
|
||||||
if c.data == nil || c.data[c.conversationType] == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return c.data[c.conversationType][key]
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *ConversationState) SetData(key string, value interface{}) {
|
|
||||||
c.Lock()
|
|
||||||
defer c.Unlock()
|
|
||||||
if c.data == nil {
|
|
||||||
c.data = make(map[ConversationType]map[string]interface{})
|
|
||||||
}
|
|
||||||
if c.data[c.conversationType] == nil {
|
|
||||||
c.data[c.conversationType] = make(map[string]interface{})
|
|
||||||
}
|
|
||||||
c.data[c.conversationType][key] = value
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: Implement conversation handling
|
|
||||||
// var userConversationState = make(map[int64]*ConversationState)
|
|
||||||
|
|
||||||
// func handleConversation(ctx *ext.Context, update *ext.Update) error {
|
|
||||||
// userID := update.EffectiveUser().GetID()
|
|
||||||
// state, ok := userConversationState[userID]
|
|
||||||
// if !ok {
|
|
||||||
// return dispatcher.ContinueGroups
|
|
||||||
// }
|
|
||||||
// if update.EffectiveMessage.Text == "/cancel" {
|
|
||||||
// state.Reset()
|
|
||||||
// ctx.Reply(update, ext.ReplyTextString("已取消"), nil)
|
|
||||||
// return dispatcher.EndGroups
|
|
||||||
// }
|
|
||||||
// if !state.InConversation {
|
|
||||||
// return dispatcher.ContinueGroups
|
|
||||||
// }
|
|
||||||
// return handleConversationState(ctx, update, state)
|
|
||||||
// }
|
|
||||||
|
|
||||||
// func handleConversationState(ctx *ext.Context, update *ext.Update, state *ConversationState) error {
|
|
||||||
// switch state.conversationType {
|
|
||||||
// default:
|
|
||||||
// common.Log.Errorf("Unknown conversation type: %s", state.conversationType)
|
|
||||||
// }
|
|
||||||
// return dispatcher.EndGroups
|
|
||||||
// }
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
package bot
|
|
||||||
|
|
||||||
import (
|
|
||||||
"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 dirCmd(ctx *ext.Context, update *ext.Update) error {
|
|
||||||
args := strings.Split(strings.TrimPrefix(update.EffectiveMessage.Text, "/dir "), " ")
|
|
||||||
if len(args) < 3 {
|
|
||||||
dirs, err := dao.GetUserDirsByChatID(update.GetUserChat().GetID())
|
|
||||||
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(" - 删除路径\n"),
|
|
||||||
styling.Plain("\n示例:\n"),
|
|
||||||
styling.Code("/dir add local1 path/to/dir"),
|
|
||||||
styling.Plain("\n\n当前已添加的路径:\n"),
|
|
||||||
styling.Blockquote(func() string {
|
|
||||||
var sb strings.Builder
|
|
||||||
for _, dir := range dirs {
|
|
||||||
sb.WriteString(dir.StorageName)
|
|
||||||
sb.WriteString(" - ")
|
|
||||||
sb.WriteString(dir.Path)
|
|
||||||
sb.WriteString("\n")
|
|
||||||
}
|
|
||||||
return sb.String()
|
|
||||||
}(), true),
|
|
||||||
},
|
|
||||||
), 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
|
|
||||||
}
|
|
||||||
switch args[0] {
|
|
||||||
case "add":
|
|
||||||
return addDir(ctx, update, user, args[1], args[2])
|
|
||||||
case "del":
|
|
||||||
return delDir(ctx, update, user, args[1], args[2])
|
|
||||||
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, user *dao.User, storageName, path string) error {
|
|
||||||
if err := dao.DeleteDirForUser(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
|
|
||||||
}
|
|
||||||
@@ -1,85 +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/storage"
|
|
||||||
"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)
|
|
||||||
}
|
|
||||||
|
|
||||||
if 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(),
|
|
||||||
}); 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,
|
|
||||||
File: file,
|
|
||||||
StorageName: user.DefaultStorage,
|
|
||||||
FileChatID: update.EffectiveChat().GetID(),
|
|
||||||
ReplyMessageID: msg.ID,
|
|
||||||
ReplyChatID: update.GetUserChat().GetID(),
|
|
||||||
FileMessageID: update.EffectiveMessage.ID,
|
|
||||||
UserID: user.ChatID,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,108 +0,0 @@
|
|||||||
package bot
|
|
||||||
|
|
||||||
import (
|
|
||||||
"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/storage"
|
|
||||||
"github.com/krau/SaveAny-Bot/types"
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
linkRegexString = `t.me/.*/\d+`
|
|
||||||
linkRegex = regexp.MustCompile(linkRegexString)
|
|
||||||
)
|
|
||||||
|
|
||||||
func handleLinkMessage(ctx *ext.Context, update *ext.Update) error {
|
|
||||||
common.Log.Trace("Got link message")
|
|
||||||
link := linkRegex.FindString(update.EffectiveMessage.Text)
|
|
||||||
if link == "" {
|
|
||||||
return dispatcher.ContinueGroups
|
|
||||||
}
|
|
||||||
strSlice := strings.Split(link, "/")
|
|
||||||
if len(strSlice) < 3 {
|
|
||||||
return dispatcher.ContinueGroups
|
|
||||||
}
|
|
||||||
messageID, err := strconv.Atoi(strSlice[2])
|
|
||||||
if err != nil {
|
|
||||||
common.Log.Errorf("解析消息 ID 失败: %s", err)
|
|
||||||
ctx.Reply(update, ext.ReplyTextString("无法解析消息 ID"), nil)
|
|
||||||
return dispatcher.EndGroups
|
|
||||||
}
|
|
||||||
chatUsername := strSlice[1]
|
|
||||||
linkChat, err := ctx.ResolveUsername(chatUsername)
|
|
||||||
if err != nil {
|
|
||||||
common.Log.Errorf("解析 Chat ID 失败: %s", err)
|
|
||||||
ctx.Reply(update, ext.ReplyTextString("无法解析 Chat ID"), nil)
|
|
||||||
return dispatcher.EndGroups
|
|
||||||
}
|
|
||||||
if linkChat == nil {
|
|
||||||
common.Log.Errorf("无法找到聊天: %s", chatUsername)
|
|
||||||
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
|
|
||||||
}
|
|
||||||
storages := storage.GetUserStorages(user.ChatID)
|
|
||||||
|
|
||||||
if len(storages) == 0 {
|
|
||||||
ctx.Reply(update, ext.ReplyTextString("无可用的存储"), nil)
|
|
||||||
return dispatcher.EndGroups
|
|
||||||
}
|
|
||||||
replied, err := ctx.Reply(update, ext.ReplyTextString("正在获取文件..."), nil)
|
|
||||||
if err != nil {
|
|
||||||
common.Log.Errorf("回复失败: %s", err)
|
|
||||||
return dispatcher.EndGroups
|
|
||||||
}
|
|
||||||
|
|
||||||
file, err := FileFromMessage(ctx, linkChat.GetID(), messageID, "")
|
|
||||||
if err != nil {
|
|
||||||
common.Log.Errorf("获取文件失败: %s", err)
|
|
||||||
ctx.Reply(update, ext.ReplyTextString("获取文件失败: "+err.Error()), nil)
|
|
||||||
return dispatcher.EndGroups
|
|
||||||
}
|
|
||||||
if file.FileName == "" {
|
|
||||||
file.FileName = GenFileNameFromMessage(*update.EffectiveMessage.Message, file)
|
|
||||||
}
|
|
||||||
|
|
||||||
receivedFile := &dao.ReceivedFile{
|
|
||||||
Processing: false,
|
|
||||||
FileName: file.FileName,
|
|
||||||
ChatID: linkChat.GetID(),
|
|
||||||
MessageID: messageID,
|
|
||||||
ReplyMessageID: replied.ID,
|
|
||||||
ReplyChatID: update.GetUserChat().GetID(),
|
|
||||||
}
|
|
||||||
if err := dao.SaveReceivedFile(receivedFile); err != nil {
|
|
||||||
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, file.FileName, linkChat.GetID(), messageID, replied.ID)
|
|
||||||
}
|
|
||||||
return HandleSilentAddTask(ctx, update, user, &types.Task{
|
|
||||||
Ctx: ctx,
|
|
||||||
Status: types.Pending,
|
|
||||||
File: file,
|
|
||||||
StorageName: user.DefaultStorage,
|
|
||||||
UserID: user.ChatID,
|
|
||||||
FileChatID: linkChat.GetID(),
|
|
||||||
FileMessageID: messageID,
|
|
||||||
ReplyMessageID: replied.ID,
|
|
||||||
ReplyChatID: update.GetUserChat().GetID(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,115 +0,0 @@
|
|||||||
package bot
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"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"
|
|
||||||
"github.com/krau/SaveAny-Bot/types"
|
|
||||||
)
|
|
||||||
|
|
||||||
func saveCmd(ctx *ext.Context, update *ext.Update) error {
|
|
||||||
res, ok := update.EffectiveMessage.GetReplyTo()
|
|
||||||
if !ok || res == nil {
|
|
||||||
ctx.Reply(update, ext.ReplyTextString("请回复要保存的文件"), nil)
|
|
||||||
return dispatcher.EndGroups
|
|
||||||
}
|
|
||||||
replyHeader, ok := res.(*tg.MessageReplyHeader)
|
|
||||||
if !ok {
|
|
||||||
ctx.Reply(update, ext.ReplyTextString("请回复要保存的文件"), nil)
|
|
||||||
return dispatcher.EndGroups
|
|
||||||
}
|
|
||||||
replyToMsgID, ok := replyHeader.GetReplyToMsgID()
|
|
||||||
if !ok {
|
|
||||||
ctx.Reply(update, ext.ReplyTextString("请回复要保存的文件"), nil)
|
|
||||||
return dispatcher.EndGroups
|
|
||||||
}
|
|
||||||
|
|
||||||
user, err := dao.GetUserByChatID(update.GetUserChat().GetID())
|
|
||||||
if err != nil {
|
|
||||||
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(),
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := dao.SaveReceivedFile(receivedFile); 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,
|
|
||||||
File: file,
|
|
||||||
StorageName: user.DefaultStorage,
|
|
||||||
FileChatID: update.EffectiveChat().GetID(),
|
|
||||||
ReplyMessageID: replied.ID,
|
|
||||||
ReplyChatID: update.GetUserChat().GetID(),
|
|
||||||
FileMessageID: msg.ID,
|
|
||||||
UserID: user.ChatID,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -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[:7])), 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,32 +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/krau/SaveAny-Bot/common"
|
|
||||||
)
|
|
||||||
|
|
||||||
func RegisterHandlers(dispatcher dispatcher.Dispatcher) {
|
|
||||||
dispatcher.AddHandler(handlers.NewMessage(filters.Message.All, checkPermission))
|
|
||||||
dispatcher.AddHandler(handlers.NewCommand("start", start))
|
|
||||||
dispatcher.AddHandler(handlers.NewCommand("help", help))
|
|
||||||
dispatcher.AddHandler(handlers.NewCommand("silent", silent))
|
|
||||||
dispatcher.AddHandler(handlers.NewCommand("storage", storageCmd))
|
|
||||||
dispatcher.AddHandler(handlers.NewCommand("save", saveCmd))
|
|
||||||
dispatcher.AddHandler(handlers.NewCommand("dir", dirCmd))
|
|
||||||
linkRegexFilter, err := filters.Message.Regex(linkRegexString)
|
|
||||||
if err != nil {
|
|
||||||
common.Log.Panicf("创建正则表达式过滤器失败: %s", err)
|
|
||||||
}
|
|
||||||
dispatcher.AddHandler(handlers.NewMessage(linkRegexFilter, handleLinkMessage))
|
|
||||||
telegraphUrlRegexFilter, err := filters.Message.Regex(TelegraphUrlRegexString)
|
|
||||||
if err != nil {
|
|
||||||
common.Log.Panicf("创建 Telegraph URL 正则表达式过滤器失败: %s", err)
|
|
||||||
}
|
|
||||||
dispatcher.AddHandler(handlers.NewMessage(telegraphUrlRegexFilter, handleTelegraph))
|
|
||||||
dispatcher.AddHandler(handlers.NewCallbackQuery(filters.CallbackQuery.Prefix("add"), AddToQueue))
|
|
||||||
dispatcher.AddHandler(handlers.NewCallbackQuery(filters.CallbackQuery.Prefix("set_default"), setDefaultStorage))
|
|
||||||
dispatcher.AddHandler(handlers.NewCallbackQuery(filters.CallbackQuery.Prefix("cancel"), cancelTask))
|
|
||||||
dispatcher.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(5)
|
|
||||||
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
|
|
||||||
}
|
|
||||||
322
bot/utils.go
322
bot/utils.go
@@ -1,322 +0,0 @@
|
|||||||
package bot
|
|
||||||
|
|
||||||
import (
|
|
||||||
"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/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: []byte(fmt.Sprintf("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)
|
|
||||||
}
|
|
||||||
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)
|
|
||||||
common.Log.Debugf("Getting file: %s", key)
|
|
||||||
var cachedFile types.File
|
|
||||||
err := common.Cache.Get(key, &cachedFile)
|
|
||||||
if err == nil {
|
|
||||||
return &cachedFile, nil
|
|
||||||
}
|
|
||||||
message, err := GetTGMessage(ctx, chatID, messageID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
file, err := FileFromMedia(message.Media, customFileName)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if err := common.Cache.Set(key, file, 3600); err != nil {
|
|
||||||
common.Log.Errorf("Failed to cache file: %s", err)
|
|
||||||
}
|
|
||||||
return file, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func GetTGMessage(ctx *ext.Context, chatId int64, messageID int) (*tg.Message, error) {
|
|
||||||
common.Log.Debugf("Fetching message: %d", messageID)
|
|
||||||
messages, err := ctx.GetMessages(chatId, []tg.InputMessageClass{&tg.InputMessageID{ID: messageID}})
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if len(messages) == 0 {
|
|
||||||
return nil, ErrEmptyMessage
|
|
||||||
}
|
|
||||||
msg := messages[0]
|
|
||||||
tgMessage, ok := msg.(*tg.Message)
|
|
||||||
if !ok {
|
|
||||||
return nil, fmt.Errorf("unexpected message type: %T", msg)
|
|
||||||
}
|
|
||||||
return tgMessage, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func ProvideSelectMessage(ctx *ext.Context, update *ext.Update, 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()))
|
|
||||||
}
|
|
||||||
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
|
|
||||||
}
|
|
||||||
@@ -2,35 +2,25 @@ package bot
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"net/url"
|
|
||||||
"os"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/celestix/gotgproto"
|
"github.com/celestix/gotgproto"
|
||||||
|
"github.com/celestix/gotgproto/dispatcher"
|
||||||
|
"github.com/celestix/gotgproto/ext"
|
||||||
"github.com/celestix/gotgproto/sessionMaker"
|
"github.com/celestix/gotgproto/sessionMaker"
|
||||||
"github.com/glebarez/sqlite"
|
"github.com/charmbracelet/log"
|
||||||
"github.com/gotd/td/telegram/dcs"
|
"github.com/gotd/td/telegram/dcs"
|
||||||
"github.com/gotd/td/tg"
|
"github.com/gotd/td/tg"
|
||||||
"github.com/krau/SaveAny-Bot/common"
|
"github.com/krau/SaveAny-Bot/client/bot/handlers"
|
||||||
|
"github.com/krau/SaveAny-Bot/client/middleware"
|
||||||
|
"github.com/krau/SaveAny-Bot/common/utils/netutil"
|
||||||
"github.com/krau/SaveAny-Bot/config"
|
"github.com/krau/SaveAny-Bot/config"
|
||||||
|
"github.com/ncruces/go-sqlite3/gormlite"
|
||||||
"golang.org/x/net/proxy"
|
"golang.org/x/net/proxy"
|
||||||
)
|
)
|
||||||
|
|
||||||
var Client *gotgproto.Client
|
func Init(ctx context.Context) {
|
||||||
|
log.FromContext(ctx).Info("初始化 Bot...")
|
||||||
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() {
|
|
||||||
InitTelegraphClient()
|
|
||||||
common.Log.Info("初始化 Telegram 客户端...")
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
resultChan := make(chan struct {
|
resultChan := make(chan struct {
|
||||||
client *gotgproto.Client
|
client *gotgproto.Client
|
||||||
err error
|
err error
|
||||||
@@ -38,7 +28,7 @@ func Init() {
|
|||||||
go func() {
|
go func() {
|
||||||
var resolver dcs.Resolver
|
var resolver dcs.Resolver
|
||||||
if config.Cfg.Telegram.Proxy.Enable && config.Cfg.Telegram.Proxy.URL != "" {
|
if config.Cfg.Telegram.Proxy.Enable && config.Cfg.Telegram.Proxy.URL != "" {
|
||||||
dialer, err := newProxyDialer(config.Cfg.Telegram.Proxy.URL)
|
dialer, err := netutil.NewProxyDialer(config.Cfg.Telegram.Proxy.URL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
resultChan <- struct {
|
resultChan <- struct {
|
||||||
client *gotgproto.Client
|
client *gotgproto.Client
|
||||||
@@ -52,14 +42,22 @@ func Init() {
|
|||||||
} else {
|
} else {
|
||||||
resolver = dcs.DefaultResolver()
|
resolver = dcs.DefaultResolver()
|
||||||
}
|
}
|
||||||
client, err := gotgproto.NewClient(config.Cfg.Telegram.AppID,
|
client, err := gotgproto.NewClient(
|
||||||
|
config.Cfg.Telegram.AppID,
|
||||||
config.Cfg.Telegram.AppHash,
|
config.Cfg.Telegram.AppHash,
|
||||||
gotgproto.ClientTypeBot(config.Cfg.Telegram.Token),
|
gotgproto.ClientTypeBot(config.Cfg.Telegram.Token),
|
||||||
&gotgproto.ClientOpts{
|
&gotgproto.ClientOpts{
|
||||||
Session: sessionMaker.SqlSession(sqlite.Open("data/session.db")),
|
Session: sessionMaker.SqlSession(gormlite.Open(config.Cfg.DB.Session)),
|
||||||
DisableCopyright: true,
|
DisableCopyright: true,
|
||||||
Middlewares: FloodWaitMiddleware(),
|
Middlewares: middleware.NewDefaultMiddlewares(ctx, 5*time.Minute),
|
||||||
Resolver: resolver,
|
Resolver: resolver,
|
||||||
|
Context: ctx,
|
||||||
|
MaxRetries: config.Cfg.Telegram.RpcRetry,
|
||||||
|
AutoFetchReply: true,
|
||||||
|
ErrorHandler: func(ctx *ext.Context, u *ext.Update, s string) error {
|
||||||
|
log.FromContext(ctx).Errorf("Unhandled error: %s", s)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -69,6 +67,9 @@ func Init() {
|
|||||||
}{nil, err}
|
}{nil, err}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
client.API().BotsSetBotCommands(ctx, &tg.BotsSetBotCommandsRequest{
|
||||||
|
Scope: &tg.BotCommandScopeDefault{},
|
||||||
|
})
|
||||||
_, err = client.API().BotsSetBotCommands(ctx, &tg.BotsSetBotCommandsRequest{
|
_, err = client.API().BotsSetBotCommands(ctx, &tg.BotsSetBotCommandsRequest{
|
||||||
Scope: &tg.BotCommandScopeDefault{},
|
Scope: &tg.BotCommandScopeDefault{},
|
||||||
Commands: []tg.BotCommand{
|
Commands: []tg.BotCommand{
|
||||||
@@ -78,6 +79,7 @@ func Init() {
|
|||||||
{Command: "storage", Description: "设置默认存储端"},
|
{Command: "storage", Description: "设置默认存储端"},
|
||||||
{Command: "save", Description: "保存所回复的文件"},
|
{Command: "save", Description: "保存所回复的文件"},
|
||||||
{Command: "dir", Description: "管理存储文件夹"},
|
{Command: "dir", Description: "管理存储文件夹"},
|
||||||
|
{Command: "rule", Description: "管理规则"},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
resultChan <- struct {
|
resultChan <- struct {
|
||||||
@@ -88,15 +90,12 @@ func Init() {
|
|||||||
|
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
common.Log.Fatal("初始化客户端失败: 超时")
|
log.FromContext(ctx).Errorf("已取消 Bot 初始化: %s", ctx.Err())
|
||||||
os.Exit(1)
|
|
||||||
case result := <-resultChan:
|
case result := <-resultChan:
|
||||||
if result.err != nil {
|
if result.err != nil {
|
||||||
common.Log.Fatalf("初始化客户端失败: %s", result.err)
|
log.FromContext(ctx).Fatalf("初始化 Bot 失败: %s", result.err)
|
||||||
os.Exit(1)
|
|
||||||
}
|
}
|
||||||
Client = result.client
|
handlers.Register(result.client.Dispatcher)
|
||||||
RegisterHandlers(Client.Dispatcher)
|
log.FromContext(ctx).Info("Bot 初始化完成")
|
||||||
common.Log.Info("客户端初始化完成")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
80
client/bot/handlers/add_task.go
Normal file
80
client/bot/handlers/add_task.go
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/celestix/gotgproto/dispatcher"
|
||||||
|
"github.com/celestix/gotgproto/ext"
|
||||||
|
"github.com/charmbracelet/log"
|
||||||
|
"github.com/gotd/td/tg"
|
||||||
|
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/msgelem"
|
||||||
|
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/shortcut"
|
||||||
|
"github.com/krau/SaveAny-Bot/database"
|
||||||
|
"github.com/krau/SaveAny-Bot/pkg/enums/tasktype"
|
||||||
|
"github.com/krau/SaveAny-Bot/pkg/tcbdata"
|
||||||
|
"github.com/krau/SaveAny-Bot/storage"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
func handleAddCallback(ctx *ext.Context, update *ext.Update) error {
|
||||||
|
dataid := strings.Split(string(update.CallbackQuery.Data), " ")[1]
|
||||||
|
data, err := shortcut.GetCallbackDataWithAnswer[tcbdata.Add](ctx, update, dataid)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
queryID := update.CallbackQuery.GetQueryID()
|
||||||
|
msgID := update.CallbackQuery.GetMsgID()
|
||||||
|
userID := update.CallbackQuery.GetUserID()
|
||||||
|
|
||||||
|
selectedStorage, err := storage.GetStorageByUserIDAndName(ctx, userID, data.SelectedStorName)
|
||||||
|
if err != nil {
|
||||||
|
log.FromContext(ctx).Errorf("Failed to get storage: %s", err)
|
||||||
|
ctx.AnswerCallback(msgelem.AlertCallbackAnswer(queryID, "存储获取失败: "+err.Error()))
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
dirs, err := database.GetDirsByUserChatIDAndStorageName(ctx, userID, data.SelectedStorName)
|
||||||
|
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return fmt.Errorf("获取用户目录失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !data.SettedDir && len(dirs) != 0 {
|
||||||
|
// ask for directory selection
|
||||||
|
markup, err := msgelem.BuildSetDirKeyboard(dirs, dataid)
|
||||||
|
if err != nil {
|
||||||
|
log.FromContext(ctx).Errorf("Failed to build directory keyboard: %s", err)
|
||||||
|
ctx.AnswerCallback(msgelem.AlertCallbackAnswer(queryID, "目录键盘构建失败: "+err.Error()))
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
|
||||||
|
ID: update.CallbackQuery.GetMsgID(),
|
||||||
|
Message: "请选择要存储到的目录",
|
||||||
|
ReplyMarkup: markup,
|
||||||
|
})
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
|
||||||
|
dirPath := ""
|
||||||
|
if data.DirID != 0 {
|
||||||
|
dir, err := database.GetDirByID(ctx, data.DirID)
|
||||||
|
if err != nil {
|
||||||
|
ctx.AnswerCallback(msgelem.AlertCallbackAnswer(queryID, "获取目录失败: "+err.Error()))
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
dirPath = dir.Path
|
||||||
|
}
|
||||||
|
|
||||||
|
switch data.TaskType {
|
||||||
|
case tasktype.TaskTypeTgfiles:
|
||||||
|
if data.AsBatch {
|
||||||
|
return shortcut.CreateAndAddBatchTGFileTaskWithEdit(ctx, userID, selectedStorage, dirPath, data.Files, msgID)
|
||||||
|
}
|
||||||
|
return shortcut.CreateAndAddTGFileTaskWithEdit(ctx, userID, selectedStorage, dirPath, data.Files[0], msgID)
|
||||||
|
case tasktype.TaskTypeTphpics:
|
||||||
|
return shortcut.CreateAndAddTphTaskWithEdit(ctx, userID, data.TphPageNode, data.TphDirPath, data.TphPics, selectedStorage, msgID)
|
||||||
|
default:
|
||||||
|
log.FromContext(ctx).Errorf("Unsupported task type: %s", data.TaskType)
|
||||||
|
}
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
28
client/bot/handlers/cancel_task.go
Normal file
28
client/bot/handlers/cancel_task.go
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/celestix/gotgproto/dispatcher"
|
||||||
|
"github.com/celestix/gotgproto/ext"
|
||||||
|
"github.com/charmbracelet/log"
|
||||||
|
"github.com/gotd/td/tg"
|
||||||
|
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/msgelem"
|
||||||
|
"github.com/krau/SaveAny-Bot/core"
|
||||||
|
)
|
||||||
|
|
||||||
|
func handleCancelCallback(ctx *ext.Context, update *ext.Update) error {
|
||||||
|
taskid := strings.Split(string(update.CallbackQuery.Data), " ")[1]
|
||||||
|
if err := core.CancelTask(ctx, taskid); err != nil {
|
||||||
|
log.FromContext(ctx).Errorf("error cancelling task %s: %v", taskid, err)
|
||||||
|
ctx.AnswerCallback(msgelem.AlertCallbackAnswer(update.CallbackQuery.GetQueryID(), "取消任务失败: "+err.Error()))
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.EditMessage(update.CallbackQuery.GetUserID(), &tg.MessagesEditMessageRequest{
|
||||||
|
ID: update.CallbackQuery.GetMsgID(),
|
||||||
|
Message: "正在取消任务...",
|
||||||
|
})
|
||||||
|
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
74
client/bot/handlers/dir.go
Normal file
74
client/bot/handlers/dir.go
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/celestix/gotgproto/dispatcher"
|
||||||
|
"github.com/celestix/gotgproto/ext"
|
||||||
|
"github.com/charmbracelet/log"
|
||||||
|
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/msgelem"
|
||||||
|
"github.com/krau/SaveAny-Bot/database"
|
||||||
|
"github.com/krau/SaveAny-Bot/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
func handleDirCmd(ctx *ext.Context, update *ext.Update) error {
|
||||||
|
logger := log.FromContext(ctx)
|
||||||
|
args := strings.Split(update.EffectiveMessage.Text, " ")
|
||||||
|
userChatID := update.GetUserChat().GetID()
|
||||||
|
dirs, err := database.GetUserDirsByChatID(ctx, userChatID)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("获取用户文件夹失败: %s", err)
|
||||||
|
ctx.Reply(update, ext.ReplyTextString("获取用户文件夹失败"), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
if len(args) < 2 {
|
||||||
|
ctx.Reply(update, ext.ReplyTextStyledTextArray(msgelem.BuildDirHelpStyling(dirs)), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
user, err := database.GetUserByChatID(ctx, update.GetUserChat().GetID())
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("获取用户失败: %s", err)
|
||||||
|
ctx.Reply(update, ext.ReplyTextString("获取用户失败"), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
switch args[1] {
|
||||||
|
case "add":
|
||||||
|
// /dir add local1 path/to/dir
|
||||||
|
if len(args) < 4 {
|
||||||
|
ctx.Reply(update, ext.ReplyTextStyledTextArray(msgelem.BuildDirHelpStyling(dirs)), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
if _, err := storage.GetStorageByUserIDAndName(ctx, user.ChatID, args[2]); err != nil {
|
||||||
|
ctx.Reply(update, ext.ReplyTextString(err.Error()), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := database.CreateDirForUser(ctx, user.ID, args[2], args[3]); err != nil {
|
||||||
|
logger.Errorf("创建文件夹失败: %s", err)
|
||||||
|
ctx.Reply(update, ext.ReplyTextString("创建文件夹失败"), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
ctx.Reply(update, ext.ReplyTextString("文件夹添加成功"), nil)
|
||||||
|
case "del":
|
||||||
|
// /dir del 3
|
||||||
|
if len(args) < 3 {
|
||||||
|
ctx.Reply(update, ext.ReplyTextStyledTextArray(msgelem.BuildDirHelpStyling(dirs)), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
dirID, err := strconv.Atoi(args[2])
|
||||||
|
if err != nil {
|
||||||
|
ctx.Reply(update, ext.ReplyTextString("文件夹ID无效"), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
if err := database.DeleteDirByID(ctx, uint(dirID)); err != nil {
|
||||||
|
logger.Errorf("删除文件夹失败: %s", err)
|
||||||
|
ctx.Reply(update, ext.ReplyTextString("删除文件夹失败"), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
ctx.Reply(update, ext.ReplyTextString("文件夹删除成功"), nil)
|
||||||
|
default:
|
||||||
|
ctx.Reply(update, ext.ReplyTextString("未知操作"), nil)
|
||||||
|
}
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
31
client/bot/handlers/help.go
Normal file
31
client/bot/handlers/help.go
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/celestix/gotgproto/dispatcher"
|
||||||
|
"github.com/celestix/gotgproto/ext"
|
||||||
|
"github.com/krau/SaveAny-Bot/pkg/consts"
|
||||||
|
)
|
||||||
|
|
||||||
|
func handleHelpCmd(ctx *ext.Context, update *ext.Update) error {
|
||||||
|
const helpText string = `
|
||||||
|
Save Any Bot - 转存你的 Telegram 文件
|
||||||
|
版本: %s , 提交: %s
|
||||||
|
|
||||||
|
命令:
|
||||||
|
/start - 开始使用
|
||||||
|
/help - 显示帮助
|
||||||
|
/silent - 开关静默模式
|
||||||
|
/storage - 设置默认存储位置
|
||||||
|
/save [自定义文件名] - 保存文件
|
||||||
|
|
||||||
|
使用帮助: https://sabot.unv.app/usage/
|
||||||
|
`
|
||||||
|
shortHash := consts.GitCommit
|
||||||
|
if len(shortHash) > 7 {
|
||||||
|
shortHash = shortHash[:7]
|
||||||
|
}
|
||||||
|
ctx.Reply(update, ext.ReplyTextString(fmt.Sprintf(helpText, consts.Version, shortHash)), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
62
client/bot/handlers/link.go
Normal file
62
client/bot/handlers/link.go
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/celestix/gotgproto/dispatcher"
|
||||||
|
"github.com/celestix/gotgproto/ext"
|
||||||
|
"github.com/charmbracelet/log"
|
||||||
|
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/msgelem"
|
||||||
|
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/shortcut"
|
||||||
|
"github.com/krau/SaveAny-Bot/pkg/tcbdata"
|
||||||
|
"github.com/krau/SaveAny-Bot/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
func handleMessageLink(ctx *ext.Context, update *ext.Update) error {
|
||||||
|
replied, files, editReplied, err := shortcut.GetFilesFromUpdateLinkMessageWithReplyEdit(ctx, update)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
logger := log.FromContext(ctx)
|
||||||
|
userId := update.GetUserChat().GetID()
|
||||||
|
stors := storage.GetUserStorages(ctx, userId)
|
||||||
|
if len(files) == 1 {
|
||||||
|
req, err := msgelem.BuildAddOneSelectStorageMessage(ctx, stors, files[0], replied.ID)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("构建存储选择消息失败: %s", err)
|
||||||
|
editReplied("构建存储选择消息失败: "+err.Error(), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
ctx.EditMessage(update.EffectiveChat().GetID(), req)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
markup, err := msgelem.BuildAddSelectStorageKeyboard(stors, tcbdata.Add{
|
||||||
|
Files: files,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("构建存储选择键盘失败: %s", err)
|
||||||
|
editReplied("构建存储选择键盘失败: "+err.Error(), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
editReplied(fmt.Sprintf("找到 %d 个文件, 请选择存储位置", len(files)), markup)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleSilentSaveLink(ctx *ext.Context, update *ext.Update) error {
|
||||||
|
logger := log.FromContext(ctx)
|
||||||
|
stor := storage.FromContext(ctx)
|
||||||
|
if stor == nil {
|
||||||
|
logger.Warn("Context storage is nil")
|
||||||
|
ctx.Reply(update, ext.ReplyTextString("未找到存储"), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
replied, files, _, err := shortcut.GetFilesFromUpdateLinkMessageWithReplyEdit(ctx, update)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
userId := update.GetUserChat().GetID()
|
||||||
|
if len(files) == 1 {
|
||||||
|
return shortcut.CreateAndAddTGFileTaskWithEdit(ctx, userId, stor, "", files[0], replied.ID)
|
||||||
|
}
|
||||||
|
return shortcut.CreateAndAddBatchTGFileTaskWithEdit(ctx, userId, stor, "", files, replied.ID)
|
||||||
|
}
|
||||||
159
client/bot/handlers/media.go
Normal file
159
client/bot/handlers/media.go
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/celestix/gotgproto/dispatcher"
|
||||||
|
"github.com/celestix/gotgproto/ext"
|
||||||
|
"github.com/charmbracelet/log"
|
||||||
|
"github.com/gotd/td/tg"
|
||||||
|
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/mediautil"
|
||||||
|
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/msgelem"
|
||||||
|
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/shortcut"
|
||||||
|
"github.com/krau/SaveAny-Bot/common/utils/tgutil"
|
||||||
|
"github.com/krau/SaveAny-Bot/pkg/tcbdata"
|
||||||
|
"github.com/krau/SaveAny-Bot/pkg/tfile"
|
||||||
|
"github.com/krau/SaveAny-Bot/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
func handleMediaMessage(ctx *ext.Context, update *ext.Update) error {
|
||||||
|
logger := log.FromContext(ctx)
|
||||||
|
message := update.EffectiveMessage.Message
|
||||||
|
groupID, isGroup := message.GetGroupedID()
|
||||||
|
if isGroup && groupID != 0 {
|
||||||
|
return handleGroupMediaMessage(ctx, update, message, groupID)
|
||||||
|
}
|
||||||
|
logger.Debugf("Got media: %s", message.Media.TypeName())
|
||||||
|
|
||||||
|
msg, file, err := shortcut.GetFileFromMessageWithReply(ctx, update, message)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
userId := update.GetUserChat().GetID()
|
||||||
|
stors := storage.GetUserStorages(ctx, userId)
|
||||||
|
req, err := msgelem.BuildAddOneSelectStorageMessage(ctx, stors, file, msg.ID)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("构建存储选择消息失败: %s", err)
|
||||||
|
ctx.Reply(update, ext.ReplyTextString("构建存储选择消息失败: "+err.Error()), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
ctx.EditMessage(update.EffectiveChat().GetID(), req)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleSilentSaveMedia(ctx *ext.Context, update *ext.Update) error {
|
||||||
|
logger := log.FromContext(ctx)
|
||||||
|
stor := storage.FromContext(ctx)
|
||||||
|
if stor == nil {
|
||||||
|
logger.Warn("Context storage is nil")
|
||||||
|
ctx.Reply(update, ext.ReplyTextString("未找到存储"), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
message := update.EffectiveMessage.Message
|
||||||
|
groupID, isGroup := message.GetGroupedID()
|
||||||
|
if isGroup && groupID != 0 {
|
||||||
|
return handleGroupMediaMessage(ctx, update, message, groupID)
|
||||||
|
}
|
||||||
|
logger.Debugf("Got media: %s", message.Media.TypeName())
|
||||||
|
userID := update.GetUserChat().GetID()
|
||||||
|
msg, file, err := shortcut.GetFileFromMessageWithReply(ctx, update, message)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return shortcut.CreateAndAddTGFileTaskWithEdit(ctx, userID, stor, "", file, msg.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
type MediaGroupHandler struct {
|
||||||
|
groups map[int64][]tfile.TGFileMessage
|
||||||
|
timers map[int64]*time.Timer
|
||||||
|
mu sync.Mutex
|
||||||
|
timeout time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
var mediaGroupHandler = &MediaGroupHandler{
|
||||||
|
groups: make(map[int64][]tfile.TGFileMessage),
|
||||||
|
timers: make(map[int64]*time.Timer),
|
||||||
|
timeout: 1 * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleGroupMediaMessage(ctx *ext.Context, update *ext.Update, message *tg.Message, groupID int64) error {
|
||||||
|
logger := log.FromContext(ctx)
|
||||||
|
media := message.Media
|
||||||
|
supported := mediautil.IsSupported(media)
|
||||||
|
if !supported {
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
file, err := tfile.FromMediaMessage(media, ctx.Raw, message, tfile.WithNameIfEmpty(
|
||||||
|
tgutil.GenFileNameFromMessage(*message),
|
||||||
|
))
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("Failed to get file from media: %s", err)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
mediaGroupHandler.mu.Lock()
|
||||||
|
defer mediaGroupHandler.mu.Unlock()
|
||||||
|
if mediaGroupHandler.groups[groupID] == nil {
|
||||||
|
mediaGroupHandler.groups[groupID] = make([]tfile.TGFileMessage, 0)
|
||||||
|
}
|
||||||
|
mediaGroupHandler.groups[groupID] = append(mediaGroupHandler.groups[groupID], file)
|
||||||
|
|
||||||
|
if timer, exists := mediaGroupHandler.timers[groupID]; exists {
|
||||||
|
timer.Stop()
|
||||||
|
}
|
||||||
|
mediaGroupHandler.timers[groupID] = time.AfterFunc(mediaGroupHandler.timeout, func() {
|
||||||
|
processMediaGroup(ctx, update, groupID)
|
||||||
|
})
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
|
||||||
|
func processMediaGroup(ctx *ext.Context, update *ext.Update, groupID int64) {
|
||||||
|
logger := log.FromContext(ctx)
|
||||||
|
mediaGroupHandler.mu.Lock()
|
||||||
|
items := mediaGroupHandler.groups[groupID]
|
||||||
|
delete(mediaGroupHandler.groups, groupID)
|
||||||
|
delete(mediaGroupHandler.timers, groupID)
|
||||||
|
mediaGroupHandler.mu.Unlock()
|
||||||
|
if len(items) == 0 {
|
||||||
|
logger.Warn("No media items to process for group", "groupID", groupID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
logger.Debugf("Processing media group %d with %d items", groupID, len(items))
|
||||||
|
|
||||||
|
userId := update.GetUserChat().GetID()
|
||||||
|
msg, err := ctx.Reply(update, ext.ReplyTextString("正在保存文件..."), nil)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("Failed to reply: %s", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
stor := storage.FromContext(ctx)
|
||||||
|
if stor != nil {
|
||||||
|
// In silent mode
|
||||||
|
if len(items) == 1 {
|
||||||
|
shortcut.CreateAndAddTGFileTaskWithEdit(ctx, userId, stor, "", items[0], msg.ID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
shortcut.CreateAndAddBatchTGFileTaskWithEdit(ctx, userId, stor, "", items, msg.ID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
stors := storage.GetUserStorages(ctx, userId)
|
||||||
|
markup, err := msgelem.BuildAddSelectStorageKeyboard(stors, tcbdata.Add{
|
||||||
|
Files: items,
|
||||||
|
AsBatch: len(items) > 1,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("构建存储选择键盘失败: %s", err)
|
||||||
|
ctx.EditMessage(userId, &tg.MessagesEditMessageRequest{
|
||||||
|
ID: msg.ID,
|
||||||
|
Message: "构建存储选择键盘失败: " + err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx.EditMessage(userId, &tg.MessagesEditMessageRequest{
|
||||||
|
ID: msg.ID,
|
||||||
|
Message: fmt.Sprintf("共 %d 个文件, 请选择存储位置", len(items)),
|
||||||
|
ReplyMarkup: markup,
|
||||||
|
})
|
||||||
|
}
|
||||||
49
client/bot/handlers/middleware.go
Normal file
49
client/bot/handlers/middleware.go
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/celestix/gotgproto/dispatcher"
|
||||||
|
"github.com/celestix/gotgproto/ext"
|
||||||
|
"github.com/duke-git/lancet/v2/slice"
|
||||||
|
"github.com/krau/SaveAny-Bot/config"
|
||||||
|
"github.com/krau/SaveAny-Bot/database"
|
||||||
|
"github.com/krau/SaveAny-Bot/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
func checkPermission(ctx *ext.Context, update *ext.Update) error {
|
||||||
|
userID := update.GetUserChat().GetID()
|
||||||
|
if !slice.Contain(config.Cfg.GetUsersID(), userID) {
|
||||||
|
const noPermissionText string = `
|
||||||
|
您不在白名单中, 无法使用此 Bot.
|
||||||
|
您可以部署自己的实例: https://github.com/krau/SaveAny-Bot
|
||||||
|
`
|
||||||
|
ctx.Reply(update, ext.ReplyTextString(noPermissionText), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
|
||||||
|
return dispatcher.ContinueGroups
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleSilentMode(next func(*ext.Context, *ext.Update) error, handler func(*ext.Context, *ext.Update) error) func(*ext.Context, *ext.Update) error {
|
||||||
|
return func(ctx *ext.Context, update *ext.Update) error {
|
||||||
|
userID := update.GetUserChat().GetID()
|
||||||
|
user, err := database.GetUserByChatID(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
ctx.Reply(update, ext.ReplyTextString("获取用户信息失败: "+err.Error()), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
if !user.Silent {
|
||||||
|
return next(ctx, update)
|
||||||
|
}
|
||||||
|
if user.DefaultStorage == "" {
|
||||||
|
ctx.Reply(update, ext.ReplyTextString("您已开启静默模式, 但未设置默认存储端, 请先使用 /storage 设置"), nil)
|
||||||
|
return next(ctx, update)
|
||||||
|
}
|
||||||
|
stor, err := storage.GetStorageByUserIDAndName(ctx, userID, user.DefaultStorage)
|
||||||
|
if err != nil {
|
||||||
|
ctx.Reply(update, ext.ReplyTextString("获取默认存储失败: "+err.Error()), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
ctx.Context = storage.WithContext(ctx.Context, stor)
|
||||||
|
return handler(ctx, update)
|
||||||
|
}
|
||||||
|
}
|
||||||
41
client/bot/handlers/register.go
Normal file
41
client/bot/handlers/register.go
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/celestix/gotgproto/dispatcher"
|
||||||
|
"github.com/celestix/gotgproto/dispatcher/handlers"
|
||||||
|
"github.com/celestix/gotgproto/dispatcher/handlers/filters"
|
||||||
|
"github.com/celestix/gotgproto/ext"
|
||||||
|
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/re"
|
||||||
|
"github.com/krau/SaveAny-Bot/pkg/tcbdata"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Register(disp dispatcher.Dispatcher) {
|
||||||
|
disp.AddHandler(handlers.NewMessage(filters.Message.ChatType(filters.ChatTypeChannel), func(ctx *ext.Context, u *ext.Update) error {
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}))
|
||||||
|
disp.AddHandler(handlers.NewMessage(filters.Message.ChatType(filters.ChatTypeChat), func(ctx *ext.Context, u *ext.Update) error {
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}))
|
||||||
|
disp.AddHandler(handlers.NewMessage(filters.Message.All, checkPermission))
|
||||||
|
disp.AddHandler(handlers.NewCommand("start", handleHelpCmd))
|
||||||
|
disp.AddHandler(handlers.NewCommand("help", handleHelpCmd))
|
||||||
|
disp.AddHandler(handlers.NewCommand("silent", handleSilentCmd))
|
||||||
|
disp.AddHandler(handlers.NewCommand("storage", handleStorageCmd))
|
||||||
|
disp.AddHandler(handlers.NewCommand("dir", handleDirCmd))
|
||||||
|
disp.AddHandler(handlers.NewCommand("rule", handleRuleCmd))
|
||||||
|
disp.AddHandler(handlers.NewCommand("save", handleSilentMode(handleSaveCmd, handleSilentSaveReplied)))
|
||||||
|
disp.AddHandler(handlers.NewCallbackQuery(filters.CallbackQuery.Prefix(tcbdata.TypeAdd), handleAddCallback))
|
||||||
|
disp.AddHandler(handlers.NewCallbackQuery(filters.CallbackQuery.Prefix(tcbdata.TypeSetDefault), handleSetDefaultCallback))
|
||||||
|
disp.AddHandler(handlers.NewCallbackQuery(filters.CallbackQuery.Prefix("cancel"), handleCancelCallback))
|
||||||
|
linkRegexFilter, err := filters.Message.Regex(re.TgMessageLinkRegexString)
|
||||||
|
if err != nil {
|
||||||
|
panic("failed to create regex filter: " + err.Error())
|
||||||
|
}
|
||||||
|
disp.AddHandler(handlers.NewMessage(linkRegexFilter, handleSilentMode(handleMessageLink, handleSilentSaveLink)))
|
||||||
|
telegraphUrlRegexFilter, err := filters.Message.Regex(re.TelegraphUrlRegexString)
|
||||||
|
if err != nil {
|
||||||
|
panic("failed to create Telegraph URL regex filter: " + err.Error())
|
||||||
|
}
|
||||||
|
disp.AddHandler(handlers.NewMessage(telegraphUrlRegexFilter, handleSilentMode(handleTelegraphUrlMessage, handleSilentSaveTelegraph)))
|
||||||
|
disp.AddHandler(handlers.NewMessage(filters.Message.Media, handleSilentMode(handleMediaMessage, handleSilentSaveMedia)))
|
||||||
|
}
|
||||||
101
client/bot/handlers/rule.go
Normal file
101
client/bot/handlers/rule.go
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/celestix/gotgproto/dispatcher"
|
||||||
|
"github.com/celestix/gotgproto/ext"
|
||||||
|
"github.com/charmbracelet/log"
|
||||||
|
"github.com/duke-git/lancet/v2/slice"
|
||||||
|
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/msgelem"
|
||||||
|
"github.com/krau/SaveAny-Bot/database"
|
||||||
|
"github.com/krau/SaveAny-Bot/pkg/enums/rule"
|
||||||
|
)
|
||||||
|
|
||||||
|
func handleRuleCmd(ctx *ext.Context, update *ext.Update) error {
|
||||||
|
logger := log.FromContext(ctx)
|
||||||
|
args := strings.Split(update.EffectiveMessage.Text, " ")
|
||||||
|
userChatID := update.GetUserChat().GetID()
|
||||||
|
user, err := database.GetUserByChatID(ctx, userChatID)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("获取用户规则失败: %s", err)
|
||||||
|
ctx.Reply(update, ext.ReplyTextString("获取用户规则失败"), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
if len(args) < 2 {
|
||||||
|
ctx.Reply(update, ext.ReplyTextStyledTextArray(msgelem.BuildRuleHelpStyling(user.ApplyRule, user.Rules)), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
switch args[1] {
|
||||||
|
case "switch":
|
||||||
|
// /rule switch
|
||||||
|
applyRule := !user.ApplyRule
|
||||||
|
if err := database.UpdateUserApplyRule(ctx, user.ChatID, applyRule); err != nil {
|
||||||
|
logger.Errorf("更新用户失败: %s", err)
|
||||||
|
ctx.Reply(update, ext.ReplyTextString("更新用户失败"), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
ctx.Reply(update, ext.ReplyTextString(fmt.Sprintf("已%s规则模式", map[bool]string{true: "启用", false: "禁用"}[applyRule])), nil)
|
||||||
|
case "add":
|
||||||
|
// /rule add <type> <data> <storage> <dirpath>
|
||||||
|
if len(args) < 6 {
|
||||||
|
ctx.Reply(update, ext.ReplyTextStyledTextArray(msgelem.BuildRuleHelpStyling(user.ApplyRule, user.Rules)), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
ruleTypeArg := args[2]
|
||||||
|
ruleType, err := func() (rule.RuleType, error) {
|
||||||
|
for _, t := range rule.Values() {
|
||||||
|
if strings.EqualFold(t.String(), ruleTypeArg) {
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rule.RuleType(""), fmt.Errorf("无效的规则类型: %s\n可用: %v", ruleTypeArg, slice.Join(rule.Values(), ", "))
|
||||||
|
}()
|
||||||
|
if err != nil {
|
||||||
|
ctx.Reply(update, ext.ReplyTextString(err.Error()), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
|
||||||
|
ruleData := args[3]
|
||||||
|
storageName := args[4]
|
||||||
|
dirPath := args[5]
|
||||||
|
|
||||||
|
rd := &database.Rule{
|
||||||
|
Type: ruleType.String(),
|
||||||
|
Data: ruleData,
|
||||||
|
StorageName: storageName,
|
||||||
|
DirPath: dirPath,
|
||||||
|
UserID: user.ID,
|
||||||
|
}
|
||||||
|
if err := database.CreateRule(ctx, rd); err != nil {
|
||||||
|
logger.Errorf("创建规则失败: %s", err)
|
||||||
|
ctx.Reply(update, ext.ReplyTextString("创建规则失败"), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
ctx.Reply(update, ext.ReplyTextString("创建规则成功"), nil)
|
||||||
|
case "del":
|
||||||
|
// /rule del <id>
|
||||||
|
if len(args) < 3 {
|
||||||
|
ctx.Reply(update, ext.ReplyTextString("请提供规则ID"), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
ruleID := args[2]
|
||||||
|
id, err := strconv.Atoi(ruleID)
|
||||||
|
if err != nil {
|
||||||
|
ctx.Reply(update, ext.ReplyTextString("无效的规则ID"), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
if err := database.DeleteRule(ctx, uint(id)); err != nil {
|
||||||
|
logger.Errorf("删除规则失败: %s", err)
|
||||||
|
ctx.Reply(update, ext.ReplyTextString("删除规则失败"), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
ctx.Reply(update, ext.ReplyTextString("删除规则成功"), nil)
|
||||||
|
default:
|
||||||
|
ctx.Reply(update, ext.ReplyTextStyledTextArray(msgelem.BuildRuleHelpStyling(user.ApplyRule, user.Rules)), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
198
client/bot/handlers/save.go
Normal file
198
client/bot/handlers/save.go
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/celestix/gotgproto/dispatcher"
|
||||||
|
"github.com/celestix/gotgproto/ext"
|
||||||
|
"github.com/celestix/gotgproto/functions"
|
||||||
|
"github.com/charmbracelet/log"
|
||||||
|
"github.com/gotd/td/tg"
|
||||||
|
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/mediautil"
|
||||||
|
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/msgelem"
|
||||||
|
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/shortcut"
|
||||||
|
"github.com/krau/SaveAny-Bot/common/utils/strutil"
|
||||||
|
"github.com/krau/SaveAny-Bot/common/utils/tgutil"
|
||||||
|
"github.com/krau/SaveAny-Bot/pkg/tcbdata"
|
||||||
|
"github.com/krau/SaveAny-Bot/pkg/tfile"
|
||||||
|
|
||||||
|
"github.com/krau/SaveAny-Bot/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
func handleSaveCmd(ctx *ext.Context, update *ext.Update) error {
|
||||||
|
logger := log.FromContext(ctx)
|
||||||
|
args := strings.Split(string(update.EffectiveMessage.Text), " ")
|
||||||
|
if len(args) >= 3 {
|
||||||
|
return handleBatchSave(ctx, update, args[1:])
|
||||||
|
}
|
||||||
|
replyTo := update.EffectiveMessage.ReplyToMessage
|
||||||
|
if replyTo == nil || replyTo.Message == nil {
|
||||||
|
ctx.Reply(update, ext.ReplyTextString(msgelem.SaveHelpText), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
genFilename := func() string {
|
||||||
|
if len(args) > 1 {
|
||||||
|
return args[1]
|
||||||
|
}
|
||||||
|
filename := tgutil.GenFileNameFromMessage(*replyTo.Message)
|
||||||
|
return filename
|
||||||
|
}()
|
||||||
|
option := tfile.WithNameIfEmpty(genFilename)
|
||||||
|
if len(args) > 1 {
|
||||||
|
option = tfile.WithName(genFilename)
|
||||||
|
}
|
||||||
|
msg, file, err := shortcut.GetFileFromMessageWithReply(ctx, update, replyTo.Message, option)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
userId := update.GetUserChat().GetID()
|
||||||
|
stors := storage.GetUserStorages(ctx, userId)
|
||||||
|
req, err := msgelem.BuildAddOneSelectStorageMessage(ctx, stors, file, msg.ID)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("构建存储选择消息失败: %s", err)
|
||||||
|
ctx.Reply(update, ext.ReplyTextString("构建存储选择消息失败: "+err.Error()), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
ctx.EditMessage(update.EffectiveChat().GetID(), req)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleSilentSaveReplied(ctx *ext.Context, update *ext.Update) error {
|
||||||
|
args := strings.Split(string(update.EffectiveMessage.Text), " ")
|
||||||
|
if len(args) >= 3 {
|
||||||
|
return handleBatchSave(ctx, update, args[1:])
|
||||||
|
}
|
||||||
|
logger := log.FromContext(ctx)
|
||||||
|
stor := storage.FromContext(ctx)
|
||||||
|
if stor == nil {
|
||||||
|
logger.Warn("Context storage is nil")
|
||||||
|
ctx.Reply(update, ext.ReplyTextString("未找到存储"), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
replyTo := update.EffectiveMessage.ReplyToMessage
|
||||||
|
if replyTo == nil || replyTo.Message == nil {
|
||||||
|
ctx.Reply(update, ext.ReplyTextString(msgelem.SaveHelpText), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
genFilename := func() string {
|
||||||
|
if len(args) > 1 {
|
||||||
|
return args[1]
|
||||||
|
}
|
||||||
|
filename := tgutil.GenFileNameFromMessage(*replyTo.Message)
|
||||||
|
return filename
|
||||||
|
}()
|
||||||
|
option := tfile.WithNameIfEmpty(genFilename)
|
||||||
|
if len(args) > 1 {
|
||||||
|
option = tfile.WithName(genFilename)
|
||||||
|
}
|
||||||
|
msg, file, err := shortcut.GetFileFromMessageWithReply(ctx, update, replyTo.Message, option)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return shortcut.CreateAndAddTGFileTaskWithEdit(ctx, update.GetUserChat().GetID(), stor, "", file, msg.GetID())
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleBatchSave(ctx *ext.Context, update *ext.Update, args []string) error {
|
||||||
|
chatArg := args[0]
|
||||||
|
msgIdRangeArg := args[1]
|
||||||
|
var filterStr string
|
||||||
|
var filter *regexp.Regexp
|
||||||
|
if len(args) > 2 {
|
||||||
|
filterStr = args[2]
|
||||||
|
var err error
|
||||||
|
filter, err = regexp.Compile(filterStr)
|
||||||
|
if err != nil {
|
||||||
|
ctx.Reply(update, ext.ReplyTextString("无效的正则表达式: "+err.Error()), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
}
|
||||||
|
startID, endID, err := strutil.ParseIntStrRange(msgIdRangeArg, "-")
|
||||||
|
if err != nil {
|
||||||
|
ctx.Reply(update, ext.ReplyTextString("无效的消息ID范围: "+err.Error()), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
chatID, err := tgutil.ParseChatID(ctx, chatArg)
|
||||||
|
if err != nil {
|
||||||
|
ctx.Reply(update, ext.ReplyTextString("无效的ID或用户名: "+err.Error()), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
|
||||||
|
replied, err := ctx.Reply(update, ext.ReplyTextString("正在获取消息..."), nil)
|
||||||
|
if err != nil {
|
||||||
|
log.FromContext(ctx).Errorf("回复失败: %s", err)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: generator istead of get all messages
|
||||||
|
msgs, err := tgutil.GetMessagesRange(ctx, chatID, int(startID), int(endID))
|
||||||
|
if err != nil {
|
||||||
|
ctx.Reply(update, ext.ReplyTextString("获取消息失败: "+err.Error()), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
if len(msgs) == 0 {
|
||||||
|
ctx.Reply(update, ext.ReplyTextString("没有找到指定范围内的消息"), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
files := make([]tfile.TGFileMessage, 0, len(msgs))
|
||||||
|
sb := strings.Builder{}
|
||||||
|
for _, msg := range msgs {
|
||||||
|
if msg == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
media, ok := msg.GetMedia()
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
supported := mediautil.IsSupported(media)
|
||||||
|
if !supported {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
file, err := tfile.FromMediaMessage(media, ctx.Raw, msg, tfile.WithNameIfEmpty(tgutil.GenFileNameFromMessage(*msg)))
|
||||||
|
if err != nil {
|
||||||
|
log.FromContext(ctx).Errorf("获取文件失败: %s", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if filter != nil {
|
||||||
|
sb.Reset()
|
||||||
|
sb.WriteString(msg.GetMessage())
|
||||||
|
sb.WriteString(" ")
|
||||||
|
fn, _ := functions.GetMediaFileNameWithId(media)
|
||||||
|
sb.WriteString(fn)
|
||||||
|
log.FromContext(ctx).Debugf("正在检查消息内容: %s", sb.String())
|
||||||
|
if !filter.MatchString(sb.String()) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
files = append(files, file)
|
||||||
|
}
|
||||||
|
if len(files) == 0 {
|
||||||
|
ctx.Reply(update, ext.ReplyTextString("没有找到指定范围内的可保存消息"), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
stor := storage.FromContext(ctx)
|
||||||
|
if stor == nil {
|
||||||
|
// not in silent mode
|
||||||
|
stors := storage.GetUserStorages(ctx, update.GetUserChat().GetID())
|
||||||
|
markup, err := msgelem.BuildAddSelectStorageKeyboard(stors, tcbdata.Add{
|
||||||
|
Files: files,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.FromContext(ctx).Errorf("构建存储选择键盘失败: %s", err)
|
||||||
|
ctx.EditMessage(update.EffectiveChat().GetID(), &tg.MessagesEditMessageRequest{
|
||||||
|
ID: replied.ID,
|
||||||
|
Message: "构建存储选择键盘失败: " + err.Error(),
|
||||||
|
})
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
ctx.EditMessage(update.EffectiveChat().GetID(), &tg.MessagesEditMessageRequest{
|
||||||
|
ID: replied.ID,
|
||||||
|
Message: fmt.Sprintf("找到 %d 个文件, 请选择存储位置", len(files)),
|
||||||
|
ReplyMarkup: markup,
|
||||||
|
})
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
return shortcut.CreateAndAddBatchTGFileTaskWithEdit(ctx, update.GetUserChat().GetID(), stor, "", files, replied.ID)
|
||||||
|
|
||||||
|
}
|
||||||
104
client/bot/handlers/silent.go
Normal file
104
client/bot/handlers/silent.go
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/celestix/gotgproto/dispatcher"
|
||||||
|
"github.com/celestix/gotgproto/ext"
|
||||||
|
"github.com/gotd/td/tg"
|
||||||
|
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/msgelem"
|
||||||
|
"github.com/krau/SaveAny-Bot/common/cache"
|
||||||
|
"github.com/krau/SaveAny-Bot/database"
|
||||||
|
"github.com/krau/SaveAny-Bot/pkg/tcbdata"
|
||||||
|
"github.com/krau/SaveAny-Bot/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
func handleSilentCmd(ctx *ext.Context, update *ext.Update) error {
|
||||||
|
user, err := database.GetUserByChatID(ctx, update.GetUserChat().GetID())
|
||||||
|
if err != nil {
|
||||||
|
ctx.Reply(update, ext.ReplyTextString("获取用户信息失败: "+err.Error()), nil)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !user.Silent && user.DefaultStorage == "" {
|
||||||
|
ctx.Reply(update, ext.ReplyTextString("请先使用 /storage 设置默认存储位置"), nil)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
user.Silent = !user.Silent
|
||||||
|
if err := database.UpdateUser(ctx, user); err != nil {
|
||||||
|
ctx.Reply(update, ext.ReplyTextString("更新用户信息失败: "+err.Error()), nil)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
responseText := "已" + map[bool]string{true: "开启", false: "关闭"}[user.Silent] + "静默模式"
|
||||||
|
ctx.Reply(update, ext.ReplyTextString(responseText), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleSetDefaultCallback(ctx *ext.Context, update *ext.Update) error {
|
||||||
|
dataid := strings.Split(string(update.CallbackQuery.Data), " ")[1]
|
||||||
|
data, ok := cache.Get[tcbdata.SetDefaultStorage](dataid)
|
||||||
|
if !ok {
|
||||||
|
ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{
|
||||||
|
QueryID: update.CallbackQuery.GetQueryID(),
|
||||||
|
Alert: true,
|
||||||
|
Message: "数据已过期",
|
||||||
|
CacheTime: 5,
|
||||||
|
})
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
userID := update.CallbackQuery.GetUserID()
|
||||||
|
|
||||||
|
storageName := data.StorageName
|
||||||
|
selectedStorage, err := storage.GetStorageByUserIDAndName(ctx, userID, storageName)
|
||||||
|
if err != nil {
|
||||||
|
ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{
|
||||||
|
QueryID: update.CallbackQuery.GetQueryID(),
|
||||||
|
Alert: true,
|
||||||
|
Message: "存储获取失败: " + err.Error(),
|
||||||
|
CacheTime: 5,
|
||||||
|
})
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
user, err := database.GetUserByChatID(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{
|
||||||
|
QueryID: update.CallbackQuery.GetQueryID(),
|
||||||
|
Alert: true,
|
||||||
|
Message: "获取用户信息失败: " + err.Error(),
|
||||||
|
CacheTime: 5,
|
||||||
|
})
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
user.DefaultStorage = selectedStorage.Name()
|
||||||
|
if err := database.UpdateUser(ctx, user); err != nil {
|
||||||
|
ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{
|
||||||
|
QueryID: update.CallbackQuery.GetQueryID(),
|
||||||
|
Alert: true,
|
||||||
|
Message: "更新用户信息失败: " + err.Error(),
|
||||||
|
CacheTime: 5,
|
||||||
|
})
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
|
||||||
|
ID: update.CallbackQuery.GetMsgID(),
|
||||||
|
Message: "已将默认存储位置设置为: " + selectedStorage.Name(),
|
||||||
|
})
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleStorageCmd(ctx *ext.Context, update *ext.Update) error {
|
||||||
|
userID := update.GetUserChat().GetID()
|
||||||
|
storages := storage.GetUserStorages(ctx, userID)
|
||||||
|
if len(storages) == 0 {
|
||||||
|
ctx.Reply(update, ext.ReplyTextString("无可用的存储"), nil)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
markup, err := msgelem.BuildSetDefaultStorageMarkup(ctx, userID, storages)
|
||||||
|
if err != nil {
|
||||||
|
ctx.Reply(update, ext.ReplyTextString("获取存储失败: "+err.Error()), nil)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
ctx.Reply(update, ext.ReplyTextString("请选择要设为默认的存储位置"), &ext.ReplyOpts{
|
||||||
|
Markup: markup,
|
||||||
|
})
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
76
client/bot/handlers/telegraph.go
Normal file
76
client/bot/handlers/telegraph.go
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/celestix/gotgproto/dispatcher"
|
||||||
|
"github.com/celestix/gotgproto/ext"
|
||||||
|
"github.com/charmbracelet/log"
|
||||||
|
"github.com/gotd/td/telegram/message/entity"
|
||||||
|
"github.com/gotd/td/telegram/message/styling"
|
||||||
|
"github.com/gotd/td/tg"
|
||||||
|
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/msgelem"
|
||||||
|
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/shortcut"
|
||||||
|
"github.com/krau/SaveAny-Bot/pkg/enums/tasktype"
|
||||||
|
"github.com/krau/SaveAny-Bot/pkg/tcbdata"
|
||||||
|
"github.com/krau/SaveAny-Bot/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
func handleTelegraphUrlMessage(ctx *ext.Context, update *ext.Update) error {
|
||||||
|
logger := log.FromContext(ctx)
|
||||||
|
|
||||||
|
msg, result, err := shortcut.GetTphPicsFromMessageWithReply(ctx, update)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
userID := update.GetUserChat().GetID()
|
||||||
|
stors := storage.GetUserStorages(ctx, userID)
|
||||||
|
markup, err := msgelem.BuildAddSelectStorageKeyboard(stors, tcbdata.Add{
|
||||||
|
TaskType: tasktype.TaskTypeTphpics,
|
||||||
|
TphPageNode: result.Page,
|
||||||
|
TphDirPath: result.TphDir,
|
||||||
|
TphPics: result.Pics,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("构建存储选择键盘失败: %s", err)
|
||||||
|
ctx.Reply(update, ext.ReplyTextString("构建存储选择键盘失败: "+err.Error()), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
|
||||||
|
eb := entity.Builder{}
|
||||||
|
if err := styling.Perform(&eb,
|
||||||
|
styling.Plain("标题: "),
|
||||||
|
styling.Code(result.Page.Title),
|
||||||
|
styling.Plain("\n图片数量: "),
|
||||||
|
styling.Code(fmt.Sprintf("%d", len(result.Pics))),
|
||||||
|
styling.Plain("\n请选择存储位置"),
|
||||||
|
); err != nil {
|
||||||
|
log.FromContext(ctx).Errorf("Failed to build entity: %s", err)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
text, entities := eb.Complete()
|
||||||
|
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
|
||||||
|
Message: text,
|
||||||
|
ID: msg.ID,
|
||||||
|
ReplyMarkup: markup,
|
||||||
|
Entities: entities,
|
||||||
|
})
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleSilentSaveTelegraph(ctx *ext.Context, update *ext.Update) error {
|
||||||
|
logger := log.FromContext(ctx)
|
||||||
|
stor := storage.FromContext(ctx)
|
||||||
|
if stor == nil {
|
||||||
|
logger.Warn("Context storage is nil")
|
||||||
|
ctx.Reply(update, ext.ReplyTextString("未找到存储"), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
msg, result, err := shortcut.GetTphPicsFromMessageWithReply(ctx, update)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
userID := update.GetUserChat().GetID()
|
||||||
|
return shortcut.CreateAndAddTphTaskWithEdit(ctx, userID, result.Page, result.TphDir, result.Pics, stor, msg.ID)
|
||||||
|
|
||||||
|
}
|
||||||
12
client/bot/handlers/utils/mediautil/media.go
Normal file
12
client/bot/handlers/utils/mediautil/media.go
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
package mediautil
|
||||||
|
|
||||||
|
import "github.com/gotd/td/tg"
|
||||||
|
|
||||||
|
func IsSupported(media tg.MessageMediaClass) bool {
|
||||||
|
switch media.(type) {
|
||||||
|
case *tg.MessageMediaDocument, *tg.MessageMediaPhoto:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
12
client/bot/handlers/utils/msgelem/callback.go
Normal file
12
client/bot/handlers/utils/msgelem/callback.go
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
package msgelem
|
||||||
|
|
||||||
|
import "github.com/gotd/td/tg"
|
||||||
|
|
||||||
|
func AlertCallbackAnswer(queryID int64, text string) *tg.MessagesSetBotCallbackAnswerRequest {
|
||||||
|
return &tg.MessagesSetBotCallbackAnswerRequest{
|
||||||
|
QueryID: queryID,
|
||||||
|
Alert: true,
|
||||||
|
Message: text,
|
||||||
|
CacheTime: 5,
|
||||||
|
}
|
||||||
|
}
|
||||||
36
client/bot/handlers/utils/msgelem/dir.go
Normal file
36
client/bot/handlers/utils/msgelem/dir.go
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
package msgelem
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gotd/td/telegram/message/styling"
|
||||||
|
"github.com/krau/SaveAny-Bot/database"
|
||||||
|
)
|
||||||
|
|
||||||
|
func BuildDirHelpStyling(dirs []database.Dir) []styling.StyledTextOption {
|
||||||
|
return []styling.StyledTextOption{
|
||||||
|
styling.Bold("使用方法: /dir <操作> <参数...>"),
|
||||||
|
styling.Plain("\n\n可用操作:\n"),
|
||||||
|
styling.Code("add"),
|
||||||
|
styling.Plain(" <存储名> <路径> - 添加路径\n"),
|
||||||
|
styling.Code("del"),
|
||||||
|
styling.Plain(" <路径ID> - 删除路径\n"),
|
||||||
|
styling.Plain("\n添加路径示例:\n"),
|
||||||
|
styling.Code("/dir add local1 path/to/dir"),
|
||||||
|
styling.Plain("\n\n删除路径示例:\n"),
|
||||||
|
styling.Code("/dir del 3"),
|
||||||
|
styling.Plain("\n\n当前已添加的路径:\n"),
|
||||||
|
styling.Blockquote(func() string {
|
||||||
|
var sb strings.Builder
|
||||||
|
for _, dir := range dirs {
|
||||||
|
sb.WriteString(fmt.Sprintf("%d: ", dir.ID))
|
||||||
|
sb.WriteString(dir.StorageName)
|
||||||
|
sb.WriteString(" - ")
|
||||||
|
sb.WriteString(dir.Path)
|
||||||
|
sb.WriteString("\n")
|
||||||
|
}
|
||||||
|
return sb.String()
|
||||||
|
}(), true),
|
||||||
|
}
|
||||||
|
}
|
||||||
32
client/bot/handlers/utils/msgelem/rule.go
Normal file
32
client/bot/handlers/utils/msgelem/rule.go
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
package msgelem
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gotd/td/telegram/message/styling"
|
||||||
|
"github.com/krau/SaveAny-Bot/database"
|
||||||
|
)
|
||||||
|
|
||||||
|
func BuildRuleHelpStyling(enabled bool, rules []database.Rule) []styling.StyledTextOption {
|
||||||
|
return []styling.StyledTextOption{
|
||||||
|
styling.Bold("使用方法: /rule <操作> <参数...>"),
|
||||||
|
styling.Bold(fmt.Sprintf("\n当前已%s规则模式", map[bool]string{true: "启用", false: "禁用"}[enabled])),
|
||||||
|
styling.Plain("\n\n可用操作:\n"),
|
||||||
|
styling.Code("switch"),
|
||||||
|
styling.Plain(" - 开关规则模式\n"),
|
||||||
|
styling.Code("add"),
|
||||||
|
styling.Plain(" <类型> <数据> <存储名> <路径> - 添加规则\n"),
|
||||||
|
styling.Code("del"),
|
||||||
|
styling.Plain(" <规则ID> - 删除规则\n"),
|
||||||
|
styling.Plain("\n当前已添加的规则:\n"),
|
||||||
|
styling.Blockquote(func() string {
|
||||||
|
var sb strings.Builder
|
||||||
|
for _, rule := range rules {
|
||||||
|
ruleText := fmt.Sprintf("%s %s %s %s", rule.Type, rule.Data, rule.StorageName, rule.DirPath)
|
||||||
|
sb.WriteString(fmt.Sprintf("%d: %s\n", rule.ID, ruleText))
|
||||||
|
}
|
||||||
|
return sb.String()
|
||||||
|
}(), true),
|
||||||
|
}
|
||||||
|
}
|
||||||
15
client/bot/handlers/utils/msgelem/save.go
Normal file
15
client/bot/handlers/utils/msgelem/save.go
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
package msgelem
|
||||||
|
|
||||||
|
const (
|
||||||
|
SaveHelpText = `
|
||||||
|
使用方法:
|
||||||
|
|
||||||
|
1. 使用该命令回复要保存的文件, 可选文件名参数.
|
||||||
|
示例:
|
||||||
|
/save custom_file_name.mp4
|
||||||
|
|
||||||
|
2. 设置默认存储后, 发送 /save <频道ID/用户名> <消息ID范围> 来批量保存文件. 遵从存储规则, 若未匹配到任何规则则使用默认存储.
|
||||||
|
示例:
|
||||||
|
/save @moreacg 114-514
|
||||||
|
`
|
||||||
|
)
|
||||||
161
client/bot/handlers/utils/msgelem/storage.go
Normal file
161
client/bot/handlers/utils/msgelem/storage.go
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
package msgelem
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/log"
|
||||||
|
"github.com/gotd/td/telegram/message/entity"
|
||||||
|
"github.com/gotd/td/telegram/message/styling"
|
||||||
|
"github.com/gotd/td/tg"
|
||||||
|
"github.com/krau/SaveAny-Bot/common/cache"
|
||||||
|
"github.com/krau/SaveAny-Bot/database"
|
||||||
|
"github.com/krau/SaveAny-Bot/pkg/enums/tasktype"
|
||||||
|
"github.com/krau/SaveAny-Bot/pkg/tcbdata"
|
||||||
|
"github.com/krau/SaveAny-Bot/pkg/tfile"
|
||||||
|
"github.com/krau/SaveAny-Bot/storage"
|
||||||
|
"github.com/rs/xid"
|
||||||
|
)
|
||||||
|
|
||||||
|
func BuildAddSelectStorageKeyboard(stors []storage.Storage, adddata tcbdata.Add) (*tg.ReplyInlineMarkup, error) {
|
||||||
|
taskType := adddata.TaskType
|
||||||
|
if taskType == "" {
|
||||||
|
if len(adddata.Files) > 0 {
|
||||||
|
taskType = tasktype.TaskTypeTgfiles
|
||||||
|
} else if adddata.TphPageNode != nil {
|
||||||
|
taskType = tasktype.TaskTypeTphpics
|
||||||
|
} else {
|
||||||
|
return nil, fmt.Errorf("unknown task type: %s", taskType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
buttons := make([]tg.KeyboardButtonClass, 0)
|
||||||
|
for _, storage := range stors {
|
||||||
|
data := tcbdata.Add{
|
||||||
|
TaskType: taskType,
|
||||||
|
SelectedStorName: storage.Name(),
|
||||||
|
|
||||||
|
Files: adddata.Files,
|
||||||
|
AsBatch: len(adddata.Files) > 1,
|
||||||
|
|
||||||
|
TphPageNode: adddata.TphPageNode,
|
||||||
|
TphPics: adddata.TphPics,
|
||||||
|
TphDirPath: adddata.TphDirPath,
|
||||||
|
}
|
||||||
|
dataid := xid.New().String()
|
||||||
|
err := cache.Set(dataid, data)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
buttons = append(buttons, &tg.KeyboardButtonCallback{
|
||||||
|
Text: storage.Name(),
|
||||||
|
Data: fmt.Appendf(nil, "%s %s", tcbdata.TypeAdd, dataid),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
markup := &tg.ReplyInlineMarkup{}
|
||||||
|
for i := 0; i < len(buttons); i += 3 {
|
||||||
|
row := tg.KeyboardButtonRow{}
|
||||||
|
row.Buttons = buttons[i:min(i+3, len(buttons))]
|
||||||
|
markup.Rows = append(markup.Rows, row)
|
||||||
|
}
|
||||||
|
return markup, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func BuildAddOneSelectStorageMessage(ctx context.Context, stors []storage.Storage, file tfile.TGFileMessage, msgId int) (*tg.MessagesEditMessageRequest, error) {
|
||||||
|
eb := entity.Builder{}
|
||||||
|
var entities []tg.MessageEntityClass
|
||||||
|
text := fmt.Sprintf("文件名: %s\n请选择存储位置", file.Name())
|
||||||
|
if err := styling.Perform(&eb,
|
||||||
|
styling.Plain("文件名: "),
|
||||||
|
styling.Code(file.Name()),
|
||||||
|
styling.Plain("\n请选择存储位置"),
|
||||||
|
); err != nil {
|
||||||
|
log.FromContext(ctx).Errorf("Failed to build entity: %s", err)
|
||||||
|
} else {
|
||||||
|
text, entities = eb.Complete()
|
||||||
|
}
|
||||||
|
markup, err := BuildAddSelectStorageKeyboard(stors, tcbdata.Add{
|
||||||
|
TaskType: tasktype.TaskTypeTgfiles,
|
||||||
|
Files: []tfile.TGFileMessage{file},
|
||||||
|
AsBatch: false,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to build storage keyboard: %w", err)
|
||||||
|
}
|
||||||
|
return &tg.MessagesEditMessageRequest{
|
||||||
|
Message: text,
|
||||||
|
Entities: entities,
|
||||||
|
ReplyMarkup: markup,
|
||||||
|
ID: msgId,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func BuildSetDefaultStorageMarkup(ctx context.Context, userID int64, stors []storage.Storage) (*tg.ReplyInlineMarkup, error) {
|
||||||
|
buttons := make([]tg.KeyboardButtonClass, 0)
|
||||||
|
for _, storage := range stors {
|
||||||
|
data := tcbdata.SetDefaultStorage{
|
||||||
|
StorageName: storage.Name(),
|
||||||
|
}
|
||||||
|
dataid := xid.New().String()
|
||||||
|
err := cache.Set(dataid, data)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
buttons = append(buttons, &tg.KeyboardButtonCallback{
|
||||||
|
Text: storage.Name(),
|
||||||
|
Data: fmt.Appendf(nil, "%s %s", tcbdata.TypeSetDefault, dataid),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
markup := &tg.ReplyInlineMarkup{}
|
||||||
|
for i := 0; i < len(buttons); i += 3 {
|
||||||
|
row := tg.KeyboardButtonRow{}
|
||||||
|
row.Buttons = buttons[i:min(i+3, len(buttons))]
|
||||||
|
markup.Rows = append(markup.Rows, row)
|
||||||
|
}
|
||||||
|
return markup, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func BuildSetDirKeyboard(dirs []database.Dir, dataid string) (*tg.ReplyInlineMarkup, error) {
|
||||||
|
data, ok := cache.Get[tcbdata.Add](dataid)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("failed to get data from cache: %s", dataid)
|
||||||
|
}
|
||||||
|
if data.DirID != 0 || data.SettedDir {
|
||||||
|
log.Warnf("Data already has a directory set: %d, %t", data.DirID, data.SettedDir)
|
||||||
|
return nil, fmt.Errorf("data already has a directory set")
|
||||||
|
}
|
||||||
|
buttons := make([]tg.KeyboardButtonClass, 0)
|
||||||
|
for _, dir := range dirs {
|
||||||
|
dirDataId := xid.New().String()
|
||||||
|
dirData := data
|
||||||
|
dirData.DirID = dir.ID
|
||||||
|
dirData.SettedDir = true
|
||||||
|
err := cache.Set(dirDataId, dirData)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to set directory data in cache: %w", err)
|
||||||
|
}
|
||||||
|
buttons = append(buttons, &tg.KeyboardButtonCallback{
|
||||||
|
Text: dir.Path,
|
||||||
|
Data: fmt.Appendf(nil, "%s %s", tcbdata.TypeAdd, dirDataId),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
dirDefaultDataId := xid.New().String()
|
||||||
|
dirDefaultData := data
|
||||||
|
dirDefaultData.DirID = 0
|
||||||
|
dirDefaultData.SettedDir = true
|
||||||
|
err := cache.Set(dirDefaultDataId, dirDefaultData)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to set default directory data in cache: %w", err)
|
||||||
|
}
|
||||||
|
buttons = append(buttons, &tg.KeyboardButtonCallback{
|
||||||
|
Text: "默认",
|
||||||
|
Data: fmt.Appendf(nil, "%s %s", tcbdata.TypeAdd, dirDefaultDataId),
|
||||||
|
})
|
||||||
|
markup := &tg.ReplyInlineMarkup{}
|
||||||
|
for i := 0; i < len(buttons); i += 3 {
|
||||||
|
row := tg.KeyboardButtonRow{}
|
||||||
|
row.Buttons = buttons[i:min(i+3, len(buttons))]
|
||||||
|
markup.Rows = append(markup.Rows, row)
|
||||||
|
}
|
||||||
|
return markup, nil
|
||||||
|
}
|
||||||
33
client/bot/handlers/utils/msgelem/task.go
Normal file
33
client/bot/handlers/utils/msgelem/task.go
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
package msgelem
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/log"
|
||||||
|
"github.com/gotd/td/telegram/message/entity"
|
||||||
|
"github.com/gotd/td/telegram/message/styling"
|
||||||
|
"github.com/gotd/td/tg"
|
||||||
|
)
|
||||||
|
|
||||||
|
func BuildTaskAddedEntities(
|
||||||
|
ctx context.Context,
|
||||||
|
filename string,
|
||||||
|
queueLength int,
|
||||||
|
) (string, []tg.MessageEntityClass) {
|
||||||
|
entityBuilder := entity.Builder{}
|
||||||
|
var entities []tg.MessageEntityClass
|
||||||
|
text := fmt.Sprintf("已添加到任务队列\n文件名: %s\n当前排队任务数: %d", filename, queueLength)
|
||||||
|
if err := styling.Perform(&entityBuilder,
|
||||||
|
styling.Plain("已添加到任务队列\n文件名: "),
|
||||||
|
styling.Code(filename),
|
||||||
|
styling.Plain("\n当前排队任务数: "),
|
||||||
|
styling.Bold(strconv.Itoa(queueLength)),
|
||||||
|
); err != nil {
|
||||||
|
log.FromContext(ctx).Errorf("Failed to build entity: %s", err)
|
||||||
|
} else {
|
||||||
|
text, entities = entityBuilder.Complete()
|
||||||
|
}
|
||||||
|
return text, entities
|
||||||
|
}
|
||||||
10
client/bot/handlers/utils/re/regexp.go
Normal file
10
client/bot/handlers/utils/re/regexp.go
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
package re
|
||||||
|
|
||||||
|
import "regexp"
|
||||||
|
|
||||||
|
var (
|
||||||
|
TgMessageLinkRegexString = `https?://t\.me/(?:c/\d+|[A-Za-z0-9_]+)/\d+(?:/\d+)?(?:\?[^\s#]*[A-Za-z0-9_])?\b`
|
||||||
|
TgMessageLinkRegexp = regexp.MustCompile(TgMessageLinkRegexString)
|
||||||
|
TelegraphUrlRegexString = `https://telegra.ph/.*`
|
||||||
|
TelegraphUrlRegexp = regexp.MustCompile(TelegraphUrlRegexString)
|
||||||
|
)
|
||||||
112
client/bot/handlers/utils/ruleutil/rule.go
Normal file
112
client/bot/handlers/utils/ruleutil/rule.go
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
package ruleutil
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/duke-git/lancet/v2/convertor"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/log"
|
||||||
|
"github.com/krau/SaveAny-Bot/database"
|
||||||
|
"github.com/krau/SaveAny-Bot/pkg/consts"
|
||||||
|
ruleenum "github.com/krau/SaveAny-Bot/pkg/enums/rule"
|
||||||
|
"github.com/krau/SaveAny-Bot/pkg/rule"
|
||||||
|
"github.com/krau/SaveAny-Bot/pkg/tfile"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ruleInput struct {
|
||||||
|
File tfile.TGFileMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
type ruleInputOption func(*ruleInput)
|
||||||
|
|
||||||
|
func NewInput(file tfile.TGFileMessage, opts ...ruleInputOption) *ruleInput {
|
||||||
|
input := &ruleInput{
|
||||||
|
File: file,
|
||||||
|
}
|
||||||
|
for _, opt := range opts {
|
||||||
|
opt(input)
|
||||||
|
}
|
||||||
|
return input
|
||||||
|
}
|
||||||
|
|
||||||
|
type matchedStorName string
|
||||||
|
|
||||||
|
func (m matchedStorName) String() string {
|
||||||
|
return string(m)
|
||||||
|
}
|
||||||
|
|
||||||
|
// can we use this storage name directly?
|
||||||
|
func (m matchedStorName) IsUsable() bool {
|
||||||
|
return m != "" && m != consts.RuleStorNameChosen
|
||||||
|
}
|
||||||
|
|
||||||
|
type MatchedDirPath string
|
||||||
|
|
||||||
|
func (m MatchedDirPath) String() string {
|
||||||
|
return string(m)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m MatchedDirPath) NeedNewForAlbum() bool {
|
||||||
|
return m != "" && m == consts.RuleDirPathNewForAlbum
|
||||||
|
}
|
||||||
|
|
||||||
|
func ApplyRule(ctx context.Context, rules []database.Rule, inputs *ruleInput) (matchedStorageName matchedStorName, dirPath MatchedDirPath) {
|
||||||
|
if inputs == nil || len(rules) == 0 {
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
logger := log.FromContext(ctx)
|
||||||
|
for _, ur := range rules {
|
||||||
|
switch ur.Type {
|
||||||
|
case ruleenum.FileNameRegex.String():
|
||||||
|
ru, err := rule.NewRuleFileNameRegex(ur.StorageName, ur.DirPath, ur.Data)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("Failed to create rule: %s", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ok, err := ru.Match(inputs.File)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("Failed to match rule: %s", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if ok {
|
||||||
|
dirPath = MatchedDirPath(ru.StoragePath())
|
||||||
|
matchedStorageName = matchedStorName(ru.StorageName())
|
||||||
|
}
|
||||||
|
case ruleenum.MessageRegex.String():
|
||||||
|
ru, err := rule.NewRuleMessageRegex(ur.StorageName, ur.DirPath, ur.Data)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("Failed to create rule: %s", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ok, err := ru.Match(inputs.File.Message().GetMessage())
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("Failed to match rule: %s", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if ok {
|
||||||
|
dirPath = MatchedDirPath(ru.StoragePath())
|
||||||
|
matchedStorageName = matchedStorName(ru.StorageName())
|
||||||
|
}
|
||||||
|
case ruleenum.IsAlbum.String():
|
||||||
|
matchAlbum, err := convertor.ToBool(ur.Data)
|
||||||
|
if err != nil {
|
||||||
|
matchAlbum = false
|
||||||
|
}
|
||||||
|
ru, err := rule.NewRuleMediaType(ur.StorageName, ur.DirPath, matchAlbum)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("Failed to create rule: %s", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ok, err := ru.Match(inputs.File.Message().GroupedID != 0)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("Failed to match rule: %s", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if ok {
|
||||||
|
dirPath = MatchedDirPath(ru.StoragePath())
|
||||||
|
matchedStorageName = matchedStorName(ru.StorageName())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
225
client/bot/handlers/utils/shortcut/message.go
Normal file
225
client/bot/handlers/utils/shortcut/message.go
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
// Some shortcuts for duplicate code in handlers, they should return dispatcher errors
|
||||||
|
package shortcut
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/celestix/gotgproto/dispatcher"
|
||||||
|
"github.com/celestix/gotgproto/ext"
|
||||||
|
"github.com/celestix/gotgproto/types"
|
||||||
|
"github.com/charmbracelet/log"
|
||||||
|
"github.com/gotd/td/tg"
|
||||||
|
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/mediautil"
|
||||||
|
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/msgelem"
|
||||||
|
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/re"
|
||||||
|
uc "github.com/krau/SaveAny-Bot/client/user"
|
||||||
|
"github.com/krau/SaveAny-Bot/common/cache"
|
||||||
|
"github.com/krau/SaveAny-Bot/common/utils/tgutil"
|
||||||
|
"github.com/krau/SaveAny-Bot/common/utils/tphutil"
|
||||||
|
"github.com/krau/SaveAny-Bot/config"
|
||||||
|
"github.com/krau/SaveAny-Bot/pkg/telegraph"
|
||||||
|
"github.com/krau/SaveAny-Bot/pkg/tfile"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 获取消息中的文件并回复等待消息, 返回等待消息, 获取到的文件
|
||||||
|
func GetFileFromMessageWithReply(ctx *ext.Context, update *ext.Update, message *tg.Message, tfileopts ...tfile.TGFileOptions) (replied *types.Message,
|
||||||
|
file tfile.TGFileMessage, err error,
|
||||||
|
) {
|
||||||
|
logger := log.FromContext(ctx)
|
||||||
|
media := message.Media
|
||||||
|
supported := mediautil.IsSupported(media)
|
||||||
|
if !supported {
|
||||||
|
ctx.Reply(update, ext.ReplyTextString("不支持的消息类型"), nil)
|
||||||
|
return nil, nil, dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
|
||||||
|
replied, err = ctx.Reply(update, ext.ReplyTextString("正在获取文件信息..."), nil)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("Failed to reply: %s", err)
|
||||||
|
return nil, nil, dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
options := []tfile.TGFileOptions{
|
||||||
|
tfile.WithMessage(message),
|
||||||
|
}
|
||||||
|
if len(tfileopts) > 0 {
|
||||||
|
options = append(options, tfileopts...)
|
||||||
|
} else {
|
||||||
|
options = append(options, tfile.WithNameIfEmpty(tgutil.GenFileNameFromMessage(*message)))
|
||||||
|
}
|
||||||
|
file, err = tfile.FromMediaMessage(media, ctx.Raw, message, options...)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("Failed to get file from media: %s", err)
|
||||||
|
ctx.Reply(update, ext.ReplyTextString("获取文件失败: "+err.Error()), nil)
|
||||||
|
return nil, nil, dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
return replied, file, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type EditMessageFunc func(text string, markup tg.ReplyMarkupClass)
|
||||||
|
|
||||||
|
// 获取链接中的文件并回复等待消息
|
||||||
|
func GetFilesFromUpdateLinkMessageWithReplyEdit(ctx *ext.Context, update *ext.Update) (replied *types.Message, files []tfile.TGFileMessage, editReplied EditMessageFunc, err error) {
|
||||||
|
logger := log.FromContext(ctx)
|
||||||
|
msgLinks := re.TgMessageLinkRegexp.FindAllString(update.EffectiveMessage.GetMessage(), -1)
|
||||||
|
if len(msgLinks) == 0 {
|
||||||
|
logger.Warn("no matched message links but called handleMessageLink")
|
||||||
|
return nil, nil, nil, dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
replied, err = ctx.Reply(update, ext.ReplyTextString("正在获取消息..."), nil)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("failed to reply: %s", err)
|
||||||
|
return nil, nil, nil, dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
editReplied = func(text string, markup tg.ReplyMarkupClass) {
|
||||||
|
if _, err := ctx.EditMessage(update.EffectiveChat().GetID(), &tg.MessagesEditMessageRequest{
|
||||||
|
ID: replied.ID,
|
||||||
|
Message: text,
|
||||||
|
ReplyMarkup: markup,
|
||||||
|
}); err != nil {
|
||||||
|
logger.Errorf("failed to edit message: %s", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
files = make([]tfile.TGFileMessage, 0, len(msgLinks))
|
||||||
|
addFile := func(client tfile.DlerClient, msg *tg.Message) {
|
||||||
|
if msg == nil || msg.Media == nil {
|
||||||
|
logger.Warn("message is nil, skipping")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
media, ok := msg.GetMedia()
|
||||||
|
if !ok {
|
||||||
|
logger.Debugf("message %d has no media", msg.GetID())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
file, err := tfile.FromMediaMessage(media, client, msg, tfile.WithNameIfEmpty(tgutil.GenFileNameFromMessage(*msg)))
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("failed to create file from media: %s", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
files = append(files, file)
|
||||||
|
}
|
||||||
|
|
||||||
|
tctx := ctx
|
||||||
|
if config.Cfg.Telegram.Userbot.Enable {
|
||||||
|
tctx = uc.GetCtx()
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, link := range msgLinks {
|
||||||
|
linkUrl, err := url.Parse(link)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("failed to parse message link %s: %s", link, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
chatId, msgId, err := tgutil.ParseMessageLink(tctx, link)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("failed to parse message link %s: %s", link, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
msg, err := tgutil.GetMessageByID(tctx, chatId, msgId)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("failed to get message by ID: %s", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
groupID, isGroup := msg.GetGroupedID()
|
||||||
|
if isGroup && groupID != 0 && !linkUrl.Query().Has("single") {
|
||||||
|
gmsgs, err := tgutil.GetGroupedMessages(ctx, chatId, msg)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("failed to get grouped messages: %s", err)
|
||||||
|
} else {
|
||||||
|
for _, gmsg := range gmsgs {
|
||||||
|
addFile(tctx.Raw, gmsg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
addFile(tctx.Raw, msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(files) == 0 {
|
||||||
|
editReplied("没有找到可保存的文件", nil)
|
||||||
|
return nil, nil, nil, dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
return replied, files, editReplied, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetCallbackDataWithAnswer[DataType any](ctx *ext.Context, update *ext.Update, dataid string) (DataType, error) {
|
||||||
|
data, ok := cache.Get[DataType](dataid)
|
||||||
|
if !ok {
|
||||||
|
log.FromContext(ctx).Warnf("Invalid data ID: %s", dataid)
|
||||||
|
queryID := update.CallbackQuery.GetQueryID()
|
||||||
|
ctx.AnswerCallback(msgelem.AlertCallbackAnswer(queryID, "数据已过期或无效"))
|
||||||
|
var zero DataType
|
||||||
|
return zero, dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type TelegraphResult struct {
|
||||||
|
Pics []string `json:"pics"` // image urls
|
||||||
|
TphDir string `json:"tph_dir"` // telegraph path, unescaped
|
||||||
|
Page *telegraph.Page `json:"page"` // telegraph page node
|
||||||
|
}
|
||||||
|
|
||||||
|
// return replied message, image urls, telegraph path(unescaped), error
|
||||||
|
func GetTphPicsFromMessageWithReply(ctx *ext.Context, update *ext.Update) (*types.Message, *TelegraphResult, error) {
|
||||||
|
logger := log.FromContext(ctx)
|
||||||
|
tphurl := re.TelegraphUrlRegexp.FindString(update.EffectiveMessage.GetMessage()) // TODO: batch urls
|
||||||
|
if tphurl == "" {
|
||||||
|
logger.Warnf("No telegraph url found but called handleTelegraph")
|
||||||
|
return nil, nil, dispatcher.ContinueGroups
|
||||||
|
}
|
||||||
|
pagepath := strings.Split(tphurl, "/")[len(strings.Split(tphurl, "/"))-1]
|
||||||
|
tphdir, err := url.PathUnescape(pagepath)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("Failed to unescape telegraph path: %s", err)
|
||||||
|
ctx.Reply(update, ext.ReplyTextString("解析 telegraph 路径失败: "+err.Error()), nil)
|
||||||
|
return nil, nil, dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
msg, err := ctx.Reply(update, ext.ReplyTextString("正在获取 telegraph 页面..."), nil)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("Failed to reply to update: %s", err)
|
||||||
|
return nil, nil, dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
page, err := tphutil.DefaultClient().GetPage(ctx, pagepath)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("Failed to get telegraph page: %s", err)
|
||||||
|
ctx.Reply(update, ext.ReplyTextString("获取 telegraph 页面失败: "+err.Error()), nil)
|
||||||
|
return nil, nil, dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
imgs := make([]string, 0)
|
||||||
|
for _, elem := range page.Content {
|
||||||
|
var node telegraph.NodeElement
|
||||||
|
data, err := json.Marshal(elem)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("Failed to marshal element: %s", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
err = json.Unmarshal(data, &node)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("Failed to unmarshal element: %s", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(node.Children) != 0 {
|
||||||
|
for _, child := range node.Children {
|
||||||
|
imgs = append(imgs, tphutil.GetNodeImages(child)...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if node.Tag == "img" {
|
||||||
|
if src, ok := node.Attrs["src"]; ok {
|
||||||
|
imgs = append(imgs, src)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(imgs) == 0 {
|
||||||
|
logger.Warn("No images found in telegraph page")
|
||||||
|
ctx.Reply(update, ext.ReplyTextString("在 telegraph 页面中未找到图片"), nil)
|
||||||
|
return nil, nil, dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
return msg, &TelegraphResult{
|
||||||
|
Pics: imgs,
|
||||||
|
TphDir: tphdir,
|
||||||
|
Page: page,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
200
client/bot/handlers/utils/shortcut/tftask.go
Normal file
200
client/bot/handlers/utils/shortcut/tftask.go
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
package shortcut
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"path"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/celestix/gotgproto/dispatcher"
|
||||||
|
"github.com/celestix/gotgproto/ext"
|
||||||
|
"github.com/charmbracelet/log"
|
||||||
|
"github.com/gotd/td/tg"
|
||||||
|
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/msgelem"
|
||||||
|
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/ruleutil"
|
||||||
|
"github.com/krau/SaveAny-Bot/common/utils/tgutil"
|
||||||
|
"github.com/krau/SaveAny-Bot/core"
|
||||||
|
"github.com/krau/SaveAny-Bot/core/batchtftask"
|
||||||
|
"github.com/krau/SaveAny-Bot/core/tftask"
|
||||||
|
"github.com/krau/SaveAny-Bot/database"
|
||||||
|
"github.com/krau/SaveAny-Bot/pkg/tfile"
|
||||||
|
"github.com/krau/SaveAny-Bot/storage"
|
||||||
|
"github.com/rs/xid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 创建一个 tftask.TGFileTask 并添加到任务队列中, 以编辑消息的方式反馈结果
|
||||||
|
func CreateAndAddTGFileTaskWithEdit(ctx *ext.Context, userID int64, stor storage.Storage, dirPath string, file tfile.TGFileMessage, trackMsgID int) error {
|
||||||
|
logger := log.FromContext(ctx)
|
||||||
|
user, err := database.GetUserByChatID(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("Failed to get user by chat ID: %s", err)
|
||||||
|
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
|
||||||
|
ID: trackMsgID,
|
||||||
|
Message: "获取用户失败: " + err.Error(),
|
||||||
|
})
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
if user.ApplyRule && user.Rules != nil {
|
||||||
|
matchedStorageName, matchedDirPath := ruleutil.ApplyRule(ctx, user.Rules, ruleutil.NewInput(file))
|
||||||
|
dirPath = matchedDirPath.String()
|
||||||
|
if matchedStorageName.IsUsable() {
|
||||||
|
stor, err = storage.GetStorageByUserIDAndName(ctx, user.ChatID, matchedStorageName.String())
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("Failed to get storage by user ID and name: %s", err)
|
||||||
|
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
|
||||||
|
ID: trackMsgID,
|
||||||
|
Message: "获取存储失败: " + err.Error(),
|
||||||
|
})
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
storagePath := stor.JoinStoragePath(path.Join(dirPath, file.Name()))
|
||||||
|
injectCtx := tgutil.ExtWithContext(ctx.Context, ctx)
|
||||||
|
taskid := xid.New().String()
|
||||||
|
task, err := tftask.NewTGFileTask(taskid, injectCtx, file, stor, storagePath,
|
||||||
|
tftask.NewProgressTrack(
|
||||||
|
trackMsgID,
|
||||||
|
userID))
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("create task failed: %s", err)
|
||||||
|
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
|
||||||
|
ID: trackMsgID,
|
||||||
|
Message: "创建任务失败: " + err.Error(),
|
||||||
|
})
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
if err := core.AddTask(injectCtx, task); err != nil {
|
||||||
|
logger.Errorf("add task failed: %s", err)
|
||||||
|
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
|
||||||
|
ID: trackMsgID,
|
||||||
|
Message: "添加任务失败: " + err.Error(),
|
||||||
|
})
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
text, entities := msgelem.BuildTaskAddedEntities(ctx, file.Name(), core.GetLength(injectCtx))
|
||||||
|
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
|
||||||
|
ID: trackMsgID,
|
||||||
|
Message: text,
|
||||||
|
Entities: entities,
|
||||||
|
})
|
||||||
|
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建一个 batchtftask.BatchTGFileTask 并添加到任务队列中, 以编辑消息的方式反馈结果
|
||||||
|
func CreateAndAddBatchTGFileTaskWithEdit(ctx *ext.Context, userID int64, stor storage.Storage, dirPath string, files []tfile.TGFileMessage, trackMsgID int) error {
|
||||||
|
logger := log.FromContext(ctx)
|
||||||
|
user, err := database.GetUserByChatID(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("Failed to get user by chat ID: %s", err)
|
||||||
|
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
|
||||||
|
ID: trackMsgID,
|
||||||
|
Message: "获取用户失败: " + err.Error(),
|
||||||
|
})
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
|
||||||
|
useRule := user.ApplyRule && user.Rules != nil
|
||||||
|
|
||||||
|
applyRule := func(file tfile.TGFileMessage) (string, ruleutil.MatchedDirPath) {
|
||||||
|
if !useRule {
|
||||||
|
return stor.Name(), ruleutil.MatchedDirPath(dirPath)
|
||||||
|
}
|
||||||
|
storName, dirP := ruleutil.ApplyRule(ctx, user.Rules, ruleutil.NewInput(file))
|
||||||
|
|
||||||
|
storname := storName.String()
|
||||||
|
if !storName.IsUsable() {
|
||||||
|
storname = stor.Name()
|
||||||
|
}
|
||||||
|
return storname, dirP
|
||||||
|
}
|
||||||
|
|
||||||
|
elems := make([]batchtftask.TaskElement, 0, len(files))
|
||||||
|
type albumFile struct {
|
||||||
|
file tfile.TGFileMessage
|
||||||
|
storage storage.Storage
|
||||||
|
}
|
||||||
|
albumFiles := make(map[int64][]albumFile, 0)
|
||||||
|
for _, file := range files {
|
||||||
|
storName, dirPath := applyRule(file)
|
||||||
|
fileStor := stor
|
||||||
|
if storName != stor.Name() && storName != "" {
|
||||||
|
fileStor, err = storage.GetStorageByUserIDAndName(ctx, user.ChatID, storName)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("Failed to get storage by user ID and name: %s", err)
|
||||||
|
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
|
||||||
|
ID: trackMsgID,
|
||||||
|
Message: "获取存储失败: " + err.Error(),
|
||||||
|
})
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !dirPath.NeedNewForAlbum() {
|
||||||
|
storPath := fileStor.JoinStoragePath(path.Join(dirPath.String(), file.Name()))
|
||||||
|
elem, err := batchtftask.NewTaskElement(fileStor, storPath, file)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("Failed to create task element: %s", err)
|
||||||
|
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
|
||||||
|
ID: trackMsgID,
|
||||||
|
Message: "任务创建失败: " + err.Error(),
|
||||||
|
})
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
elems = append(elems, *elem)
|
||||||
|
} else {
|
||||||
|
groupId, isGroup := file.Message().GetGroupedID()
|
||||||
|
if !isGroup || groupId == 0 {
|
||||||
|
logger.Warnf("File %s is not in a group, skipping album handling", file.Name())
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := albumFiles[groupId]; !ok {
|
||||||
|
albumFiles[groupId] = make([]albumFile, 0)
|
||||||
|
}
|
||||||
|
albumFiles[groupId] = append(albumFiles[groupId], albumFile{
|
||||||
|
file: file,
|
||||||
|
storage: fileStor,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, afiles := range albumFiles {
|
||||||
|
if len(afiles) <= 1 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// 对于需要新建目录的文件, 将第一个文件的文件名(去除扩展名)作为目录名
|
||||||
|
// 存储以第一个文件的存储为准
|
||||||
|
albumDir := strings.TrimSuffix(path.Base(afiles[0].file.Name()), path.Ext(afiles[0].file.Name()))
|
||||||
|
albumStor := afiles[0].storage
|
||||||
|
for _, af := range afiles {
|
||||||
|
afstorPath := af.storage.JoinStoragePath(path.Join(dirPath, albumDir, af.file.Name()))
|
||||||
|
elem, err := batchtftask.NewTaskElement(albumStor, afstorPath, af.file)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("Failed to create task element for album file: %s", err)
|
||||||
|
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
|
||||||
|
ID: trackMsgID,
|
||||||
|
Message: "任务创建失败: " + err.Error(),
|
||||||
|
})
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
elems = append(elems, *elem)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
injectCtx := tgutil.ExtWithContext(ctx.Context, ctx)
|
||||||
|
taskid := xid.New().String()
|
||||||
|
task := batchtftask.NewBatchTGFileTask(taskid, injectCtx, elems, batchtftask.NewProgressTracker(trackMsgID, userID), true)
|
||||||
|
if err := core.AddTask(injectCtx, task); err != nil {
|
||||||
|
logger.Errorf("Failed to add batch task: %s", err)
|
||||||
|
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
|
||||||
|
ID: trackMsgID,
|
||||||
|
Message: "批量任务添加失败: " + err.Error(),
|
||||||
|
})
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
|
||||||
|
ID: trackMsgID,
|
||||||
|
Message: fmt.Sprintf("已添加批量任务, 共 %d 个文件", len(files)),
|
||||||
|
ReplyMarkup: nil,
|
||||||
|
})
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
50
client/bot/handlers/utils/shortcut/tphtask.go
Normal file
50
client/bot/handlers/utils/shortcut/tphtask.go
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
package shortcut
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/celestix/gotgproto/dispatcher"
|
||||||
|
"github.com/celestix/gotgproto/ext"
|
||||||
|
"github.com/charmbracelet/log"
|
||||||
|
"github.com/gotd/td/tg"
|
||||||
|
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/msgelem"
|
||||||
|
"github.com/krau/SaveAny-Bot/common/utils/tgutil"
|
||||||
|
"github.com/krau/SaveAny-Bot/common/utils/tphutil"
|
||||||
|
"github.com/krau/SaveAny-Bot/core"
|
||||||
|
"github.com/krau/SaveAny-Bot/core/tphtask"
|
||||||
|
"github.com/krau/SaveAny-Bot/pkg/telegraph"
|
||||||
|
"github.com/krau/SaveAny-Bot/storage"
|
||||||
|
"github.com/rs/xid"
|
||||||
|
)
|
||||||
|
|
||||||
|
func CreateAndAddTphTaskWithEdit(ctx *ext.Context,
|
||||||
|
userID int64,
|
||||||
|
tphpage *telegraph.Page,
|
||||||
|
dirPath string, // unescaped ph path for file storage
|
||||||
|
pics []string,
|
||||||
|
stor storage.Storage,
|
||||||
|
trackMsgID int) error {
|
||||||
|
injectCtx := tgutil.ExtWithContext(ctx.Context, ctx)
|
||||||
|
task := tphtask.NewTask(xid.New().String(),
|
||||||
|
injectCtx,
|
||||||
|
tphpage.Path,
|
||||||
|
pics,
|
||||||
|
stor,
|
||||||
|
stor.JoinStoragePath(dirPath),
|
||||||
|
tphutil.DefaultClient(),
|
||||||
|
tphtask.NewProgress(trackMsgID, userID),
|
||||||
|
)
|
||||||
|
if err := core.AddTask(injectCtx, task); err != nil {
|
||||||
|
log.FromContext(ctx).Errorf("Failed to add task: %s", err)
|
||||||
|
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
|
||||||
|
ID: trackMsgID,
|
||||||
|
Message: "任务添加失败: " + err.Error(),
|
||||||
|
})
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
text, entities := msgelem.BuildTaskAddedEntities(ctx, tphpage.Title, core.GetLength(ctx))
|
||||||
|
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
|
||||||
|
ID: trackMsgID,
|
||||||
|
Message: text,
|
||||||
|
Entities: entities,
|
||||||
|
})
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
30
client/middleware/default.go
Normal file
30
client/middleware/default.go
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/cenkalti/backoff/v4"
|
||||||
|
"github.com/gotd/contrib/middleware/floodwait"
|
||||||
|
"github.com/gotd/td/telegram"
|
||||||
|
"github.com/krau/SaveAny-Bot/client/middleware/recovery"
|
||||||
|
"github.com/krau/SaveAny-Bot/client/middleware/retry"
|
||||||
|
"github.com/krau/SaveAny-Bot/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// https://github.com/iyear/tdl/blob/master/core/tclient/tclient.go
|
||||||
|
func NewDefaultMiddlewares(ctx context.Context, timeout time.Duration) []telegram.Middleware {
|
||||||
|
return []telegram.Middleware{
|
||||||
|
recovery.New(ctx, newBackoff(timeout)),
|
||||||
|
retry.New(config.Cfg.Telegram.RpcRetry),
|
||||||
|
floodwait.NewSimpleWaiter(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newBackoff(timeout time.Duration) backoff.BackOff {
|
||||||
|
b := backoff.NewExponentialBackOff()
|
||||||
|
b.Multiplier = 1.1
|
||||||
|
b.MaxElapsedTime = timeout
|
||||||
|
b.MaxInterval = 10 * time.Second
|
||||||
|
return b
|
||||||
|
}
|
||||||
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
61
client/middleware/recovery/recovery.go
Normal file
61
client/middleware/recovery/recovery.go
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
package recovery
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/cenkalti/backoff/v4"
|
||||||
|
"github.com/charmbracelet/log"
|
||||||
|
"github.com/go-faster/errors"
|
||||||
|
"github.com/gotd/td/bin"
|
||||||
|
"github.com/gotd/td/telegram"
|
||||||
|
"github.com/gotd/td/tg"
|
||||||
|
"github.com/gotd/td/tgerr"
|
||||||
|
)
|
||||||
|
|
||||||
|
type recovery struct {
|
||||||
|
ctx context.Context
|
||||||
|
backoff backoff.BackOff
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(ctx context.Context, backoff backoff.BackOff) telegram.Middleware {
|
||||||
|
return &recovery{
|
||||||
|
ctx: ctx,
|
||||||
|
backoff: backoff,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *recovery) Handle(next tg.Invoker) telegram.InvokeFunc {
|
||||||
|
return func(ctx context.Context, input bin.Encoder, output bin.Decoder) error {
|
||||||
|
|
||||||
|
return backoff.RetryNotify(func() error {
|
||||||
|
if err := next.Invoke(ctx, input, output); err != nil {
|
||||||
|
if r.shouldRecover(ctx, err) {
|
||||||
|
return errors.Wrap(err, "recover")
|
||||||
|
}
|
||||||
|
|
||||||
|
return backoff.Permanent(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}, r.backoff, func(err error, duration time.Duration) {
|
||||||
|
log.FromContext(ctx).Debug("Wait for connection recovery", "error", err, "duration", duration)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *recovery) shouldRecover(ctx context.Context, err error) bool {
|
||||||
|
// context in recovery is used to stop recovery process by external os signal, otherwise we will wait till max retries when user press ctrl+c
|
||||||
|
select {
|
||||||
|
case <-r.ctx.Done():
|
||||||
|
return false
|
||||||
|
case <-ctx.Done():
|
||||||
|
return false
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
// we try recover when encountered any error that is not telegram business error
|
||||||
|
_, ok := tgerr.As(err)
|
||||||
|
|
||||||
|
return !ok
|
||||||
|
}
|
||||||
56
client/middleware/retry/retry.go
Normal file
56
client/middleware/retry/retry.go
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
package retry
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/log"
|
||||||
|
"github.com/gotd/td/bin"
|
||||||
|
"github.com/gotd/td/telegram"
|
||||||
|
"github.com/gotd/td/tg"
|
||||||
|
"github.com/gotd/td/tgerr"
|
||||||
|
)
|
||||||
|
|
||||||
|
var internalErrors = []string{
|
||||||
|
"Timedout", // #373
|
||||||
|
"No workers running",
|
||||||
|
"RPC_CALL_FAIL",
|
||||||
|
"RPC_MCGET_FAIL",
|
||||||
|
"WORKER_BUSY_TOO_LONG_RETRY", // #462
|
||||||
|
"memory limit exit", // #504
|
||||||
|
}
|
||||||
|
|
||||||
|
type retry struct {
|
||||||
|
max int
|
||||||
|
errors []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r retry) Handle(next tg.Invoker) telegram.InvokeFunc {
|
||||||
|
return func(ctx context.Context, input bin.Encoder, output bin.Decoder) error {
|
||||||
|
retries := 0
|
||||||
|
|
||||||
|
for retries < r.max {
|
||||||
|
if err := next.Invoke(ctx, input, output); err != nil {
|
||||||
|
if tgerr.Is(err, r.errors...) {
|
||||||
|
log.FromContext(ctx).Debug("retry middleware", "retries", retries, "error", err)
|
||||||
|
retries++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// retry middleware skip
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Errorf("retry limit reached after %d attempts", r.max)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// New returns middleware that retries request if it fails with one of provided errors.
|
||||||
|
func New(max int, errors ...string) telegram.Middleware {
|
||||||
|
return retry{
|
||||||
|
max: max,
|
||||||
|
errors: append(errors, internalErrors...), // #373
|
||||||
|
}
|
||||||
|
}
|
||||||
80
client/user/auth_terminal.go
Normal file
80
client/user/auth_terminal.go
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
package user
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/celestix/gotgproto"
|
||||||
|
"github.com/charmbracelet/huh"
|
||||||
|
"github.com/charmbracelet/log"
|
||||||
|
"github.com/fatih/color"
|
||||||
|
)
|
||||||
|
|
||||||
|
type terminalAuthConversator struct{}
|
||||||
|
|
||||||
|
func (t *terminalAuthConversator) AskPhoneNumber() (string, error) {
|
||||||
|
phone := ""
|
||||||
|
err := huh.NewInput().Title("Your Phone Number").
|
||||||
|
Placeholder("+44 123456").
|
||||||
|
Prompt("> ").
|
||||||
|
Value(&phone).
|
||||||
|
WithTheme(huh.ThemeCatppuccin()).
|
||||||
|
Run()
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Info("Sending code to your phone number...")
|
||||||
|
|
||||||
|
return strings.TrimSpace(phone), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *terminalAuthConversator) AskCode() (string, error) {
|
||||||
|
code := ""
|
||||||
|
err := huh.NewInput().Title("Your Code").
|
||||||
|
Placeholder("123456").
|
||||||
|
Value(&code).
|
||||||
|
Prompt("> ").
|
||||||
|
WithTheme(huh.ThemeCatppuccin()).
|
||||||
|
Run()
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.TrimSpace(code), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *terminalAuthConversator) AskPassword() (string, error) {
|
||||||
|
pwd := ""
|
||||||
|
|
||||||
|
err := huh.NewInput().Title("Your 2FA Password").
|
||||||
|
EchoMode(huh.EchoModePassword).
|
||||||
|
Value(&pwd).
|
||||||
|
Prompt("> ").
|
||||||
|
WithTheme(huh.ThemeCatppuccin()).
|
||||||
|
Run()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.TrimSpace(pwd), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *terminalAuthConversator) AuthStatus(authStatus gotgproto.AuthStatus) {
|
||||||
|
switch authStatus.Event {
|
||||||
|
case gotgproto.AuthStatusPhoneRetrial:
|
||||||
|
color.Red("The phone number you just entered seems to be incorrect,")
|
||||||
|
color.Red("Attempts Left: %d", authStatus.AttemptsLeft)
|
||||||
|
color.Red("Please try again....")
|
||||||
|
case gotgproto.AuthStatusPasswordRetrial:
|
||||||
|
color.Red("The 2FA password you just entered seems to be incorrect,")
|
||||||
|
color.Red("Attempts Left: %d", authStatus.AttemptsLeft)
|
||||||
|
color.Red("Please try again....")
|
||||||
|
case gotgproto.AuthStatusPhoneCodeRetrial:
|
||||||
|
color.Red("The OTP you just entered seems to be incorrect,")
|
||||||
|
color.Red("Attempts Left: %d", authStatus.AttemptsLeft)
|
||||||
|
color.Red("Please try again....")
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
112
client/user/userclient.go
Normal file
112
client/user/userclient.go
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
package user
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/celestix/gotgproto"
|
||||||
|
"github.com/celestix/gotgproto/dispatcher"
|
||||||
|
"github.com/celestix/gotgproto/ext"
|
||||||
|
"github.com/celestix/gotgproto/sessionMaker"
|
||||||
|
"github.com/charmbracelet/log"
|
||||||
|
"github.com/gotd/td/telegram/dcs"
|
||||||
|
"github.com/krau/SaveAny-Bot/client/middleware"
|
||||||
|
"github.com/krau/SaveAny-Bot/common/utils/netutil"
|
||||||
|
"github.com/krau/SaveAny-Bot/config"
|
||||||
|
"github.com/ncruces/go-sqlite3/gormlite"
|
||||||
|
"golang.org/x/net/proxy"
|
||||||
|
)
|
||||||
|
|
||||||
|
var uc *gotgproto.Client
|
||||||
|
var ectx *ext.Context
|
||||||
|
|
||||||
|
func GetCtx() *ext.Context {
|
||||||
|
if uc == nil {
|
||||||
|
panic("User client is not initialized, please call Login first")
|
||||||
|
}
|
||||||
|
if ectx != nil {
|
||||||
|
return ectx
|
||||||
|
}
|
||||||
|
ectx = uc.CreateContext()
|
||||||
|
return ectx
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetClient() *gotgproto.Client {
|
||||||
|
if uc == nil {
|
||||||
|
panic("User client is not initialized, please call Login first")
|
||||||
|
}
|
||||||
|
return uc
|
||||||
|
}
|
||||||
|
|
||||||
|
func Login(ctx context.Context) (*gotgproto.Client, error) {
|
||||||
|
log.FromContext(ctx).Debug("Logging in user client")
|
||||||
|
if uc != nil {
|
||||||
|
return uc, nil
|
||||||
|
}
|
||||||
|
res := make(chan struct {
|
||||||
|
client *gotgproto.Client
|
||||||
|
err error
|
||||||
|
})
|
||||||
|
go func() {
|
||||||
|
var resolver dcs.Resolver
|
||||||
|
if config.Cfg.Telegram.Proxy.Enable && config.Cfg.Telegram.Proxy.URL != "" {
|
||||||
|
dialer, err := netutil.NewProxyDialer(config.Cfg.Telegram.Proxy.URL)
|
||||||
|
if err != nil {
|
||||||
|
res <- struct {
|
||||||
|
client *gotgproto.Client
|
||||||
|
err error
|
||||||
|
}{nil, err}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resolver = dcs.Plain(dcs.PlainOptions{
|
||||||
|
Dial: dialer.(proxy.ContextDialer).DialContext,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
resolver = dcs.DefaultResolver()
|
||||||
|
}
|
||||||
|
tclient, err := gotgproto.NewClient(
|
||||||
|
config.Cfg.Telegram.AppID,
|
||||||
|
config.Cfg.Telegram.AppHash,
|
||||||
|
gotgproto.ClientTypePhone(""),
|
||||||
|
&gotgproto.ClientOpts{
|
||||||
|
Session: sessionMaker.SqlSession(gormlite.Open(config.Cfg.Telegram.Userbot.Session)),
|
||||||
|
AuthConversator: &terminalAuthConversator{},
|
||||||
|
Context: ctx,
|
||||||
|
DisableCopyright: true,
|
||||||
|
Resolver: resolver,
|
||||||
|
MaxRetries: config.Cfg.Telegram.RpcRetry,
|
||||||
|
AutoFetchReply: true,
|
||||||
|
Middlewares: middleware.NewDefaultMiddlewares(ctx, 5*time.Minute),
|
||||||
|
ErrorHandler: func(ctx *ext.Context, u *ext.Update, s string) error {
|
||||||
|
log.FromContext(ctx).Errorf("Unhandled error: %s", s)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
res <- struct {
|
||||||
|
client *gotgproto.Client
|
||||||
|
err error
|
||||||
|
}{nil, err}
|
||||||
|
}
|
||||||
|
res <- struct {
|
||||||
|
client *gotgproto.Client
|
||||||
|
err error
|
||||||
|
}(struct {
|
||||||
|
client *gotgproto.Client
|
||||||
|
err error
|
||||||
|
}{tclient, nil})
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil, ctx.Err()
|
||||||
|
case r := <-res:
|
||||||
|
if r.err != nil {
|
||||||
|
return nil, r.err
|
||||||
|
}
|
||||||
|
uc = r.client
|
||||||
|
log.FromContext(ctx).Infof("User client logged in successfully: %s", uc.Self.FirstName+" "+uc.Self.LastName)
|
||||||
|
return uc, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
84
cmd/geni18n/main.go
Normal file
84
cmd/geni18n/main.go
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
// cmd/geni18n/main.go
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
dir := flag.String("dir", "./common/i18n/locale", "Locales directory path")
|
||||||
|
out := flag.String("out", "common/i18n/i18nk/keys.go", "Output file path")
|
||||||
|
pkg := flag.String("pkg", "i18nk", "Package name for generated file")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
keys := make(map[string]struct{})
|
||||||
|
re := regexp.MustCompile(`^\s*\[+\s*([^\]\[]+)\s*\]+`)
|
||||||
|
|
||||||
|
err := filepath.WalkDir(*dir, func(path string, d fs.DirEntry, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if d.IsDir() || !strings.HasSuffix(d.Name(), ".toml") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
s := bufio.NewScanner(f)
|
||||||
|
for s.Scan() {
|
||||||
|
if m := re.FindStringSubmatch(s.Text()); m != nil {
|
||||||
|
keys[m[1]] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s.Err()
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error walking directory: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
var list []string
|
||||||
|
for k := range keys {
|
||||||
|
list = append(list, k)
|
||||||
|
}
|
||||||
|
sort.Strings(list)
|
||||||
|
|
||||||
|
f, err := os.Create(*out)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error creating output file: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
w := bufio.NewWriter(f)
|
||||||
|
fmt.Fprintf(w, "// Code generated by cmd/gen_i18n. DO NOT EDIT.\n")
|
||||||
|
fmt.Fprintf(w, "package %s\n\n", *pkg)
|
||||||
|
fmt.Fprintf(w, "const (\n")
|
||||||
|
for _, key := range list {
|
||||||
|
name := toPascal(key)
|
||||||
|
fmt.Fprintf(w, "\t%s = %q\n", name, key)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, ")\n")
|
||||||
|
w.Flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
func toPascal(key string) string {
|
||||||
|
parts := strings.Split(key, ".")
|
||||||
|
for i, p := range parts {
|
||||||
|
if len(p) > 0 {
|
||||||
|
parts[i] = strings.ToUpper(string(p[0])) + p[1:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Join(parts, "")
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package cmd
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
97
cmd/run.go
97
cmd/run.go
@@ -1,67 +1,100 @@
|
|||||||
package cmd
|
package cmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"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/storage"
|
"github.com/krau/SaveAny-Bot/storage"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
func Run(_ *cobra.Command, _ []string) {
|
func Run(cmd *cobra.Command, _ []string) {
|
||||||
InitAll()
|
ctx := 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)
|
initAll(ctx)
|
||||||
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
core.Run(ctx)
|
||||||
sig := <-quit
|
|
||||||
common.Log.Info(sig, ", exitting...")
|
<-ctx.Done()
|
||||||
defer common.Log.Info("Bye!")
|
logger.Info(i18n.T(i18nk.Exiting))
|
||||||
|
defer logger.Info(i18n.T(i18nk.Bye))
|
||||||
|
cleanCache()
|
||||||
|
}
|
||||||
|
|
||||||
|
func initAll(ctx context.Context) {
|
||||||
|
if err := config.Init(ctx); err != nil {
|
||||||
|
fmt.Println("Failed to load config:", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
cache.Init()
|
||||||
|
logger := log.FromContext(ctx)
|
||||||
|
i18n.Init(config.Cfg.Lang)
|
||||||
|
logger.Info(i18n.T(i18nk.Initing))
|
||||||
|
database.Init(ctx)
|
||||||
|
storage.LoadStorages(ctx)
|
||||||
|
if config.Cfg.Telegram.Userbot.Enable {
|
||||||
|
_, err := userclient.Login(ctx)
|
||||||
|
if err != nil {
|
||||||
|
logger.Fatalf("User client login failed: %s", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bot.Init(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func cleanCache() {
|
||||||
if config.Cfg.NoCleanCache {
|
if config.Cfg.NoCleanCache {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if config.Cfg.Temp.BasePath != "" && !config.Cfg.Stream {
|
if config.Cfg.Temp.BasePath != "" && !config.Cfg.Stream {
|
||||||
if slices.Contains([]string{"/", ".", "\\", ".."}, filepath.Clean(config.Cfg.Temp.BasePath)) {
|
if slices.Contains([]string{"/", ".", "\\", ".."}, filepath.Clean(config.Cfg.Temp.BasePath)) {
|
||||||
common.Log.Error("无效的缓存文件夹: ", config.Cfg.Temp.BasePath)
|
log.Error(i18n.T(i18nk.InvalidCacheDir, map[string]any{
|
||||||
|
"Path": config.Cfg.Temp.BasePath,
|
||||||
|
}))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
currentDir, err := os.Getwd()
|
currentDir, err := os.Getwd()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
common.Log.Error("获取工作目录失败: ", err)
|
log.Error(i18n.T(i18nk.GetWorkdirFailed, map[string]any{
|
||||||
|
"Error": err,
|
||||||
|
}))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
cachePath := filepath.Join(currentDir, config.Cfg.Temp.BasePath)
|
cachePath := filepath.Join(currentDir, config.Cfg.Temp.BasePath)
|
||||||
cachePath, err = filepath.Abs(cachePath)
|
cachePath, err = filepath.Abs(cachePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
common.Log.Error("获取缓存绝对路径失败: ", err)
|
log.Error(i18n.T(i18nk.GetCacheAbsPathFailed, map[string]any{
|
||||||
|
"Error": err,
|
||||||
|
}))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
common.Log.Info("正在清理缓存文件夹: ", cachePath)
|
log.Info(i18n.T(i18nk.CleaningCache, map[string]any{
|
||||||
if err := os.RemoveAll(cachePath); err != nil {
|
"Path": cachePath,
|
||||||
common.Log.Error("清理缓存失败: ", err)
|
}))
|
||||||
|
if err := fsutil.RemoveAllInDir(cachePath); err != nil {
|
||||||
|
log.Error(i18n.T(i18nk.CleanCacheFailed, map[string]any{
|
||||||
|
"Error": err,
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func InitAll() {
|
|
||||||
if err := config.Init(); err != nil {
|
|
||||||
fmt.Println("加载配置文件失败: ", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
common.InitLogger()
|
|
||||||
common.Log.Info("正在启动 SaveAny-Bot...")
|
|
||||||
dao.Init()
|
|
||||||
storage.LoadStorages()
|
|
||||||
common.Init()
|
|
||||||
bot.Init()
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
|
||||||
"github.com/krau/SaveAny-Bot/common"
|
"github.com/krau/SaveAny-Bot/pkg/consts"
|
||||||
"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", consts.Version, runtime.GOOS, runtime.GOARCH, consts.BuildTime, consts.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(consts.Version)
|
||||||
latest, err := selfupdate.UpdateSelf(v, "krau/SaveAny-Bot")
|
latest, err := selfupdate.UpdateSelf(v, "krau/SaveAny-Bot")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Println("Binary update failed:", err)
|
fmt.Println("Binary update failed:", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if latest.Version.Equals(v) {
|
if latest.Version.Equals(v) {
|
||||||
fmt.Println("Current binary is the latest version", common.Version)
|
fmt.Println("Current binary is the latest version", consts.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,60 +0,0 @@
|
|||||||
package common
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"encoding/gob"
|
|
||||||
"sync"
|
|
||||||
|
|
||||||
"github.com/coocood/freecache"
|
|
||||||
"github.com/gotd/td/tg"
|
|
||||||
"github.com/krau/SaveAny-Bot/types"
|
|
||||||
)
|
|
||||||
|
|
||||||
type CommonCache struct {
|
|
||||||
cache *freecache.Cache
|
|
||||||
mu sync.RWMutex
|
|
||||||
}
|
|
||||||
|
|
||||||
var Cache *CommonCache
|
|
||||||
|
|
||||||
func initCache() {
|
|
||||||
gob.Register(types.File{})
|
|
||||||
gob.Register(tg.InputDocumentFileLocation{})
|
|
||||||
gob.Register(tg.InputPhotoFileLocation{})
|
|
||||||
Cache = &CommonCache{cache: freecache.NewCache(10 * 1024 * 1024)}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *CommonCache) Get(key string, value any) error {
|
|
||||||
c.mu.RLock()
|
|
||||||
defer c.mu.RUnlock()
|
|
||||||
data, err := Cache.cache.Get([]byte(key))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
dec := gob.NewDecoder(bytes.NewReader(data))
|
|
||||||
err = dec.Decode(&value)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *CommonCache) Set(key string, value any, expireSeconds int) error {
|
|
||||||
c.mu.Lock()
|
|
||||||
defer c.mu.Unlock()
|
|
||||||
var buf bytes.Buffer
|
|
||||||
enc := gob.NewEncoder(&buf)
|
|
||||||
err := enc.Encode(value)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
Cache.cache.Set([]byte(key), buf.Bytes(), expireSeconds)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *CommonCache) Delete(key string) error {
|
|
||||||
c.mu.Lock()
|
|
||||||
defer c.mu.Unlock()
|
|
||||||
Cache.cache.Del([]byte(key))
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
53
common/cache/ristretto.go
vendored
Normal file
53
common/cache/ristretto.go
vendored
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
package cache
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/log"
|
||||||
|
"github.com/dgraph-io/ristretto/v2"
|
||||||
|
"github.com/krau/SaveAny-Bot/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
var cache *ristretto.Cache[string, any]
|
||||||
|
|
||||||
|
func Init() {
|
||||||
|
if cache != nil {
|
||||||
|
panic("cache already initialized")
|
||||||
|
}
|
||||||
|
c, err := ristretto.NewCache(&ristretto.Config[string, any]{
|
||||||
|
NumCounters: config.Cfg.Cache.NumCounters,
|
||||||
|
MaxCost: config.Cfg.Cache.MaxCost,
|
||||||
|
BufferItems: 64,
|
||||||
|
OnReject: func(item *ristretto.Item[any]) {
|
||||||
|
log.Warnf("Cache item rejected: key=%d, value=%v", item.Key, item.Value)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("failed to create ristretto cache: %v", err)
|
||||||
|
}
|
||||||
|
cache = c
|
||||||
|
}
|
||||||
|
|
||||||
|
func Set(key string, value any) error {
|
||||||
|
ok := cache.SetWithTTL(key, value, 0, time.Duration(config.Cfg.Cache.TTL)*time.Second)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("failed to set value in cache")
|
||||||
|
}
|
||||||
|
cache.Wait()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func Get[T any](key string) (T, bool) {
|
||||||
|
v, ok := cache.Get(key)
|
||||||
|
if !ok {
|
||||||
|
var zero T
|
||||||
|
return zero, false
|
||||||
|
}
|
||||||
|
vT, ok := v.(T)
|
||||||
|
if !ok {
|
||||||
|
var zero T
|
||||||
|
return zero, false
|
||||||
|
}
|
||||||
|
return vT, true
|
||||||
|
}
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
package common
|
|
||||||
|
|
||||||
func Init() {
|
|
||||||
initCache()
|
|
||||||
}
|
|
||||||
108
common/i18n/i18n.go
Normal file
108
common/i18n/i18n.go
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
package i18n
|
||||||
|
|
||||||
|
import (
|
||||||
|
"embed"
|
||||||
|
|
||||||
|
"maps"
|
||||||
|
|
||||||
|
"github.com/nicksnyder/go-i18n/v2/i18n"
|
||||||
|
"github.com/pelletier/go-toml/v2"
|
||||||
|
"golang.org/x/text/language"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed locale/*.toml
|
||||||
|
var localesFS embed.FS
|
||||||
|
|
||||||
|
var (
|
||||||
|
bundle *i18n.Bundle
|
||||||
|
localizer *i18n.Localizer
|
||||||
|
)
|
||||||
|
|
||||||
|
func Init(lang string) {
|
||||||
|
bundle = i18n.NewBundle(language.SimplifiedChinese)
|
||||||
|
bundle.RegisterUnmarshalFunc("toml", toml.Unmarshal)
|
||||||
|
files, err := localesFS.ReadDir("locale")
|
||||||
|
if err != nil {
|
||||||
|
panic("failed to read locale directory: " + err.Error())
|
||||||
|
}
|
||||||
|
for _, file := range files {
|
||||||
|
if _, err := bundle.LoadMessageFileFS(localesFS, "locale/"+file.Name()); err != nil {
|
||||||
|
panic("failed to load message file: " + err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if lang == "" {
|
||||||
|
lang = "zh-Hans"
|
||||||
|
}
|
||||||
|
localizer = i18n.NewLocalizer(bundle, lang)
|
||||||
|
if localizer == nil {
|
||||||
|
panic("failed to create localizer, check your config for valid language setting")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func T(key string, templateData ...map[string]any) string {
|
||||||
|
if localizer == nil || bundle == nil {
|
||||||
|
panic("localizer or bundle is not initialized, call Init() first")
|
||||||
|
}
|
||||||
|
templateDataMap := make(map[string]any)
|
||||||
|
for _, data := range templateData {
|
||||||
|
maps.Copy(templateDataMap, data)
|
||||||
|
}
|
||||||
|
msg, err := localizer.Localize(&i18n.LocalizeConfig{
|
||||||
|
MessageID: key,
|
||||||
|
TemplateData: templateDataMap,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
return msg
|
||||||
|
}
|
||||||
|
|
||||||
|
func TWithLang(lang, key string, templateData ...map[string]any) string {
|
||||||
|
if bundle == nil {
|
||||||
|
panic("bundle is not initialized, call Init() first")
|
||||||
|
}
|
||||||
|
templateDataMap := make(map[string]any)
|
||||||
|
for _, data := range templateData {
|
||||||
|
maps.Copy(templateDataMap, data)
|
||||||
|
}
|
||||||
|
localizerWithLang := i18n.NewLocalizer(bundle, lang)
|
||||||
|
msg, err := localizerWithLang.Localize(&i18n.LocalizeConfig{
|
||||||
|
MessageID: key,
|
||||||
|
TemplateData: templateDataMap,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
return msg
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only use in tests or packages that load before i18n
|
||||||
|
func TWithoutInit(lang, key string, templateData ...map[string]any) string {
|
||||||
|
bundle := i18n.NewBundle(language.SimplifiedChinese)
|
||||||
|
bundle.RegisterUnmarshalFunc("toml", toml.Unmarshal)
|
||||||
|
files, err := localesFS.ReadDir("locale")
|
||||||
|
if err != nil {
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
for _, file := range files {
|
||||||
|
if _, err := bundle.LoadMessageFileFS(localesFS, "locale/"+file.Name()); err != nil {
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
}
|
||||||
|
localizer := i18n.NewLocalizer(bundle, lang)
|
||||||
|
if localizer == nil {
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
templateDataMap := make(map[string]any)
|
||||||
|
for _, data := range templateData {
|
||||||
|
maps.Copy(templateDataMap, data)
|
||||||
|
}
|
||||||
|
msg, err := localizer.Localize(&i18n.LocalizeConfig{
|
||||||
|
MessageID: key,
|
||||||
|
TemplateData: templateDataMap,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
return msg
|
||||||
|
}
|
||||||
19
common/i18n/i18nk/keys.go
Normal file
19
common/i18n/i18nk/keys.go
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
// Code generated by cmd/gen_i18n. DO NOT EDIT.
|
||||||
|
package i18nk
|
||||||
|
|
||||||
|
const (
|
||||||
|
CleanCacheFailed = "CleanCacheFailed"
|
||||||
|
CleaningCache = "CleaningCache"
|
||||||
|
ConfigInvalidDuplicateStorageName = "ConfigInvalid.DuplicateStorageName"
|
||||||
|
ConfigInvalidWorkersOrRetry = "ConfigInvalid.WorkersOrRetry"
|
||||||
|
CreateRmTimerFailed = "CreateRmTimerFailed"
|
||||||
|
GetCacheAbsPathFailed = "GetCacheAbsPathFailed"
|
||||||
|
GetWorkdirFailed = "GetWorkdirFailed"
|
||||||
|
InvalidCacheDir = "InvalidCacheDir"
|
||||||
|
LoadedStorages = "LoadedStorages"
|
||||||
|
RemoveFileAfter = "RemoveFileAfter"
|
||||||
|
RemoveFileFailed = "RemoveFileFailed"
|
||||||
|
Bye = "bye"
|
||||||
|
Exiting = "exiting"
|
||||||
|
Initing = "initing"
|
||||||
|
)
|
||||||
28
common/i18n/locale/zh-Hans.toml
Normal file
28
common/i18n/locale/zh-Hans.toml
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
[initing]
|
||||||
|
other = "正在启动..."
|
||||||
|
[exiting]
|
||||||
|
other = "正在退出..."
|
||||||
|
[bye]
|
||||||
|
other = "已退出"
|
||||||
|
[InvalidCacheDir]
|
||||||
|
other = "无效的缓存文件夹: {{.Path}}"
|
||||||
|
[GetWorkdirFailed]
|
||||||
|
other = "获取工作目录失败: {{.Error}}"
|
||||||
|
[GetCacheAbsPathFailed]
|
||||||
|
other = "获取缓存绝对路径失败: {{.Error}}"
|
||||||
|
[CleaningCache]
|
||||||
|
other = "正在清理缓存文件夹: {{.Path}}"
|
||||||
|
[CleanCacheFailed]
|
||||||
|
other = "清理缓存失败: {{.Error}}"
|
||||||
|
[CreateRmTimerFailed]
|
||||||
|
other = "创建清理定时器失败, 路径: {{.Path}}, 错误: {{.Error}}"
|
||||||
|
[RemoveFileAfter]
|
||||||
|
other = "将在 {{.Duration}} 后删除文件: {{.Path}}"
|
||||||
|
[RemoveFileFailed]
|
||||||
|
other = "删除文件失败: {{.Path}}, 错误: {{.Error}}"
|
||||||
|
[LoadedStorages]
|
||||||
|
other = "已加载 {{.Count}} 个存储"
|
||||||
|
[ConfigInvalid.WorkersOrRetry]
|
||||||
|
other = "配置无效: workers 或 retry 必须大于 0, 但当前值为: workers={{.Workers}}, retry={{.Retry}}"
|
||||||
|
[ConfigInvalid.DuplicateStorageName]
|
||||||
|
other = "存储名称重复: {{.Name}}"
|
||||||
@@ -1,39 +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
|
|
||||||
}
|
|
||||||
slog.DefaultChannelName = "SaveAnyBot"
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
consoleH := handler.NewConsoleHandler(logLevels)
|
|
||||||
fileH, err := handler.NewTimeRotateFile(
|
|
||||||
logFilePath,
|
|
||||||
rotatefile.EveryDay,
|
|
||||||
handler.WithLogLevels(slog.AllLevels),
|
|
||||||
handler.WithBackupNum(logBackupNum),
|
|
||||||
handler.WithBuffSize(0),
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
Log.AddHandlers(consoleH, fileH)
|
|
||||||
}
|
|
||||||
55
common/os.go
55
common/os.go
@@ -1,55 +0,0 @@
|
|||||||
package common
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// 创建文件, 自动创建目录
|
|
||||||
func MkFile(path string, data []byte) error {
|
|
||||||
err := os.MkdirAll(filepath.Dir(path), os.ModePerm)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return os.WriteFile(path, data, os.ModePerm)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 删除文件, 并清理空目录. 如果文件不存在则返回 nil
|
|
||||||
func PurgeFile(path string) error {
|
|
||||||
if err := os.Remove(path); err != nil {
|
|
||||||
if !errors.Is(err, os.ErrNotExist) {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return RemoveEmptyDirectories(filepath.Dir(path))
|
|
||||||
}
|
|
||||||
|
|
||||||
func RmFileAfter(path string, td time.Duration) {
|
|
||||||
_, err := os.Stat(path)
|
|
||||||
if err != nil {
|
|
||||||
Log.Errorf("Failed to create timer for %s: %s", path, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
Log.Debugf("Remove file after %s: %s", td, path)
|
|
||||||
time.AfterFunc(td, func() {
|
|
||||||
PurgeFile(path)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// 递归删除空目录
|
|
||||||
func RemoveEmptyDirectories(dirPath string) error {
|
|
||||||
entries, err := os.ReadDir(dirPath)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if len(entries) == 0 {
|
|
||||||
err := os.Remove(dirPath)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return RemoveEmptyDirectories(filepath.Dir(dirPath))
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,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
|
||||||
|
}
|
||||||
57
common/utils/fsutil/fs.go
Normal file
57
common/utils/fsutil/fs.go
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
package fsutil
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/gabriel-vasile/mimetype"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 删除文件夹内的所有文件和子目录, 但不删除文件夹本身
|
||||||
|
func RemoveAllInDir(dirPath string) error {
|
||||||
|
entries, err := os.ReadDir(dirPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, entry := range entries {
|
||||||
|
entryPath := filepath.Join(dirPath, entry.Name())
|
||||||
|
if err := os.RemoveAll(entryPath); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func DetectFileExt(fp string) string {
|
||||||
|
mt, err := mimetype.DetectFile(fp)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return mt.Extension()
|
||||||
|
}
|
||||||
|
|
||||||
|
type File struct {
|
||||||
|
*os.File
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *File) Remove() error {
|
||||||
|
return os.Remove(f.Name())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *File) CloseAndRemove() error {
|
||||||
|
if err := f.Close(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return f.Remove()
|
||||||
|
}
|
||||||
|
|
||||||
|
func CreateFile(fp string) (*File, error) {
|
||||||
|
if err := os.MkdirAll(filepath.Dir(fp), os.ModePerm); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
file, err := os.Create(fp)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &File{File: file}, nil
|
||||||
|
}
|
||||||
49
common/utils/ioutil/writer.go
Normal file
49
common/utils/ioutil/writer.go
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
package ioutil
|
||||||
|
|
||||||
|
import "io"
|
||||||
|
|
||||||
|
type ProgressWriterAt struct {
|
||||||
|
wrAt io.WriterAt
|
||||||
|
onWrite func(n int)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *ProgressWriterAt) WriteAt(buf []byte, off int64) (n int, err error) {
|
||||||
|
n, err = p.wrAt.WriteAt(buf, off)
|
||||||
|
if n > 0 {
|
||||||
|
p.onWrite(n)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewProgressWriterAt(
|
||||||
|
wrAt io.WriterAt,
|
||||||
|
onWrite func(n int),
|
||||||
|
) *ProgressWriterAt {
|
||||||
|
return &ProgressWriterAt{
|
||||||
|
wrAt: wrAt,
|
||||||
|
onWrite: onWrite,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProgressWriter struct {
|
||||||
|
wr io.Writer
|
||||||
|
onWrite func(n int)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *ProgressWriter) Write(buf []byte) (n int, err error) {
|
||||||
|
n, err = p.wr.Write(buf)
|
||||||
|
if n > 0 {
|
||||||
|
p.onWrite(n)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewProgressWriter(
|
||||||
|
wr io.Writer,
|
||||||
|
onWrite func(n int),
|
||||||
|
) *ProgressWriter {
|
||||||
|
return &ProgressWriter{
|
||||||
|
wr: wr,
|
||||||
|
onWrite: onWrite,
|
||||||
|
}
|
||||||
|
}
|
||||||
15
common/utils/netutil/proxy.go
Normal file
15
common/utils/netutil/proxy.go
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
package netutil
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/url"
|
||||||
|
|
||||||
|
"golang.org/x/net/proxy"
|
||||||
|
)
|
||||||
|
|
||||||
|
func NewProxyDialer(proxyUrl string) (proxy.Dialer, error) {
|
||||||
|
url, err := url.Parse(proxyUrl)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return proxy.FromURL(url, proxy.Direct)
|
||||||
|
}
|
||||||
50
common/utils/strutil/string.go
Normal file
50
common/utils/strutil/string.go
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
package strutil
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/md5"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/duke-git/lancet/v2/slice"
|
||||||
|
)
|
||||||
|
|
||||||
|
func HashString(s string) string {
|
||||||
|
hash := md5.New()
|
||||||
|
hash.Write([]byte(s))
|
||||||
|
return hex.EncodeToString(hash.Sum(nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
var TagRe = regexp.MustCompile(`(?:^|[\p{Zs}\s.,!?(){}[\]<>\"\',。!?():;、])#([\p{L}\d_]+)`)
|
||||||
|
|
||||||
|
func ExtractTagsFromText(text string) []string {
|
||||||
|
matches := TagRe.FindAllStringSubmatch(text, -1)
|
||||||
|
tags := make([]string, 0)
|
||||||
|
for _, match := range matches {
|
||||||
|
if len(match) > 1 {
|
||||||
|
tags = append(tags, match[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return slice.Compact(tags)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ParseIntStrRange(input string, sep string) (int64, int64, error) {
|
||||||
|
parts := strings.Split(input, sep)
|
||||||
|
if len(parts) != 2 {
|
||||||
|
return 0, 0, fmt.Errorf("invalid range format: %s", input)
|
||||||
|
}
|
||||||
|
min, err := strconv.ParseInt(strings.TrimSpace(parts[0]), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, fmt.Errorf("invalid minimum value: %s", parts[0])
|
||||||
|
}
|
||||||
|
max, err := strconv.ParseInt(strings.TrimSpace(parts[1]), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, fmt.Errorf("invalid maximum value: %s", parts[1])
|
||||||
|
}
|
||||||
|
if min > max {
|
||||||
|
min, max = max, min
|
||||||
|
}
|
||||||
|
return min, max, nil
|
||||||
|
}
|
||||||
22
common/utils/tgutil/context.go
Normal file
22
common/utils/tgutil/context.go
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
package tgutil
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/celestix/gotgproto/ext"
|
||||||
|
)
|
||||||
|
|
||||||
|
type contextKey struct{}
|
||||||
|
|
||||||
|
var extKey = contextKey{}
|
||||||
|
|
||||||
|
func ExtFromContext(ctx context.Context) *ext.Context {
|
||||||
|
if extCtx, ok := ctx.Value(extKey).(*ext.Context); ok {
|
||||||
|
return extCtx
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ExtWithContext(ctx context.Context, extCtx *ext.Context) context.Context {
|
||||||
|
return context.WithValue(ctx, extKey, extCtx)
|
||||||
|
}
|
||||||
312
common/utils/tgutil/message.go
Normal file
312
common/utils/tgutil/message.go
Normal file
@@ -0,0 +1,312 @@
|
|||||||
|
package tgutil
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/celestix/gotgproto/ext"
|
||||||
|
"github.com/celestix/gotgproto/functions"
|
||||||
|
"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 {
|
||||||
|
if r < 0x20 || r == 0x7F {
|
||||||
|
return '_'
|
||||||
|
}
|
||||||
|
switch r {
|
||||||
|
// invalid characters
|
||||||
|
case '/', '\\',
|
||||||
|
':', '*', '?', '"', '<', '>', '|':
|
||||||
|
return '_'
|
||||||
|
// empty
|
||||||
|
case ' ', '\t', '\r', '\n':
|
||||||
|
return '_'
|
||||||
|
}
|
||||||
|
if validator.IsPrintable(string(r)) {
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
return '_'
|
||||||
|
}, text), 0, 64)
|
||||||
|
text = strings.Join(strings.FieldsFunc(text, func(r rune) bool {
|
||||||
|
return r == '_' || r == ' '
|
||||||
|
}), "_")
|
||||||
|
return text
|
||||||
|
}()
|
||||||
|
|
||||||
|
if filename == "" {
|
||||||
|
mname, err := functions.GetMediaFileNameWithId(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, fmt.Errorf("failed to get grouped messages: %w", err)
|
||||||
|
}
|
||||||
|
groupedMessages := make([]*tg.Message, 0, len(msgs))
|
||||||
|
for _, m := range msgs {
|
||||||
|
if m == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
mgid, isGroup := m.GetGroupedID()
|
||||||
|
if isGroup && mgid == groupID {
|
||||||
|
groupedMessages = append(groupedMessages, m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return groupedMessages, nil
|
||||||
|
}
|
||||||
119
common/utils/tgutil/resolve.go
Normal file
119
common/utils/tgutil/resolve.go
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
package tgutil
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/celestix/gotgproto/ext"
|
||||||
|
"github.com/duke-git/lancet/v2/validator"
|
||||||
|
"github.com/gotd/td/tg"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ParseChatID(ctx *ext.Context, idOrUsername string) (int64, error) {
|
||||||
|
idOrUsername = strings.TrimPrefix(idOrUsername, "@")
|
||||||
|
if validator.IsIntStr(idOrUsername) {
|
||||||
|
chatID, err := strconv.Atoi(idOrUsername)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return int64(chatID), nil
|
||||||
|
}
|
||||||
|
username := idOrUsername
|
||||||
|
peer := ctx.PeerStorage.GetPeerByUsername(username)
|
||||||
|
if peer != nil && peer.ID != 0 {
|
||||||
|
return peer.ID, nil
|
||||||
|
}
|
||||||
|
chat, err := ctx.ResolveUsername(username)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if chat == nil {
|
||||||
|
return 0, fmt.Errorf("no chat found for username: %s", idOrUsername)
|
||||||
|
}
|
||||||
|
chatID := chat.GetID()
|
||||||
|
if chatID == 0 {
|
||||||
|
return 0, fmt.Errorf("chat ID is zero for username: %s", idOrUsername)
|
||||||
|
}
|
||||||
|
return chatID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// return: ChatID, MessageID, error
|
||||||
|
func ParseMessageLink(ctx *ext.Context, link string) (int64, int, error) {
|
||||||
|
u, err := url.Parse(link)
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, fmt.Errorf("invalid URL: %w", err)
|
||||||
|
}
|
||||||
|
paths := strings.Split(strings.TrimPrefix(u.Path, "/"), "/")
|
||||||
|
|
||||||
|
if cmt := u.Query().Get("comment"); cmt != "" {
|
||||||
|
// 频道评论的消息链接
|
||||||
|
// https://t.me/acherkrau/123?comment=2
|
||||||
|
chid, err := ParseChatID(ctx, paths[0])
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, fmt.Errorf("failed to parse chat ID: %w", err)
|
||||||
|
}
|
||||||
|
chatfull, err := ctx.GetChat(chid)
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, fmt.Errorf("failed to get chat: %w", err)
|
||||||
|
}
|
||||||
|
chfull, ok := chatfull.(*tg.ChannelFull)
|
||||||
|
if !ok {
|
||||||
|
return 0, 0, fmt.Errorf("chat is not a channel: %s", chatfull.TypeName())
|
||||||
|
}
|
||||||
|
linkChatId, ok := chfull.GetLinkedChatID()
|
||||||
|
if !ok {
|
||||||
|
return 0, 0, fmt.Errorf("channel has no linked chat")
|
||||||
|
}
|
||||||
|
msgID, err := strconv.Atoi(cmt)
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, fmt.Errorf("failed to parse comment ID: %w", err)
|
||||||
|
}
|
||||||
|
return linkChatId, msgID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
switch len(paths) {
|
||||||
|
case 2: // https://t.me/acherkrau/123
|
||||||
|
chatID, err := ParseChatID(ctx, paths[0])
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, fmt.Errorf("failed to parse chat ID: %w", err)
|
||||||
|
}
|
||||||
|
msgID, err := strconv.Atoi(paths[1])
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, fmt.Errorf("failed to parse message ID: %w", err)
|
||||||
|
}
|
||||||
|
return chatID, msgID, nil
|
||||||
|
case 3:
|
||||||
|
// https://t.me/c/123456789/123
|
||||||
|
// https://t.me/acherkrau/123/456 , 456: message thread ID
|
||||||
|
chatPart, msgPart := paths[1], paths[2]
|
||||||
|
if paths[0] != "c" {
|
||||||
|
chatPart = paths[0]
|
||||||
|
}
|
||||||
|
chatID, err := ParseChatID(ctx, chatPart)
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, fmt.Errorf("failed to parse chat ID: %w", err)
|
||||||
|
}
|
||||||
|
msgID, err := strconv.Atoi(msgPart)
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, fmt.Errorf("failed to parse message ID: %w", err)
|
||||||
|
}
|
||||||
|
return chatID, msgID, nil
|
||||||
|
case 4:
|
||||||
|
// https://t.me/c/123456789/111/456 111: topic id
|
||||||
|
if paths[0] != "c" {
|
||||||
|
return 0, 0, fmt.Errorf("invalid message link format: %s", link)
|
||||||
|
}
|
||||||
|
chatID, err := ParseChatID(ctx, paths[1])
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, fmt.Errorf("failed to parse chat ID: %w", err)
|
||||||
|
}
|
||||||
|
msgID, err := strconv.Atoi(paths[3])
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, fmt.Errorf("failed to parse message ID: %w", err)
|
||||||
|
}
|
||||||
|
return chatID, msgID, nil
|
||||||
|
}
|
||||||
|
return 0, 0, fmt.Errorf("invalid message link format: %s", link)
|
||||||
|
}
|
||||||
51
common/utils/tphutil/tph.go
Normal file
51
common/utils/tphutil/tph.go
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
package tphutil
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
|
||||||
|
"github.com/krau/SaveAny-Bot/config"
|
||||||
|
"github.com/krau/SaveAny-Bot/pkg/telegraph"
|
||||||
|
)
|
||||||
|
|
||||||
|
var tphClient *telegraph.Client
|
||||||
|
|
||||||
|
func DefaultClient() *telegraph.Client {
|
||||||
|
if tphClient != nil {
|
||||||
|
return tphClient
|
||||||
|
}
|
||||||
|
if config.Cfg.Telegram.Proxy.Enable && config.Cfg.Telegram.Proxy.URL != "" {
|
||||||
|
proxyUrl := config.Cfg.Telegram.Proxy.URL
|
||||||
|
var err error
|
||||||
|
tphClient, err = telegraph.NewClientWithProxy(proxyUrl)
|
||||||
|
if err != nil {
|
||||||
|
tphClient = telegraph.NewClient()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
tphClient = telegraph.NewClient()
|
||||||
|
}
|
||||||
|
return tphClient
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetNodeImages(node telegraph.Node) []string {
|
||||||
|
var srcs []string
|
||||||
|
|
||||||
|
var nodeElement telegraph.NodeElement
|
||||||
|
data, err := json.Marshal(node)
|
||||||
|
if err != nil {
|
||||||
|
return srcs
|
||||||
|
}
|
||||||
|
err = json.Unmarshal(data, &nodeElement)
|
||||||
|
if err != nil {
|
||||||
|
return srcs
|
||||||
|
}
|
||||||
|
|
||||||
|
if nodeElement.Tag == "img" {
|
||||||
|
if src, exists := nodeElement.Attrs["src"]; exists {
|
||||||
|
srcs = append(srcs, src)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, child := range nodeElement.Children {
|
||||||
|
srcs = append(srcs, GetNodeImages(child)...)
|
||||||
|
}
|
||||||
|
return srcs
|
||||||
|
}
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
package common
|
|
||||||
|
|
||||||
var (
|
|
||||||
Version string = "dev"
|
|
||||||
BuildTime string = "unknown"
|
|
||||||
GitCommit string = "unknown"
|
|
||||||
)
|
|
||||||
@@ -1,55 +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 = 123456
|
# app_id = 1025907
|
||||||
# app_hash = "0123456789abcdef0123456789abcdef"
|
# app_hash = "452b0359b988148995f22ff0f4229750"
|
||||||
|
|
||||||
[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"
|
||||||
|
|
||||||
|
# 存储列表
|
||||||
# 存储配置列表
|
|
||||||
[[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 = "本机2"
|
|
||||||
type = "local"
|
|
||||||
enable = true
|
|
||||||
base_path = "./downloads/2"
|
|
||||||
|
|
||||||
[[storages]]
|
|
||||||
name = "MyAlist"
|
|
||||||
type = "alist"
|
|
||||||
enable = false #记得启用
|
|
||||||
base_path = '/'
|
|
||||||
url = 'https://alist.com'
|
|
||||||
username = 'admin'
|
|
||||||
password = 'password'
|
|
||||||
token_exp = 86400 # 86400--1天 604800--7天 1296000--15天 2592000--30天 15552000--180天
|
|
||||||
# alist 可直接使用 token 登录, 此时 username, password, token_exp 将被忽略
|
|
||||||
# 请自行在 alist 侧配置合理的 token 过期时间
|
|
||||||
# token = ""
|
|
||||||
|
|
||||||
|
|
||||||
[[storages]]
|
[[storages]]
|
||||||
name = "MyWebdav"
|
name = "MyWebdav"
|
||||||
type = "webdav"
|
type = "webdav"
|
||||||
@@ -59,46 +38,17 @@ url = 'https://example.com/dav'
|
|||||||
username = 'username'
|
username = 'username'
|
||||||
password = 'password'
|
password = 'password'
|
||||||
|
|
||||||
[[storages]]
|
|
||||||
name = "MyMinio"
|
|
||||||
type = "minio"
|
|
||||||
enable = true
|
|
||||||
endpoint = 'play.min.io'
|
|
||||||
use_ssl = true
|
|
||||||
access_key_id = 'Q3AM3UQ867SPQQA43P2F'
|
|
||||||
secret_access_key = 'zuf+tfteSlswRu7BJ86wekitnifILbZam1KYY3TG'
|
|
||||||
bucket_name = 'saveanybot'
|
|
||||||
base_path = '/path/telegram'
|
|
||||||
|
|
||||||
# 用户列表
|
# 用户列表
|
||||||
[[users]]
|
[[users]]
|
||||||
# telegram user id
|
# telegram user id
|
||||||
id = 114514
|
id = 114514
|
||||||
# 开启黑名单,开启后下方留空以使用所有存储,反之则为白名单,白名单请在下方输入允许的存储名
|
# 存储过滤列表, 元素为存储标识名.
|
||||||
blacklist = true
|
# 将该列表留空并开启黑名单过滤模式以允许使用所有存储,此处示例为黑名单模式,用户 114514 可使用所有存储
|
||||||
# 将列表留空并开启黑名单模式以允许使用所有存储,此处示例为黑名单模式,用户114514 可使用所有存储
|
|
||||||
storages = []
|
storages = []
|
||||||
|
# 使用列表过滤黑名单模式,反之则为白名单,白名单请在列表中指定可用的存储.
|
||||||
|
blacklist = true
|
||||||
|
|
||||||
[[users]]
|
[[users]]
|
||||||
id = 123456
|
id = 123456
|
||||||
blacklist = false #开启白名单模式,此时,用户123456 仅可使用下方列表中的存储
|
|
||||||
# 此时该用户只能使用名为 本机1 的存储
|
|
||||||
storages = ["本机1"]
|
storages = ["本机1"]
|
||||||
|
blacklist = false # 使用白名单模式,此时,用户 123456 仅可使用标识名为 '本地1' 的存储
|
||||||
|
|
||||||
|
|
||||||
# 其他配置
|
|
||||||
|
|
||||||
# [log]
|
|
||||||
# # 日志等级
|
|
||||||
# level = "DEBUG"
|
|
||||||
|
|
||||||
# [temp]
|
|
||||||
# # 下载文件临时目录, 请不要在此目录下存放任何其他文件
|
|
||||||
# base_path = "cache/"
|
|
||||||
# # 临时文件保存时间, 单位: 秒
|
|
||||||
# cache_ttl = 30
|
|
||||||
|
|
||||||
# [db]
|
|
||||||
# path = "data/data.db" # 数据库文件路径
|
|
||||||
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"`
|
||||||
|
// }
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 LocalStorageConfig struct {
|
type LocalStorageConfig struct {
|
||||||
@@ -18,8 +18,8 @@ func (l *LocalStorageConfig) Validate() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l *LocalStorageConfig) GetType() types.StorageType {
|
func (l *LocalStorageConfig) GetType() storenum.StorageType {
|
||||||
return types.StorageTypeLocal
|
return storenum.Local
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l *LocalStorageConfig) GetName() string {
|
func (l *LocalStorageConfig) GetName() string {
|
||||||
|
|||||||
@@ -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 MinioStorageConfig struct {
|
type MinioStorageConfig struct {
|
||||||
@@ -32,8 +32,8 @@ func (m *MinioStorageConfig) Validate() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MinioStorageConfig) GetType() types.StorageType {
|
func (m *MinioStorageConfig) GetType() storenum.StorageType {
|
||||||
return types.StorageTypeMinio
|
return storenum.Minio
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MinioStorageConfig) GetName() string {
|
func (m *MinioStorageConfig) GetName() string {
|
||||||
|
|||||||
32
config/storage/telegram.go
Normal file
32
config/storage/telegram.go
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
storenum "github.com/krau/SaveAny-Bot/pkg/enums/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
type TelegramStorageConfig struct {
|
||||||
|
BaseConfig
|
||||||
|
ChatID int64 `toml:"chat_id" mapstructure:"chat_id" json:"chat_id"`
|
||||||
|
RateLimit int `toml:"rate_limit" mapstructure:"rate_limit" json:"rate_limit"`
|
||||||
|
RateBurst int `toml:"rate_burst" mapstructure:"rate_burst" json:"rate_burst"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *TelegramStorageConfig) Validate() error {
|
||||||
|
if m.ChatID == 0 {
|
||||||
|
return fmt.Errorf("chat_id is required for telegram storage")
|
||||||
|
}
|
||||||
|
if m.RateLimit < 0 || m.RateBurst < 0 {
|
||||||
|
return fmt.Errorf("rate_limit and rate_burst must be greater than 0 for telegram storage")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *TelegramStorageConfig) GetType() storenum.StorageType {
|
||||||
|
return storenum.Telegram
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *TelegramStorageConfig) GetName() string {
|
||||||
|
return m.Name
|
||||||
|
}
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
package storage
|
package storage
|
||||||
|
|
||||||
import "github.com/krau/SaveAny-Bot/types"
|
import (
|
||||||
|
storenum "github.com/krau/SaveAny-Bot/pkg/enums/storage"
|
||||||
|
)
|
||||||
|
|
||||||
type StorageConfig interface {
|
type StorageConfig interface {
|
||||||
Validate() error
|
Validate() error
|
||||||
GetType() types.StorageType
|
GetType() storenum.StorageType
|
||||||
GetName() string
|
GetName() string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 WebdavStorageConfig struct {
|
type WebdavStorageConfig struct {
|
||||||
@@ -27,8 +27,8 @@ func (w *WebdavStorageConfig) Validate() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (w *WebdavStorageConfig) GetType() types.StorageType {
|
func (w *WebdavStorageConfig) GetType() storenum.StorageType {
|
||||||
return types.StorageTypeWebdav
|
return storenum.Webdav
|
||||||
}
|
}
|
||||||
|
|
||||||
func (w *WebdavStorageConfig) GetName() string {
|
func (w *WebdavStorageConfig) GetName() string {
|
||||||
|
|||||||
5
config/temp.go
Normal file
5
config/temp.go
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
type tempConfig struct {
|
||||||
|
BasePath string `toml:"base_path" mapstructure:"base_path" json:"base_path"`
|
||||||
|
}
|
||||||
20
config/tg.go
Normal file
20
config/tg.go
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
type telegramConfig struct {
|
||||||
|
Token string `toml:"token" mapstructure:"token"`
|
||||||
|
AppID int `toml:"app_id" mapstructure:"app_id" json:"app_id"`
|
||||||
|
AppHash string `toml:"app_hash" mapstructure:"app_hash" json:"app_hash"`
|
||||||
|
Proxy tgProxyConfig `toml:"proxy" mapstructure:"proxy"`
|
||||||
|
RpcRetry int `toml:"rpc_retry" mapstructure:"rpc_retry" json:"rpc_retry"`
|
||||||
|
Userbot userbotConfig `toml:"userbot" mapstructure:"userbot" json:"userbot"` // [TODO]
|
||||||
|
}
|
||||||
|
|
||||||
|
type userbotConfig struct {
|
||||||
|
Enable bool `toml:"enable" mapstructure:"enable"`
|
||||||
|
Session string `toml:"session" mapstructure:"session"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type tgProxyConfig struct {
|
||||||
|
Enable bool `toml:"enable" mapstructure:"enable"`
|
||||||
|
URL string `toml:"url" mapstructure:"url"`
|
||||||
|
}
|
||||||
@@ -10,40 +10,26 @@ type userConfig struct {
|
|||||||
Blacklist bool `toml:"blacklist" mapstructure:"blacklist" json:"blacklist"` // 黑名单模式, storage names 中的存储将不会被使用, 默认为白名单模式
|
Blacklist bool `toml:"blacklist" mapstructure:"blacklist" json:"blacklist"` // 黑名单模式, storage names 中的存储将不会被使用, 默认为白名单模式
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var userIDs []int64
|
||||||
|
var storages []string
|
||||||
|
var userStorages = make(map[int64][]string)
|
||||||
|
|
||||||
func (c *Config) GetStorageNamesByUserID(userID int64) []string {
|
func (c *Config) GetStorageNamesByUserID(userID int64) []string {
|
||||||
for _, user := range c.Users {
|
us, ok := userStorages[userID]
|
||||||
if user.ID == userID {
|
if ok {
|
||||||
if user.Blacklist {
|
return us
|
||||||
allStorages := make([]string, 0, len(c.Storages))
|
|
||||||
for _, storage := range c.Storages {
|
|
||||||
allStorages = append(allStorages, storage.GetName())
|
|
||||||
}
|
|
||||||
return slice.Compact(slice.Difference(allStorages, user.Storages))
|
|
||||||
} else {
|
|
||||||
return user.Storages
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Config) GetUsersID() []int64 {
|
func (c *Config) GetUsersID() []int64 {
|
||||||
var ids []int64
|
return userIDs
|
||||||
for _, user := range c.Users {
|
|
||||||
ids = append(ids, user.ID)
|
|
||||||
}
|
|
||||||
return ids
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Config) HasStorage(userID int64, storageName string) bool {
|
func (c *Config) HasStorage(userID int64, storageName string) bool {
|
||||||
for _, user := range c.Users {
|
us, ok := userStorages[userID]
|
||||||
if user.ID == userID {
|
if !ok {
|
||||||
if user.Blacklist {
|
return false
|
||||||
return !slice.Contain(user.Storages, storageName)
|
|
||||||
} else {
|
|
||||||
return slice.Contain(user.Storages, storageName)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return false
|
return slice.Contain(us, storageName)
|
||||||
}
|
}
|
||||||
|
|||||||
119
config/viper.go
119
config/viper.go
@@ -1,61 +1,37 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/duke-git/lancet/v2/slice"
|
||||||
|
"github.com/krau/SaveAny-Bot/common/i18n"
|
||||||
|
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
|
||||||
"github.com/krau/SaveAny-Bot/config/storage"
|
"github.com/krau/SaveAny-Bot/config/storage"
|
||||||
"github.com/spf13/viper"
|
"github.com/spf13/viper"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Workers int `toml:"workers" mapstructure:"workers"`
|
Lang string `toml:"lang" mapstructure:"lang" json:"lang"`
|
||||||
Retry int `toml:"retry" mapstructure:"retry"`
|
Workers int `toml:"workers" mapstructure:"workers"`
|
||||||
NoCleanCache bool `toml:"no_clean_cache" mapstructure:"no_clean_cache" json:"no_clean_cache"`
|
Retry int `toml:"retry" mapstructure:"retry"`
|
||||||
Threads int `toml:"threads" mapstructure:"threads" json:"threads"`
|
NoCleanCache bool `toml:"no_clean_cache" mapstructure:"no_clean_cache" json:"no_clean_cache"`
|
||||||
Stream bool `toml:"stream" mapstructure:"stream" json:"stream"`
|
Threads int `toml:"threads" mapstructure:"threads" json:"threads"`
|
||||||
|
Stream bool `toml:"stream" mapstructure:"stream" json:"stream"`
|
||||||
Users []userConfig `toml:"users" mapstructure:"users" json:"users"`
|
|
||||||
|
|
||||||
|
Cache cacheConfig `toml:"cache" mapstructure:"cache" json:"cache"`
|
||||||
|
Users []userConfig `toml:"users" mapstructure:"users" json:"users"`
|
||||||
Temp tempConfig `toml:"temp" mapstructure:"temp"`
|
Temp tempConfig `toml:"temp" mapstructure:"temp"`
|
||||||
Log logConfig `toml:"log" mapstructure:"log"`
|
|
||||||
DB dbConfig `toml:"db" mapstructure:"db"`
|
DB dbConfig `toml:"db" mapstructure:"db"`
|
||||||
Telegram telegramConfig `toml:"telegram" mapstructure:"telegram"`
|
Telegram telegramConfig `toml:"telegram" mapstructure:"telegram"`
|
||||||
Storages []storage.StorageConfig `toml:"-" mapstructure:"-" json:"storages"`
|
Storages []storage.StorageConfig `toml:"-" mapstructure:"-" json:"storages"`
|
||||||
|
Hook hookConfig `toml:"hook" mapstructure:"hook" json:"hook"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type tempConfig struct {
|
var Cfg *Config = &Config{}
|
||||||
BasePath string `toml:"base_path" mapstructure:"base_path" json:"base_path"`
|
|
||||||
CacheTTL int64 `toml:"cache_ttl" mapstructure:"cache_ttl" json:"cache_ttl"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type logConfig struct {
|
|
||||||
Level string `toml:"level" mapstructure:"level"`
|
|
||||||
File string `toml:"file" mapstructure:"file"`
|
|
||||||
BackupCount uint `toml:"backup_count" mapstructure:"backup_count" json:"backup_count"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type dbConfig struct {
|
|
||||||
Path string `toml:"path" mapstructure:"path"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type telegramConfig struct {
|
|
||||||
Token string `toml:"token" mapstructure:"token"`
|
|
||||||
AppID int `toml:"app_id" mapstructure:"app_id" json:"app_id"`
|
|
||||||
AppHash string `toml:"app_hash" mapstructure:"app_hash" json:"app_hash"`
|
|
||||||
Proxy proxyConfig `toml:"proxy" mapstructure:"proxy"`
|
|
||||||
|
|
||||||
// Deprecated
|
|
||||||
Admins []int64 `toml:"admins" mapstructure:"admins"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type proxyConfig struct {
|
|
||||||
Enable bool `toml:"enable" mapstructure:"enable"`
|
|
||||||
URL string `toml:"url" mapstructure:"url"`
|
|
||||||
}
|
|
||||||
|
|
||||||
var Cfg *Config
|
|
||||||
|
|
||||||
func (c Config) GetStorageByName(name string) storage.StorageConfig {
|
func (c Config) GetStorageByName(name string) storage.StorageConfig {
|
||||||
for _, storage := range c.Storages {
|
for _, storage := range c.Storages {
|
||||||
@@ -66,7 +42,7 @@ func (c Config) GetStorageByName(name string) storage.StorageConfig {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func Init() error {
|
func Init(ctx context.Context) error {
|
||||||
viper.SetConfigName("config")
|
viper.SetConfigName("config")
|
||||||
viper.AddConfigPath(".")
|
viper.AddConfigPath(".")
|
||||||
viper.AddConfigPath("/etc/saveany/")
|
viper.AddConfigPath("/etc/saveany/")
|
||||||
@@ -76,21 +52,36 @@ func Init() error {
|
|||||||
replacer := strings.NewReplacer(".", "_")
|
replacer := strings.NewReplacer(".", "_")
|
||||||
viper.SetEnvKeyReplacer(replacer)
|
viper.SetEnvKeyReplacer(replacer)
|
||||||
|
|
||||||
viper.SetDefault("workers", 3)
|
defaultConfigs := map[string]any{
|
||||||
viper.SetDefault("retry", 3)
|
// 基础配置
|
||||||
viper.SetDefault("threads", 4)
|
"lang": "zh-Hans",
|
||||||
|
"workers": 3,
|
||||||
|
"retry": 3,
|
||||||
|
"threads": 4,
|
||||||
|
|
||||||
viper.SetDefault("telegram.app_id", 1025907)
|
// 缓存配置
|
||||||
viper.SetDefault("telegram.app_hash", "452b0359b988148995f22ff0f4229750")
|
"cache.ttl": 86400,
|
||||||
|
"cache.num_counters": 1e5,
|
||||||
|
"cache.max_cost": 1e6,
|
||||||
|
|
||||||
viper.SetDefault("temp.base_path", "cache/")
|
// Telegram
|
||||||
viper.SetDefault("temp.cache_ttl", 3600)
|
"telegram.app_id": 1025907,
|
||||||
|
"telegram.app_hash": "452b0359b988148995f22ff0f4229750",
|
||||||
|
"telegram.rpc_retry": 5,
|
||||||
|
"telegram.userbot.enable": false,
|
||||||
|
"telegram.userbot.session": "data/usersession.db",
|
||||||
|
|
||||||
viper.SetDefault("log.level", "INFO")
|
// 临时目录
|
||||||
viper.SetDefault("log.file", "logs/saveany.log")
|
"temp.base_path": "cache/",
|
||||||
viper.SetDefault("log.backup_count", 7)
|
|
||||||
|
|
||||||
viper.SetDefault("db.path", "data/saveany.db")
|
// 数据库
|
||||||
|
"db.path": "data/saveany.db",
|
||||||
|
"db.session": "data/session.db",
|
||||||
|
}
|
||||||
|
|
||||||
|
for key, value := range defaultConfigs {
|
||||||
|
viper.SetDefault(key, value)
|
||||||
|
}
|
||||||
|
|
||||||
if err := viper.SafeWriteConfigAs("config.toml"); err != nil {
|
if err := viper.SafeWriteConfigAs("config.toml"); err != nil {
|
||||||
if _, ok := err.(viper.ConfigFileAlreadyExistsError); !ok {
|
if _, ok := err.(viper.ConfigFileAlreadyExistsError); !ok {
|
||||||
@@ -103,8 +94,6 @@ func Init() error {
|
|||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
Cfg = &Config{}
|
|
||||||
|
|
||||||
if err := viper.Unmarshal(Cfg); err != nil {
|
if err := viper.Unmarshal(Cfg); err != nil {
|
||||||
fmt.Println("Error unmarshalling config file, ", err)
|
fmt.Println("Error unmarshalling config file, ", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
@@ -119,20 +108,38 @@ func Init() error {
|
|||||||
storageNames := make(map[string]struct{})
|
storageNames := make(map[string]struct{})
|
||||||
for _, storage := range Cfg.Storages {
|
for _, storage := range Cfg.Storages {
|
||||||
if _, ok := storageNames[storage.GetName()]; ok {
|
if _, ok := storageNames[storage.GetName()]; ok {
|
||||||
return fmt.Errorf("重复的存储名: %s", storage.GetName())
|
return errors.New(i18n.TWithoutInit(Cfg.Lang, i18nk.ConfigInvalidDuplicateStorageName, map[string]any{
|
||||||
|
"Name": storage.GetName(),
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
storageNames[storage.GetName()] = struct{}{}
|
storageNames[storage.GetName()] = struct{}{}
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("已加载 %d 个存储:\n", len(Cfg.Storages))
|
fmt.Println(i18n.TWithoutInit(Cfg.Lang, i18nk.LoadedStorages, map[string]any{
|
||||||
|
"Count": len(Cfg.Storages),
|
||||||
|
}))
|
||||||
for _, storage := range Cfg.Storages {
|
for _, storage := range Cfg.Storages {
|
||||||
fmt.Printf(" - %s (%s)\n", storage.GetName(), storage.GetType())
|
fmt.Printf(" - %s (%s)\n", storage.GetName(), storage.GetType())
|
||||||
}
|
}
|
||||||
|
|
||||||
if Cfg.Workers < 1 || Cfg.Retry < 1 {
|
if Cfg.Workers < 1 || Cfg.Retry < 1 {
|
||||||
return fmt.Errorf("workers 和 retry 必须大于 0, 当前值: workers=%d, retry=%d", Cfg.Workers, Cfg.Retry)
|
return errors.New(i18n.TWithoutInit(Cfg.Lang, i18nk.ConfigInvalidWorkersOrRetry, map[string]any{
|
||||||
|
"Workers": Cfg.Workers,
|
||||||
|
"Retry": Cfg.Retry,
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for _, storage := range Cfg.Storages {
|
||||||
|
storages = append(storages, storage.GetName())
|
||||||
|
}
|
||||||
|
for _, user := range Cfg.Users {
|
||||||
|
userIDs = append(userIDs, user.ID)
|
||||||
|
if user.Blacklist {
|
||||||
|
userStorages[user.ID] = slice.Compact(slice.Difference(storages, user.Storages))
|
||||||
|
} else {
|
||||||
|
userStorages[user.ID] = user.Storages
|
||||||
|
}
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
124
core/batchtftask/execute.go
Normal file
124
core/batchtftask/execute.go
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
package batchtftask
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/log"
|
||||||
|
"github.com/duke-git/lancet/v2/retry"
|
||||||
|
"github.com/krau/SaveAny-Bot/common/utils/fsutil"
|
||||||
|
"github.com/krau/SaveAny-Bot/common/utils/ioutil"
|
||||||
|
"github.com/krau/SaveAny-Bot/config"
|
||||||
|
"github.com/krau/SaveAny-Bot/pkg/enums/ctxkey"
|
||||||
|
"github.com/krau/SaveAny-Bot/pkg/tfile"
|
||||||
|
"golang.org/x/sync/errgroup"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (t *Task) Execute(ctx context.Context) error {
|
||||||
|
logger := log.FromContext(ctx).WithPrefix(fmt.Sprintf("batch_file[%s]", t.ID))
|
||||||
|
logger.Info("Starting batch file task")
|
||||||
|
t.Progress.OnStart(ctx, t)
|
||||||
|
workers := config.Cfg.Workers
|
||||||
|
eg, gctx := errgroup.WithContext(ctx)
|
||||||
|
eg.SetLimit(workers)
|
||||||
|
for _, elem := range t.Elems {
|
||||||
|
elem := elem
|
||||||
|
eg.Go(func() error {
|
||||||
|
if t.processing[elem.ID] != nil {
|
||||||
|
return fmt.Errorf("element with ID %s is already being processed", elem.ID)
|
||||||
|
}
|
||||||
|
t.processing[elem.ID] = &elem
|
||||||
|
defer func() {
|
||||||
|
delete(t.processing, elem.ID)
|
||||||
|
}()
|
||||||
|
return t.processElement(gctx, elem)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
err := eg.Wait()
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("Error during batch file processing: %v", err)
|
||||||
|
} else {
|
||||||
|
logger.Info("Batch file task completed successfully")
|
||||||
|
}
|
||||||
|
t.Progress.OnDone(ctx, t, err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Task) processElement(ctx context.Context, elem TaskElement) error {
|
||||||
|
logger := log.FromContext(ctx).WithPrefix(fmt.Sprintf("file[%s]", elem.File.Name()))
|
||||||
|
if elem.stream {
|
||||||
|
pr, pw := io.Pipe()
|
||||||
|
defer pr.Close()
|
||||||
|
errg, uploadCtx := errgroup.WithContext(ctx)
|
||||||
|
errg.Go(func() error {
|
||||||
|
return elem.Storage.Save(uploadCtx, pr, elem.Path)
|
||||||
|
})
|
||||||
|
wr := ioutil.NewProgressWriter(pw, func(n int) {
|
||||||
|
t.downloaded.Add(int64(n))
|
||||||
|
t.Progress.OnProgress(ctx, t)
|
||||||
|
})
|
||||||
|
errg.Go(func() error {
|
||||||
|
defer pw.Close()
|
||||||
|
logger.Info("Starting file download in stream mode")
|
||||||
|
_, err := tfile.NewDownloader(elem.File).Stream(uploadCtx, wr)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("Failed to download file: %v", err)
|
||||||
|
pw.CloseWithError(err)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
if err := errg.Wait(); err != nil {
|
||||||
|
return fmt.Errorf("failed to download file in stream mode: %w", err)
|
||||||
|
}
|
||||||
|
logger.Info("File downloaded successfully in stream mode")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
logger.Info("Starting file download")
|
||||||
|
localFile, err := fsutil.CreateFile(elem.localPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create local file: %w", err)
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if err := localFile.CloseAndRemove(); err != nil {
|
||||||
|
logger.Errorf("Failed to close local file: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
wrAt := ioutil.NewProgressWriterAt(localFile, func(n int) {
|
||||||
|
t.downloaded.Add(int64(n))
|
||||||
|
t.Progress.OnProgress(ctx, t)
|
||||||
|
})
|
||||||
|
_, err = tfile.NewDownloader(elem.File).Parallel(ctx, wrAt)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to download file: %w", err)
|
||||||
|
}
|
||||||
|
logger.Info("File downloaded successfully")
|
||||||
|
if path.Ext(elem.FileName()) == "" {
|
||||||
|
ext := fsutil.DetectFileExt(elem.localPath)
|
||||||
|
if ext != "" {
|
||||||
|
elem.Path = elem.Path + ext
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var fileStat os.FileInfo
|
||||||
|
fileStat, err = os.Stat(elem.localPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get file stat: %w", err)
|
||||||
|
}
|
||||||
|
vctx := context.WithValue(ctx, ctxkey.ContentLength, fileStat.Size())
|
||||||
|
err = retry.Retry(func() error {
|
||||||
|
var file *os.File
|
||||||
|
file, err = os.Open(elem.localPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to open cache file: %w", err)
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
if err = elem.Storage.Save(vctx, file, elem.Path); err != nil {
|
||||||
|
logger.Errorf("Failed to save file: %s, retrying...", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}, retry.Context(vctx), retry.RetryTimes(uint(config.Cfg.Retry)))
|
||||||
|
return err
|
||||||
|
}
|
||||||
176
core/batchtftask/progress.go
Normal file
176
core/batchtftask/progress.go
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
package batchtftask
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/log"
|
||||||
|
"github.com/duke-git/lancet/v2/slice"
|
||||||
|
"github.com/gotd/td/telegram/message/entity"
|
||||||
|
"github.com/gotd/td/telegram/message/styling"
|
||||||
|
"github.com/gotd/td/tg"
|
||||||
|
"github.com/krau/SaveAny-Bot/common/utils/dlutil"
|
||||||
|
"github.com/krau/SaveAny-Bot/common/utils/tgutil"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ProgressTracker interface {
|
||||||
|
OnStart(ctx context.Context, info TaskInfo)
|
||||||
|
OnProgress(ctx context.Context, info TaskInfo)
|
||||||
|
OnDone(ctx context.Context, info TaskInfo, err error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type Progress struct {
|
||||||
|
MessageID int
|
||||||
|
ChatID int64
|
||||||
|
start time.Time
|
||||||
|
lastUpdatePercent atomic.Int32
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Progress) OnStart(ctx context.Context, info TaskInfo) {
|
||||||
|
p.start = time.Now()
|
||||||
|
p.lastUpdatePercent.Store(0)
|
||||||
|
log.FromContext(ctx).Debugf("Batch task progress tracking started for message %d in chat %d", p.MessageID, p.ChatID)
|
||||||
|
entityBuilder := entity.Builder{}
|
||||||
|
var entities []tg.MessageEntityClass
|
||||||
|
if err := styling.Perform(&entityBuilder,
|
||||||
|
styling.Plain("开始执行批量下载任务\n总大小: "),
|
||||||
|
styling.Code(fmt.Sprintf("%.2f MB (%d个文件)", float64(info.TotalSize())/(1024*1024), info.Count())),
|
||||||
|
); err != nil {
|
||||||
|
log.FromContext(ctx).Errorf("Failed to build entities: %s", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
text, entities := entityBuilder.Complete()
|
||||||
|
req := &tg.MessagesEditMessageRequest{
|
||||||
|
ID: p.MessageID,
|
||||||
|
}
|
||||||
|
req.SetMessage(text)
|
||||||
|
req.SetEntities(entities)
|
||||||
|
req.SetReplyMarkup(&tg.ReplyInlineMarkup{
|
||||||
|
Rows: []tg.KeyboardButtonRow{
|
||||||
|
{
|
||||||
|
Buttons: []tg.KeyboardButtonClass{
|
||||||
|
tgutil.BuildCancelButton(info.TaskID()),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}},
|
||||||
|
)
|
||||||
|
ext := tgutil.ExtFromContext(ctx)
|
||||||
|
if ext != nil {
|
||||||
|
ext.EditMessage(p.ChatID, req)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Progress) OnProgress(ctx context.Context, info TaskInfo) {
|
||||||
|
if !shouldUpdateProgress(info.TotalSize(), info.Downloaded(), int(p.lastUpdatePercent.Load())) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
percent := int((info.Downloaded() * 100) / info.TotalSize())
|
||||||
|
if p.lastUpdatePercent.Load() == int32(percent) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p.lastUpdatePercent.Store(int32(percent))
|
||||||
|
log.FromContext(ctx).Debugf("Progress update: %s, %d/%d", info.TaskID(), info.Downloaded(), info.TotalSize())
|
||||||
|
entityBuilder := entity.Builder{}
|
||||||
|
var entities []tg.MessageEntityClass
|
||||||
|
if err := styling.Perform(&entityBuilder,
|
||||||
|
styling.Plain("正在处理批量下载任务\n总大小: "),
|
||||||
|
styling.Code(fmt.Sprintf("%.2f MB (%d个文件)", float64(info.TotalSize())/(1024*1024), info.Count())),
|
||||||
|
styling.Plain("\n正在处理:\n"),
|
||||||
|
func() styling.StyledTextOption {
|
||||||
|
var lines []string
|
||||||
|
for _, elem := range info.Processing() {
|
||||||
|
lines = append(lines, fmt.Sprintf(" - %s (%.2f MB)", elem.FileName(), float64(elem.FileSize())/(1024*1024)))
|
||||||
|
}
|
||||||
|
if len(lines) == 0 {
|
||||||
|
lines = append(lines, " - 无")
|
||||||
|
}
|
||||||
|
return styling.Plain(slice.Join(lines, "\n"))
|
||||||
|
}(),
|
||||||
|
styling.Plain("\n平均速度: "),
|
||||||
|
styling.Bold(fmt.Sprintf("%.2f MB/s", dlutil.GetSpeed(info.Downloaded(), p.start)/(1024*1024))),
|
||||||
|
styling.Plain("\n当前进度: "),
|
||||||
|
styling.Bold(fmt.Sprintf("%.2f%%", float64(info.Downloaded())/float64(info.TotalSize())*100)),
|
||||||
|
); err != nil {
|
||||||
|
log.FromContext(ctx).Errorf("Failed to build entities: %s", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
text, entities := entityBuilder.Complete()
|
||||||
|
req := &tg.MessagesEditMessageRequest{
|
||||||
|
ID: p.MessageID,
|
||||||
|
}
|
||||||
|
req.SetMessage(text)
|
||||||
|
req.SetEntities(entities)
|
||||||
|
req.SetReplyMarkup(&tg.ReplyInlineMarkup{
|
||||||
|
Rows: []tg.KeyboardButtonRow{
|
||||||
|
{
|
||||||
|
Buttons: []tg.KeyboardButtonClass{
|
||||||
|
tgutil.BuildCancelButton(info.TaskID()),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}},
|
||||||
|
)
|
||||||
|
ext := tgutil.ExtFromContext(ctx)
|
||||||
|
if ext != nil {
|
||||||
|
ext.EditMessage(p.ChatID, req)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Progress) OnDone(ctx context.Context, info TaskInfo, err error) {
|
||||||
|
if err != nil {
|
||||||
|
log.FromContext(ctx).Errorf("Batch task %s failed: %s", info.TaskID(), err)
|
||||||
|
} else {
|
||||||
|
log.FromContext(ctx).Debugf("Batch task %s completed successfully", info.TaskID())
|
||||||
|
}
|
||||||
|
entityBuilder := entity.Builder{}
|
||||||
|
var stylingErr error
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, context.Canceled) {
|
||||||
|
stylingErr = styling.Perform(&entityBuilder,
|
||||||
|
styling.Plain("任务已取消"),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
stylingErr = styling.Perform(&entityBuilder,
|
||||||
|
styling.Plain("处理失败, 错误:\n "),
|
||||||
|
styling.Code(err.Error()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
stylingErr = styling.Perform(&entityBuilder,
|
||||||
|
styling.Plain("处理完成\n文件数: "),
|
||||||
|
styling.Code(strconv.Itoa(info.Count())),
|
||||||
|
styling.Plain("\n总大小: "),
|
||||||
|
styling.Code(fmt.Sprintf("%.2f MB", float64(info.TotalSize())/(1024*1024))),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if stylingErr != nil {
|
||||||
|
log.FromContext(ctx).Errorf("Failed to build entities: %s", stylingErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
text, entities := entityBuilder.Complete()
|
||||||
|
req := &tg.MessagesEditMessageRequest{
|
||||||
|
ID: p.MessageID,
|
||||||
|
}
|
||||||
|
req.SetMessage(text)
|
||||||
|
req.SetEntities(entities)
|
||||||
|
|
||||||
|
ext := tgutil.ExtFromContext(ctx)
|
||||||
|
if ext != nil {
|
||||||
|
ext.EditMessage(p.ChatID, req)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewProgressTracker(messageID int, chatID int64) ProgressTracker {
|
||||||
|
return &Progress{
|
||||||
|
MessageID: messageID,
|
||||||
|
ChatID: chatID,
|
||||||
|
}
|
||||||
|
}
|
||||||
95
core/batchtftask/task.go
Normal file
95
core/batchtftask/task.go
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
package batchtftask
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"path/filepath"
|
||||||
|
"sync/atomic"
|
||||||
|
|
||||||
|
"github.com/krau/SaveAny-Bot/config"
|
||||||
|
"github.com/krau/SaveAny-Bot/pkg/enums/tasktype"
|
||||||
|
"github.com/krau/SaveAny-Bot/pkg/tfile"
|
||||||
|
"github.com/krau/SaveAny-Bot/storage"
|
||||||
|
"github.com/rs/xid"
|
||||||
|
)
|
||||||
|
|
||||||
|
type TaskElement struct {
|
||||||
|
ID string
|
||||||
|
Storage storage.Storage
|
||||||
|
Path string
|
||||||
|
File tfile.TGFile
|
||||||
|
localPath string
|
||||||
|
stream bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type Task struct {
|
||||||
|
ID string
|
||||||
|
Ctx context.Context
|
||||||
|
Elems []TaskElement
|
||||||
|
Progress ProgressTracker
|
||||||
|
IgnoreErrors bool // if true, errors during processing will be ignored
|
||||||
|
downloaded atomic.Int64
|
||||||
|
totalSize int64
|
||||||
|
processing map[string]TaskElementInfo
|
||||||
|
failed map[string]error // errors for each element
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Task) Type() tasktype.TaskType {
|
||||||
|
return tasktype.TaskTypeTgfiles
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewTaskElement(
|
||||||
|
stor storage.Storage,
|
||||||
|
path string,
|
||||||
|
file tfile.TGFile,
|
||||||
|
) (*TaskElement, error) {
|
||||||
|
id := xid.New().String()
|
||||||
|
_, ok := stor.(storage.StorageCannotStream)
|
||||||
|
if !config.Cfg.Stream || ok {
|
||||||
|
cachePath, err := filepath.Abs(filepath.Join(config.Cfg.Temp.BasePath, fmt.Sprintf("%s_%s", id, file.Name())))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get absolute path for cache: %w", err)
|
||||||
|
}
|
||||||
|
return &TaskElement{
|
||||||
|
ID: id,
|
||||||
|
Storage: stor,
|
||||||
|
Path: path,
|
||||||
|
File: file,
|
||||||
|
localPath: cachePath,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
return &TaskElement{
|
||||||
|
ID: id,
|
||||||
|
Storage: stor,
|
||||||
|
Path: path,
|
||||||
|
File: file,
|
||||||
|
stream: true,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewBatchTGFileTask(
|
||||||
|
id string,
|
||||||
|
ctx context.Context,
|
||||||
|
files []TaskElement,
|
||||||
|
progress ProgressTracker,
|
||||||
|
ignoreErrors bool,
|
||||||
|
) *Task {
|
||||||
|
task := &Task{
|
||||||
|
ID: id,
|
||||||
|
Ctx: ctx,
|
||||||
|
Elems: files,
|
||||||
|
Progress: progress,
|
||||||
|
downloaded: atomic.Int64{},
|
||||||
|
totalSize: func() int64 {
|
||||||
|
var total int64
|
||||||
|
for _, elem := range files {
|
||||||
|
total += elem.File.Size()
|
||||||
|
}
|
||||||
|
return total
|
||||||
|
}(),
|
||||||
|
processing: make(map[string]TaskElementInfo),
|
||||||
|
IgnoreErrors: ignoreErrors,
|
||||||
|
failed: make(map[string]error),
|
||||||
|
}
|
||||||
|
return task
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user