Compare commits
348 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
17e340fff1 | ||
|
|
f92c43b9c8 | ||
|
|
3e20dc2c5f | ||
|
|
3ce00884a0 | ||
|
|
cd7cf4964d | ||
|
|
bc3c841d1d | ||
|
|
743c15f1a5 | ||
|
|
b05d86509c | ||
|
|
f17a380579 | ||
|
|
cabe1189e2 | ||
|
|
a988d05e24 | ||
|
|
1af2c1f7c7 | ||
|
|
7b36fb45f5 | ||
|
|
62cceee592 | ||
|
|
6d315f7af2 | ||
|
|
5352491c76 | ||
|
|
3f914f7a64 | ||
|
|
8972d8a169 | ||
|
|
1339c69dbf | ||
|
|
63aeabb39b | ||
|
|
e60e983229 | ||
|
|
75e5fd10ea | ||
|
|
c8d8a2e0eb | ||
|
|
044e732084 | ||
|
|
0e951f641c | ||
|
|
8dd6265d55 | ||
|
|
ad7e01f052 | ||
|
|
c069d1edc7 | ||
|
|
fd3b17a360 | ||
|
|
a3162eff89 | ||
|
|
cebadd39f7 | ||
|
|
95f7d5abb5 | ||
|
|
5b8249060d | ||
|
|
0b97d15b2a | ||
|
|
6db5633ca6 | ||
|
|
4ccbf8177e | ||
|
|
6e16dc6051 | ||
|
|
9ed2c425be | ||
|
|
bc892d9370 | ||
|
|
29d523bd4f | ||
|
|
3f9c3f2a73 | ||
|
|
b796b045a8 | ||
|
|
5f7b936270 | ||
|
|
df64ec3069 | ||
|
|
d3cc56c8e6 | ||
|
|
018ed47949 | ||
|
|
09f4dd4ce7 | ||
|
|
adc64ad510 | ||
|
|
da6cf42355 | ||
|
|
8c76516953 | ||
|
|
15c4fffb98 | ||
|
|
b5cdf1e880 | ||
|
|
264dd9f9ed | ||
|
|
b25df2e214 | ||
|
|
5999ddbe1d | ||
|
|
7424190ee5 | ||
|
|
87e8836c78 | ||
|
|
1a7747c2d2 | ||
|
|
ca0fd67fba | ||
|
|
4d736b925b | ||
|
|
ead2b20f4e | ||
|
|
080d474714 | ||
|
|
f453205fde | ||
|
|
407677f270 | ||
|
|
958bfd1dbe | ||
|
|
debe33d84d | ||
|
|
52eead3bf5 | ||
|
|
0af049a507 | ||
|
|
8752dd865c | ||
|
|
c0b4580e34 | ||
|
|
280fd6ead8 | ||
|
|
0ca3d97711 | ||
|
|
51198a1e3d | ||
|
|
651835c467 | ||
|
|
45c978980c | ||
|
|
c21ff7e499 | ||
|
|
32cc1e4b5a | ||
|
|
c974791dc0 | ||
|
|
91814a83c7 | ||
|
|
685047e463 | ||
|
|
37e9c79ceb | ||
|
|
494d1bf51c | ||
|
|
a6f194aedd | ||
|
|
acd16a91a3 | ||
|
|
75f79e8abc | ||
|
|
1065acfdb8 | ||
|
|
fef7d37a7e | ||
|
|
b5e9cf987a | ||
|
|
c58fa454bb | ||
|
|
2c5d6f0e57 | ||
|
|
7d57ad30a9 | ||
|
|
4f314bd37f | ||
|
|
131dfeb4cd | ||
|
|
3f40acff55 | ||
|
|
fe47ee3b51 | ||
|
|
4a6f63e58f | ||
|
|
16c71e6384 | ||
|
|
0c2d116708 | ||
|
|
450d32b2b7 | ||
|
|
f80ecae3cc | ||
|
|
f0853536d9 | ||
|
|
15cf81e1bd | ||
|
|
ae48bd52bf | ||
|
|
44de871f63 | ||
|
|
7a2a530e49 | ||
|
|
3aa84e89bf | ||
|
|
257c292679 | ||
|
|
0e2a9cacf2 | ||
|
|
a7854afb2a | ||
|
|
0e989cc1a6 | ||
|
|
76a82a38ee | ||
|
|
c7a0076c15 | ||
|
|
ea07ff7eca | ||
|
|
4d837e946c | ||
|
|
f947ee6fc7 | ||
|
|
40ad12a892 | ||
|
|
697e419643 | ||
|
|
eef051de3b | ||
|
|
6e29442c05 | ||
|
|
a3f1f75caf | ||
|
|
f05dd883e3 | ||
|
|
9cb866de8c | ||
|
|
980455fd24 | ||
|
|
24978470cd | ||
|
|
215e082028 | ||
|
|
a7b93e57fc | ||
|
|
a4b3b459a9 | ||
|
|
06f326088a | ||
|
|
b7d3ec6230 | ||
|
|
f812990e1c | ||
|
|
492900bbef | ||
|
|
764be2a083 | ||
|
|
46c21b77e9 | ||
|
|
8b389a58d5 | ||
|
|
25ad9befa0 | ||
|
|
e824b210d1 | ||
|
|
ae0aa7db3f | ||
|
|
226c15ef08 | ||
|
|
9b3f955e48 | ||
|
|
4997ec408f | ||
|
|
0756cc9eb1 | ||
|
|
37c32a23d4 | ||
|
|
3aa1e2eaed | ||
|
|
b87dd68880 | ||
|
|
68e5a51300 | ||
|
|
7300e54c40 | ||
|
|
94f796d0e8 | ||
|
|
c023fd869d | ||
|
|
e5d1e143e0 | ||
|
|
03eb4f8a18 | ||
|
|
231eb61d25 | ||
|
|
fd1b586b8d | ||
|
|
d035a3409e | ||
|
|
6112f6c240 | ||
|
|
18eedf2edb | ||
|
|
5f9bba9ff7 | ||
|
|
0d3d2209be | ||
|
|
302db2fe75 | ||
|
|
79386bdd7d | ||
|
|
f0607de2cc | ||
|
|
b2bfc96a8f | ||
|
|
0c5bb2ba77 | ||
|
|
9cc87380ff | ||
|
|
46afc14322 | ||
|
|
0c16650ea5 | ||
|
|
133453b5d4 | ||
|
|
8f9ef07d1c | ||
|
|
36285a0700 | ||
|
|
ccf206d176 | ||
|
|
4c851cbbaf | ||
|
|
b9d14f79c8 | ||
|
|
ee5e0b8ff0 | ||
|
|
6423fb25a7 | ||
|
|
03907f2d32 | ||
|
|
9e5042bda1 | ||
|
|
4ebacb02c1 | ||
|
|
818ac9b240 | ||
|
|
fc4a112f08 | ||
|
|
7a2274baa0 | ||
|
|
69a3ed6f4e | ||
|
|
36f3dd83fc | ||
|
|
501b9d844a | ||
|
|
03cec7ec01 | ||
|
|
dc0debcd1c | ||
|
|
4b136bd41e | ||
|
|
d703f11ea0 | ||
|
|
3ce9926967 | ||
|
|
80146176f0 | ||
|
|
14ba2afdf8 | ||
|
|
f4d427a1cb | ||
|
|
f84c83a7e2 | ||
|
|
cb6540c017 | ||
|
|
e7bab27543 | ||
|
|
f693bd6103 | ||
|
|
75f52569a0 | ||
|
|
c795f957a9 | ||
|
|
3b85911e3d | ||
|
|
336309fad0 | ||
|
|
394cdff865 | ||
|
|
40cb3dad9d | ||
|
|
2979628cf7 | ||
|
|
c82c2462bf | ||
|
|
88128ecac2 | ||
|
|
758564d436 | ||
|
|
f5e33472eb | ||
|
|
4df2c5a06d | ||
|
|
eb6f8675a4 | ||
|
|
473a5b9413 | ||
|
|
6c2abe3025 | ||
|
|
e7e5b9f434 | ||
|
|
d4d39d1c07 | ||
|
|
73b5f1b18e | ||
|
|
837700bf63 | ||
|
|
53e6d7cc54 | ||
|
|
4206d1fe96 | ||
|
|
6566dbbf96 | ||
|
|
44c0c784d7 | ||
|
|
8ebf96444d | ||
|
|
900823cdb9 | ||
|
|
280745cae3 | ||
|
|
e85d3c9441 | ||
|
|
9d3a3a8dcd | ||
|
|
19535d0438 | ||
|
|
693e20b066 | ||
|
|
56ea1d6f36 | ||
|
|
95522d03f9 | ||
|
|
2bc290b57d | ||
|
|
c7c458f147 | ||
|
|
481427683e | ||
|
|
c798c7ae99 | ||
|
|
0422c1ac3e | ||
|
|
f0445fe26f | ||
|
|
a3628be024 | ||
|
|
a9c56892c3 | ||
|
|
015539c009 | ||
|
|
71844deab1 | ||
|
|
55fed6389e | ||
|
|
8ce5c2e007 | ||
|
|
6ecfbd8385 | ||
|
|
6c2bfd72cd | ||
|
|
8ea5be5b90 | ||
|
|
7f483056e0 | ||
|
|
a6f88d7f75 | ||
|
|
b757df0b5e | ||
|
|
b017046c8b | ||
|
|
a474fdf6ae | ||
|
|
729e688748 | ||
|
|
9ea4857cd9 | ||
|
|
8bf7bc0e85 | ||
|
|
26e344a6f6 | ||
|
|
8f0744077e | ||
|
|
ed99a37831 | ||
|
|
488d709d85 | ||
|
|
66454b082a | ||
|
|
70e83e62d9 | ||
|
|
d2ddb9193a | ||
|
|
5f78db90c7 | ||
|
|
c3a4702e79 | ||
|
|
e731cfee9e | ||
|
|
75de86fe97 | ||
|
|
6d4e97b4bb | ||
|
|
239d5ad562 | ||
|
|
e76f191922 | ||
|
|
a619ed2f22 | ||
|
|
838dfc35a1 | ||
|
|
6ecee6d561 | ||
|
|
c1827f93a9 | ||
|
|
aaf3f7c35f | ||
|
|
02fbea4eb0 | ||
|
|
bf9aef6eb7 | ||
|
|
230c07fd55 | ||
|
|
18de349dc3 | ||
|
|
cef1a5c194 | ||
|
|
99f8f0cb27 | ||
|
|
789c14134c | ||
|
|
5bb3b595aa | ||
|
|
609289c16a | ||
|
|
c8c348a182 | ||
|
|
725acd0199 | ||
|
|
166c27c70f | ||
|
|
3bdef20e85 | ||
|
|
50fba3f910 | ||
|
|
87d3f14392 | ||
|
|
30452c8d46 | ||
|
|
300f7723af | ||
|
|
491ba55f1e | ||
|
|
32519b8c08 | ||
|
|
7ffd9891a0 | ||
|
|
347a60f1f7 | ||
|
|
da69fe1354 | ||
|
|
746ca026ba | ||
|
|
a8c64675e5 | ||
|
|
3918f6eee2 | ||
|
|
8d44b43c82 | ||
|
|
f14c4367f8 | ||
|
|
3e3a320672 | ||
|
|
19efab0665 | ||
|
|
635f00ac71 | ||
|
|
2d2becccf6 | ||
|
|
ed0837a89b | ||
|
|
65fee89e14 | ||
|
|
8e180006f0 | ||
|
|
721c9666eb | ||
|
|
6f35401181 | ||
|
|
72ae2ce079 | ||
|
|
495ad3ea5c | ||
|
|
3def9df4b4 | ||
|
|
790a32d297 | ||
|
|
f7779224ef | ||
|
|
7d899ae088 | ||
|
|
7e67bdb7e2 | ||
|
|
0071780ff4 | ||
|
|
0a95431468 | ||
|
|
34525c5b11 | ||
|
|
6ac6d79fb6 | ||
|
|
f21a82ad43 | ||
|
|
73f6647f8d | ||
|
|
6fbb4609f9 | ||
|
|
802c908384 | ||
|
|
5d403056d0 | ||
|
|
8e2dd37155 | ||
|
|
9c7ed833fd | ||
|
|
f9d601bd8a | ||
|
|
152f473131 | ||
|
|
7015081a84 | ||
|
|
be6444cf96 | ||
|
|
98ba7c50e7 | ||
|
|
0c31d908cc | ||
|
|
9e776b22fb | ||
|
|
d6f8603656 | ||
|
|
9c42bee662 | ||
|
|
b96340dd46 | ||
|
|
a5ba01e219 | ||
|
|
d00e907735 | ||
|
|
418f9bd2bc | ||
|
|
28b4585dba | ||
|
|
d2669f0c99 | ||
|
|
c9921926e3 | ||
|
|
d7cd2ede01 | ||
|
|
ed21b65c98 | ||
|
|
8975589c43 | ||
|
|
27dca2e343 | ||
|
|
5c8261c34a | ||
|
|
cbc2dc82d8 | ||
|
|
09a7c5597d | ||
|
|
f73f18e90d | ||
|
|
ab822c2fe6 | ||
|
|
2579044841 |
12
.dockerignore
Normal file
12
.dockerignore
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
*.md
|
||||||
|
.git
|
||||||
|
.github/
|
||||||
|
.gitignore
|
||||||
|
.vscode/
|
||||||
|
downloads/
|
||||||
|
data/
|
||||||
|
cache/
|
||||||
|
docs/
|
||||||
|
config.example.toml
|
||||||
|
docker-compose.*
|
||||||
|
playwright/
|
||||||
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"
|
||||||
|
]
|
||||||
45
.github/ISSUE_TEMPLATE/bug.yml
vendored
Normal file
45
.github/ISSUE_TEMPLATE/bug.yml
vendored
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
name: "👾 Bug Report"
|
||||||
|
description: "Report a bug or unexpected behavior"
|
||||||
|
labels:
|
||||||
|
- "bug"
|
||||||
|
assignees:
|
||||||
|
- krau
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
# Please Search Before Submitting / 提交前请搜索
|
||||||
|
Please make sure to search existing issues before submitting a new bug report.
|
||||||
|
提交新的 Bug 报告前请务必搜索已有的 issue,避免重复
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: "👾 Description"
|
||||||
|
description: "What happened?"
|
||||||
|
placeholder: "When called ... happens ..."
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: "⚡️ Expected Behavior"
|
||||||
|
description: "What was expected?"
|
||||||
|
placeholder: "It should be ..."
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: "📄 Configuration File"
|
||||||
|
description: "Please provide your config file"
|
||||||
|
placeholder: "Please remove sensitive information"
|
||||||
|
render: toml
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: "🔍 Logs"
|
||||||
|
description: "Please provide logs"
|
||||||
|
placeholder: "Please remove sensitive information"
|
||||||
|
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: 💬 Don't know how to use it correctly?
|
||||||
|
url: https://github.com/krau/SaveAny-Bot/discussions
|
||||||
|
about: "Go to the discussion area to ask questions"
|
||||||
|
- name: 📄 Documentation
|
||||||
|
url: https://sabot.unv.app
|
||||||
|
about: "View the documentation"
|
||||||
95
.github/ISSUE_TEMPLATE/feature.yml
vendored
Normal file
95
.github/ISSUE_TEMPLATE/feature.yml
vendored
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
name: "⭐️ Feature Request"
|
||||||
|
description: "Feature request"
|
||||||
|
labels:
|
||||||
|
- "enhancement"
|
||||||
|
assignees:
|
||||||
|
- krau
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
Please describe the feature you want in detail.
|
||||||
|
请详细描述你想要的功能。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚠️ IMPORTANT NOTICE / 说明
|
||||||
|
|
||||||
|
Save Any Bot supports multiple storage backends, **including Telegram**.
|
||||||
|
However, **all backends are treated equally**, keep this in mind when submitting feature requests.
|
||||||
|
|
||||||
|
Save Any Bot 支持多种存储后端,**包括 Telegram**。
|
||||||
|
但**所有后端在设计上是平等的**,请在提出功能请求前务必理解这一点。
|
||||||
|
|
||||||
|
### ❌ Out of scope requests / 不在项目范围内的请求
|
||||||
|
The following requests are **out of scope** and will be closed without discussion:
|
||||||
|
|
||||||
|
以下请求**不属于本项目设计范围**,将被直接关闭,不再讨论:
|
||||||
|
|
||||||
|
- Adding **Telegram-specific behaviors or exceptions**
|
||||||
|
添加 **仅针对 Telegram 的特殊行为或例外逻辑**
|
||||||
|
- Treating Telegram as anything other than a **generic file storage backend**
|
||||||
|
将 Telegram 视为非“通用文件存储后端”的特殊存在
|
||||||
|
- Saving or syncing **non-file content** (text messages, chat history, etc.)
|
||||||
|
保存或同步 **非文件内容**(文本消息、聊天记录等)
|
||||||
|
- Preserving or reconstructing original messages (e.g. 1:1 forwarding)
|
||||||
|
保留或还原原始消息形态(例如 1:1 转发)
|
||||||
|
- Perform special reprocessing on files to adapt to specific storage backends
|
||||||
|
(e.g. splitting, re-encoding, transforming, etc.)
|
||||||
|
为适配特定存储后端而对文件进行特殊处理
|
||||||
|
(如分割、转码、重编码、转换格式等)
|
||||||
|
- Any request that requires different logic *only because the backend is Telegram*
|
||||||
|
任何**仅因后端是 Telegram 而需要不同逻辑**的请求
|
||||||
|
|
||||||
|
### ❌ Abuse-leaning or high-risk requests / 滥用倾向的请求
|
||||||
|
Requests that may **enable or encourage** the following will NOT be accepted:
|
||||||
|
|
||||||
|
可能**促成或鼓励**以下行为的请求将不会被接受:
|
||||||
|
|
||||||
|
- Violating Telegram Terms of Service
|
||||||
|
违反 Telegram 服务条款
|
||||||
|
- Building traffic, mirror, or profit-oriented channels using third-party content
|
||||||
|
利用第三方内容构建引流、镜像或牟利用途的频道
|
||||||
|
|
||||||
|
### ⚖️ Design principle / 设计原则
|
||||||
|
Save Any Bot follows a **backend-agnostic design**:
|
||||||
|
|
||||||
|
Save Any Bot 遵循 **后端无关(backend-agnostic)** 的设计原则:
|
||||||
|
|
||||||
|
- If a feature cannot be implemented **uniformly across all backends**, it will not be added.
|
||||||
|
如果某个功能无法在 **所有后端** 中统一实现,则不会被添加。
|
||||||
|
- No backend-specific hacks or special cases will be introduced.
|
||||||
|
不会引入任何后端特有的 hack 或特殊处理逻辑。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
If your request falls into any of the categories above, please do not open an issue.
|
||||||
|
Such issues will be closed.
|
||||||
|
|
||||||
|
如果你的请求符合以上任一情况,请不要提交 issue,
|
||||||
|
相关 issue 将被直接关闭。
|
||||||
|
|
||||||
|
Thank you for respecting the scope and design principles of this project.
|
||||||
|
感谢你的理解与支持。
|
||||||
|
- 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:
|
||||||
159
.github/workflows/build-docker.yml
vendored
159
.github/workflows/build-docker.yml
vendored
@@ -7,21 +7,53 @@ on:
|
|||||||
|
|
||||||
env:
|
env:
|
||||||
REGISTRY: ghcr.io
|
REGISTRY: ghcr.io
|
||||||
IMAGE_NAME: ${{ github.repository }}
|
IMAGE_NAME: krau/saveany-bot
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: docker-build-${{ github.repository }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-and-push:
|
prepare:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
outputs:
|
||||||
|
version: ${{ steps.vars.outputs.version }}
|
||||||
|
major_minor: ${{ steps.vars.outputs.major_minor }}
|
||||||
|
short_sha: ${{ steps.vars.outputs.short_sha }}
|
||||||
|
build_time: ${{ steps.vars.outputs.build_time }}
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- name: Extract Version Components
|
||||||
|
id: vars
|
||||||
|
run: |
|
||||||
|
VERSION=${GITHUB_REF#refs/tags/v}
|
||||||
|
MAJOR_MINOR=$(echo "$VERSION" | cut -d. -f1,2)
|
||||||
|
SHORT_SHA=$(git rev-parse --short HEAD)
|
||||||
|
|
||||||
|
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "major_minor=$MAJOR_MINOR" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "short_sha=$SHORT_SHA" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "build_time=$(date -u +'%Y-%m-%dT%H:%M:%SZ')" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
build:
|
||||||
|
needs: prepare
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
packages: write
|
packages: write
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
arch: [amd64, arm64]
|
||||||
|
type: [default, micro, pico]
|
||||||
|
fail-fast: false
|
||||||
|
runs-on: ${{ matrix.arch == 'amd64' && 'ubuntu-latest' || 'ubuntu-24.04-arm' }}
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Set up QEMU
|
|
||||||
uses: docker/setup-qemu-action@v3
|
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v3
|
||||||
@@ -33,28 +65,99 @@ jobs:
|
|||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Extract metadata for Docker
|
- name: Build and push by digest
|
||||||
id: meta
|
id: build
|
||||||
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
|
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
platforms: linux/amd64,linux/arm64
|
file: ${{ matrix.type == 'default' && './Dockerfile' || format('./Dockerfile.{0}', matrix.type) }}
|
||||||
push: true
|
platforms: ${{ matrix.arch == 'amd64' && 'linux/amd64' || 'linux/arm64' }}
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
# 关键修改:不再使用 tags,而是通过 image output 按摘要推送
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
outputs: type=image,name=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
|
||||||
|
build-args: |
|
||||||
|
VERSION=${{ needs.prepare.outputs.version }}
|
||||||
|
GitCommit=${{ needs.prepare.outputs.short_sha }}
|
||||||
|
BuildTime=${{ needs.prepare.outputs.build_time }}
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
|
|
||||||
|
- name:
|
||||||
|
Export digest
|
||||||
|
# 将 digest 写入文件,供后续步骤读取
|
||||||
|
run: |
|
||||||
|
mkdir -p /tmp/digests
|
||||||
|
digest="${{ steps.build.outputs.digest }}"
|
||||||
|
touch "/tmp/digests/${digest#sha256:}"
|
||||||
|
|
||||||
|
echo "$digest" > /tmp/digests/digest
|
||||||
|
|
||||||
|
- name: Upload digest
|
||||||
|
uses: actions/upload-artifact@v6
|
||||||
|
with:
|
||||||
|
name: digest-${{ matrix.type }}-${{ matrix.arch }}
|
||||||
|
path: /tmp/digests/digest
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 1
|
||||||
|
|
||||||
|
create-manifest:
|
||||||
|
needs: [prepare, build]
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
packages: write
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
type: [default, micro, pico]
|
||||||
|
steps:
|
||||||
|
- name: Login to GitHub Container Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ env.REGISTRY }}
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Download digests
|
||||||
|
uses: actions/download-artifact@v7
|
||||||
|
with:
|
||||||
|
path: /tmp/digests
|
||||||
|
pattern: digest-${{ matrix.type }}-*
|
||||||
|
merge-multiple: false
|
||||||
|
|
||||||
|
- name: Create and push manifest lists
|
||||||
|
run: |
|
||||||
|
REPO="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}"
|
||||||
|
VERSION="${{ needs.prepare.outputs.version }}"
|
||||||
|
MAJOR_MINOR="${{ needs.prepare.outputs.major_minor }}"
|
||||||
|
SHA="${{ needs.prepare.outputs.short_sha }}"
|
||||||
|
TYPE="${{ matrix.type }}"
|
||||||
|
|
||||||
|
DIGEST_AMD64=$(cat /tmp/digests/digest-${TYPE}-amd64/digest)
|
||||||
|
DIGEST_ARM64=$(cat /tmp/digests/digest-${TYPE}-arm64/digest)
|
||||||
|
|
||||||
|
echo "Found digests for $TYPE:"
|
||||||
|
echo "AMD64: $DIGEST_AMD64"
|
||||||
|
echo "ARM64: $DIGEST_ARM64"
|
||||||
|
|
||||||
|
TAGS=()
|
||||||
|
|
||||||
|
if [ "$TYPE" == "default" ]; then
|
||||||
|
TAGS+=("$REPO:latest")
|
||||||
|
TAGS+=("$REPO:$VERSION")
|
||||||
|
TAGS+=("$REPO:$MAJOR_MINOR")
|
||||||
|
TAGS+=("$REPO:sha-$SHA")
|
||||||
|
else
|
||||||
|
TAGS+=("$REPO:$TYPE")
|
||||||
|
TAGS+=("$REPO:$TYPE-latest")
|
||||||
|
TAGS+=("$REPO:$TYPE-$VERSION")
|
||||||
|
fi
|
||||||
|
|
||||||
|
SRC_AMD64="${REPO}@${DIGEST_AMD64}"
|
||||||
|
SRC_ARM64="${REPO}@${DIGEST_ARM64}"
|
||||||
|
|
||||||
|
echo "Creating manifest list with sources:"
|
||||||
|
echo " $SRC_AMD64"
|
||||||
|
echo " $SRC_ARM64"
|
||||||
|
|
||||||
|
for TAG in "${TAGS[@]}"; do
|
||||||
|
echo "Pushing tag: $TAG"
|
||||||
|
docker buildx imagetools create -t "$TAG" "$SRC_AMD64" "$SRC_ARM64"
|
||||||
|
done
|
||||||
|
|||||||
15
.github/workflows/build-release.yml
vendored
15
.github/workflows/build-release.yml
vendored
@@ -1,3 +1,5 @@
|
|||||||
|
name: Build Release
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
@@ -13,7 +15,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
@@ -21,7 +23,7 @@ jobs:
|
|||||||
uses: softprops/action-gh-release@v2
|
uses: softprops/action-gh-release@v2
|
||||||
|
|
||||||
- name: Setup node
|
- name: Setup node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
|
|
||||||
@@ -36,6 +38,9 @@ jobs:
|
|||||||
matrix:
|
matrix:
|
||||||
goos: [linux, darwin, windows]
|
goos: [linux, darwin, windows]
|
||||||
goarch: [amd64, arm64]
|
goarch: [amd64, arm64]
|
||||||
|
exclude:
|
||||||
|
- goos: windows
|
||||||
|
goarch: arm64
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -58,9 +63,9 @@ jobs:
|
|||||||
README.md
|
README.md
|
||||||
ldflags: >-
|
ldflags: >-
|
||||||
-s -w
|
-s -w
|
||||||
-X "github.com/krau/SaveAny-Bot/common.Version=${{ env.VERSION }}"
|
-X "github.com/krau/SaveAny-Bot/config.Version=${{ env.VERSION }}"
|
||||||
-X "github.com/krau/SaveAny-Bot/common.BuildTime=${{ format(github.event.repository.updated_at, 'yyyy-MM-dd HH:mm:ss') }}"
|
-X "github.com/krau/SaveAny-Bot/config.BuildTime=${{ format(github.event.repository.updated_at, 'yyyy-MM-dd HH:mm:ss') }}"
|
||||||
-X "github.com/krau/SaveAny-Bot/common.GitCommit=${{ github.sha }}"
|
-X "github.com/krau/SaveAny-Bot/config.GitCommit=${{ github.sha }}"
|
||||||
binary_name: saveany-bot
|
binary_name: saveany-bot
|
||||||
env:
|
env:
|
||||||
VERSION: ${{ env.VERSION }}
|
VERSION: ${{ env.VERSION }}
|
||||||
|
|||||||
36
.github/workflows/docs.yml
vendored
Normal file
36
.github/workflows/docs.yml
vendored
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
name: Deploy Docs
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
paths:
|
||||||
|
- "docs/**"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
submodules: true # Fetch Hugo themes (true OR recursive)
|
||||||
|
fetch-depth: 0 # Fetch all history for .GitInfo and .Lastmod
|
||||||
|
|
||||||
|
- name: Setup Hugo
|
||||||
|
uses: peaceiris/actions-hugo@v3
|
||||||
|
with:
|
||||||
|
hugo-version: '0.147.8'
|
||||||
|
extended: true
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: hugo --minify --destination public --source docs
|
||||||
|
|
||||||
|
- name: Deploy
|
||||||
|
uses: peaceiris/actions-gh-pages@v3
|
||||||
|
if: github.ref == 'refs/heads/main'
|
||||||
|
with:
|
||||||
|
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
publish_dir: ./docs/public
|
||||||
|
publish_branch: gh-pages
|
||||||
10
.gitignore
vendored
10
.gitignore
vendored
@@ -3,6 +3,12 @@ logs/
|
|||||||
tmp/
|
tmp/
|
||||||
data/
|
data/
|
||||||
downloads/
|
downloads/
|
||||||
cache/
|
|
||||||
session.*
|
session.*
|
||||||
cache.db
|
cache.db
|
||||||
|
.vscode/
|
||||||
|
temp/
|
||||||
|
.hugo_build.lock
|
||||||
|
playwright/
|
||||||
|
testplugins/
|
||||||
|
*.exe
|
||||||
|
tmp-*
|
||||||
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",
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
301
AGENTS.md
Normal file
301
AGENTS.md
Normal file
@@ -0,0 +1,301 @@
|
|||||||
|
# SaveAny-Bot Agent Guidelines
|
||||||
|
|
||||||
|
This document provides essential information for AI coding agents working on the SaveAny-Bot project.
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
SaveAny-Bot is a Telegram bot written in Go that saves files/messages from Telegram and various websites to multiple storage backends (local, S3, MinIO, WebDAV, AList, Telegram). It features a plugin system for parsing web content and extensible storage backends.
|
||||||
|
|
||||||
|
**Tech Stack**: Go 1.24.2, gotd/td (Telegram MTProto), Cobra (CLI), Viper (config), GORM (ORM), SQLite, Goja (JS runtime), Playwright (browser automation)
|
||||||
|
|
||||||
|
## Build & Test Commands
|
||||||
|
|
||||||
|
### Build
|
||||||
|
```bash
|
||||||
|
# Standard build
|
||||||
|
go build -o saveany-bot .
|
||||||
|
|
||||||
|
# Run directly
|
||||||
|
go run ./cmd
|
||||||
|
|
||||||
|
# Docker build (multi-stage, Alpine-based)
|
||||||
|
docker build -t saveany-bot .
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test
|
||||||
|
```bash
|
||||||
|
# Run all tests
|
||||||
|
go test ./...
|
||||||
|
|
||||||
|
# Run tests in specific package
|
||||||
|
go test ./pkg/queue
|
||||||
|
go test ./storage/telegram
|
||||||
|
|
||||||
|
# Run tests with verbose output
|
||||||
|
go test -v ./...
|
||||||
|
|
||||||
|
# Run a single test
|
||||||
|
go test -run TestQueueBasic ./pkg/queue
|
||||||
|
|
||||||
|
# Run with coverage
|
||||||
|
go test -cover ./...
|
||||||
|
```
|
||||||
|
|
||||||
|
### Lint & Format
|
||||||
|
```bash
|
||||||
|
# Format code (standard Go formatting)
|
||||||
|
go fmt ./...
|
||||||
|
|
||||||
|
# Vet code for common issues
|
||||||
|
go vet ./...
|
||||||
|
|
||||||
|
# Generate code (i18n keys)
|
||||||
|
go generate ./...
|
||||||
|
```
|
||||||
|
|
||||||
|
### Other Commands
|
||||||
|
```bash
|
||||||
|
# Update dependencies
|
||||||
|
go mod tidy
|
||||||
|
|
||||||
|
# View documentation
|
||||||
|
cd docs && hugo server -D
|
||||||
|
```
|
||||||
|
|
||||||
|
## Code Style Guidelines
|
||||||
|
|
||||||
|
### Imports
|
||||||
|
- Standard library first, then third-party, then project-internal
|
||||||
|
- Group imports with blank lines between groups
|
||||||
|
- Use explicit import aliases for clarity when needed (e.g., `storconfig`, `storenum`)
|
||||||
|
|
||||||
|
```go
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/log"
|
||||||
|
|
||||||
|
"github.com/krau/SaveAny-Bot/config"
|
||||||
|
"github.com/krau/SaveAny-Bot/pkg/enums/storage"
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Formatting
|
||||||
|
- Line length: reasonable (no hard limit, but be sensible)
|
||||||
|
- Organize code with blank lines between logical sections
|
||||||
|
- Follow standard Go conventions for braces, spacing, etc.
|
||||||
|
|
||||||
|
### Types & Interfaces
|
||||||
|
- Use clear, descriptive type names (PascalCase for exported, camelCase for unexported)
|
||||||
|
- Define interfaces where abstraction is needed (e.g., `Executable`, `StorageConfig`)
|
||||||
|
- Embed context in method signatures, not structs: `func (s *Service) Do(ctx context.Context) error`
|
||||||
|
- Prefer composition over inheritance
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Interfaces define behavior
|
||||||
|
type Executable interface {
|
||||||
|
Type() tasktype.TaskType
|
||||||
|
Title() string
|
||||||
|
TaskID() string
|
||||||
|
Execute(ctx context.Context) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Structs compose behavior
|
||||||
|
type Local struct {
|
||||||
|
config config.LocalStorageConfig
|
||||||
|
logger *log.Logger
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Naming Conventions
|
||||||
|
- **Packages**: lowercase, single word when possible (avoid underscores)
|
||||||
|
- **Files**: lowercase with underscores for multiword (e.g., `auth_terminal.go`, `progress_reader.go`)
|
||||||
|
- **Variables**: camelCase for unexported, PascalCase for exported
|
||||||
|
- **Constants**: PascalCase for exported, camelCase for unexported (not ALL_CAPS)
|
||||||
|
- **Functions/Methods**: PascalCase for exported, camelCase for unexported
|
||||||
|
- **Test files**: `*_test.go` pattern
|
||||||
|
|
||||||
|
### Error Handling
|
||||||
|
- Always handle errors explicitly; never ignore them
|
||||||
|
- Wrap errors with context using `fmt.Errorf("context: %w", err)`
|
||||||
|
- Use `errors.Is()` and `errors.As()` for error checking
|
||||||
|
- Log errors with appropriate level (Error, Warn, Info)
|
||||||
|
- Return errors from functions rather than panicking (except for truly unrecoverable situations)
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Good error handling
|
||||||
|
if err := db.Save(user).Error; err != nil {
|
||||||
|
return fmt.Errorf("failed to save user %d: %w", user.ChatID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check specific errors
|
||||||
|
if errors.Is(err, context.Canceled) {
|
||||||
|
logger.Info("Operation was canceled")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Logging
|
||||||
|
- Use `github.com/charmbracelet/log` package
|
||||||
|
- Get logger from context: `log.FromContext(ctx)`
|
||||||
|
- Create prefixed loggers for components: `logger.WithPrefix("component")`
|
||||||
|
- Use appropriate levels: Debug, Info, Warn, Error
|
||||||
|
- Include context in log messages (e.g., task IDs, file names)
|
||||||
|
|
||||||
|
```go
|
||||||
|
logger := log.FromContext(ctx)
|
||||||
|
logger.Infof("Processing task: %s", task.ID)
|
||||||
|
logger.Errorf("Failed to save file %s: %v", filename, err)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Concurrency
|
||||||
|
- Use channels for communication between goroutines
|
||||||
|
- Protect shared state with `sync.Mutex` or `sync.RWMutex`
|
||||||
|
- Use `sync.WaitGroup` for coordinating goroutine completion
|
||||||
|
- Always pass `context.Context` for cancellation support
|
||||||
|
- Use `context.WithCancel/WithTimeout` for managing goroutine lifetimes
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Example from queue implementation
|
||||||
|
func (tq *TaskQueue[T]) Add(task *Task[T]) error {
|
||||||
|
tq.mu.Lock()
|
||||||
|
defer tq.mu.Unlock()
|
||||||
|
// ... critical section
|
||||||
|
tq.cond.Signal()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Comments
|
||||||
|
- Document exported types, functions, and packages with doc comments
|
||||||
|
- Start doc comments with the name being documented
|
||||||
|
- Use `//` for single-line comments
|
||||||
|
- Explain *why*, not *what* (code should be self-explanatory for "what")
|
||||||
|
- Add `[NOTE]`, `[WARN]`, `[IMPORTANT]` tags for important clarifications
|
||||||
|
|
||||||
|
```go
|
||||||
|
// GetUserByChatID retrieves a user by their Telegram chat ID.
|
||||||
|
// Returns an error if the user is not found.
|
||||||
|
func GetUserByChatID(ctx context.Context, chatID int64) (*User, error) {
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture & Conventions
|
||||||
|
|
||||||
|
### Application Structure
|
||||||
|
- **Entry point**: `main.go` → `cmd.Execute(ctx)`
|
||||||
|
- **CLI root**: `cmd/root.go` (Cobra), implementation in `cmd/run.go`
|
||||||
|
- **Startup sequence**: Config → Cache → i18n → Database → Storage → Parsers → Userbot → Bot → Queue
|
||||||
|
- Follow this order when adding new initialization steps in `cmd/run.go::initAll`
|
||||||
|
|
||||||
|
### Configuration (Viper)
|
||||||
|
- Config defined in `config/viper.go::Config`
|
||||||
|
- Read from `config.toml` (see `config.example.toml`)
|
||||||
|
- Environment variables: `SAVEANY_*` prefix (e.g., `SAVEANY_TELEGRAM_TOKEN`)
|
||||||
|
- Access via `config.C()` (returns a copy, don't modify the return value)
|
||||||
|
- Storage configs validated via `config/storage/factory.go::LoadStorageConfigs`
|
||||||
|
|
||||||
|
### Telegram Client
|
||||||
|
- **Bot client**: `client/bot/bot.go::Init` (uses gotgproto)
|
||||||
|
- **Handlers**: Centralized in `client/bot/handlers/` directory
|
||||||
|
- **Registration**: All handlers registered in `handlers.Register`
|
||||||
|
- **Commands**: Add to `CommandHandlers` slice for automatic `/help` and bot command list updates
|
||||||
|
- **Middleware**: Common middleware in `client/middleware/` (floodwait, retry, etc.)
|
||||||
|
|
||||||
|
### Tasks & Queue
|
||||||
|
- **Task interface**: `core/core.go::Executable` (Type, Title, TaskID, Execute methods)
|
||||||
|
- **Queue**: `pkg/queue.TaskQueue[Executable]` (generic, thread-safe)
|
||||||
|
- **Workers**: Count from `config.C().Workers`
|
||||||
|
- **Task types**: Implementations in `core/tasks/**` (tfile, parsed, telegraph, directlinks, batchtfile)
|
||||||
|
- **Lifecycle hooks**: `TaskBeforeStart`, `TaskSuccess`, `TaskFail`, `TaskCancel` (defined in config)
|
||||||
|
- **Adding tasks**: Use `core.AddTask(ctx, task)`
|
||||||
|
|
||||||
|
### Database (GORM + SQLite)
|
||||||
|
- **Init**: `database.Init` using `config.C().DB.Path`
|
||||||
|
- **Models**: User, Dir, Rule, WatchChat (in `database/*.go`)
|
||||||
|
- **Migrations**: Automatic via `db.AutoMigrate`
|
||||||
|
- **User sync**: `database.syncUsers` syncs DB with `config.C().Users` (don't manually create/delete users)
|
||||||
|
- **Context**: Always use `db.WithContext(ctx)` for operations
|
||||||
|
|
||||||
|
### Storage Backends
|
||||||
|
- **Interface**: Defined in `config/storage/types.go` and `storage/`
|
||||||
|
- **Implementations**: local, alist, s3/minio, webdav, telegram (each in subdirectory)
|
||||||
|
- **Adding new storage**:
|
||||||
|
1. Add enum to `pkg/enums/storage`
|
||||||
|
2. Create config struct in `config/storage/` with `Validate()` method
|
||||||
|
3. Implement storage in `storage/<name>/`
|
||||||
|
4. Register in `storageFactories` mapping
|
||||||
|
5. Update `config.example.toml` with example
|
||||||
|
|
||||||
|
### Parser Plugins (JavaScript)
|
||||||
|
- **Runtime**: Goja (JS runtime) + Playwright (browser automation)
|
||||||
|
- **Plugin API**: `registerParser({ metadata, canHandle, parse })` in JS
|
||||||
|
- **Integration**: Defined in `parsers/` directory
|
||||||
|
- **Documentation**: See `plugins/README.md`
|
||||||
|
- Plugin `parse` returns `Item`/`Resource` which becomes download/transfer task
|
||||||
|
|
||||||
|
### Internationalization (i18n)
|
||||||
|
- **Usage**: `i18n.T(i18nk.SomeKey, map[string]any{"Name": value})`
|
||||||
|
- **Locale files**: `common/i18n/locale/*.yaml`
|
||||||
|
- **Key generation**: Run `go generate ./...` to generate `common/i18n/i18nk/keys.go`
|
||||||
|
- **Adding new strings**: Add to YAML → run `go generate` → use in code
|
||||||
|
- All user-facing strings should be internationalized
|
||||||
|
|
||||||
|
### Context Usage
|
||||||
|
- Always pass `context.Context` as first parameter
|
||||||
|
- Use `log.FromContext(ctx)` to get contextual logger
|
||||||
|
- Respect context cancellation in long-running operations
|
||||||
|
- Store request-scoped data in context (e.g., `ctxkey.ContentLength`)
|
||||||
|
|
||||||
|
## Special Rules from .github/copilot-instructions.md
|
||||||
|
|
||||||
|
1. **Never modify `config.C()` return values** - it returns a copy. Modify config in `config.Init` or via Viper.
|
||||||
|
2. **Handlers must update `CommandHandlers` slice** - ensures `/help` and bot commands stay in sync.
|
||||||
|
3. **Task execution must preserve hooks** - don't remove `TaskBeforeStart`, `TaskSuccess`, `TaskFail`, `TaskCancel` hook calls.
|
||||||
|
4. **User sync is automatic** - don't manually create/delete users in DB; use config-based sync.
|
||||||
|
5. **Prefer context logger** - use `log.FromContext(ctx)` over global logger when context is available.
|
||||||
|
6. **Storage factory pattern** - new storage types must register in `storageFactories` mapping.
|
||||||
|
7. **Plugin API compatibility** - changes to `Item`/`Resource` structures require updating `plugins/README.md`.
|
||||||
|
|
||||||
|
## Common Patterns
|
||||||
|
|
||||||
|
### Adding a New Command
|
||||||
|
1. Create handler function in `client/bot/handlers/<name>.go`
|
||||||
|
2. Add to `CommandHandlers` slice in `register.go`
|
||||||
|
3. Add i18n key to `common/i18n/locale/*.yaml`
|
||||||
|
4. Run `go generate ./...`
|
||||||
|
5. Test with Telegram bot
|
||||||
|
|
||||||
|
### Adding a New Task Type
|
||||||
|
1. Create struct implementing `core.Executable` in `core/tasks/<type>/`
|
||||||
|
2. Implement `Type()`, `Title()`, `TaskID()`, `Execute(ctx)` methods
|
||||||
|
3. Add task type enum to `pkg/enums/tasktype`
|
||||||
|
4. Use `core.AddTask(ctx, task)` to enqueue
|
||||||
|
|
||||||
|
### Adding a New Storage Backend
|
||||||
|
1. Define config struct in `config/storage/<name>.go` with `Validate()` method
|
||||||
|
2. Implement storage interface in `storage/<name>/<name>.go`
|
||||||
|
3. Add storage type enum to `pkg/enums/storage`
|
||||||
|
4. Register factory in `config/storage/factory.go::storageFactories`
|
||||||
|
5. Update `config.example.toml` with configuration example
|
||||||
|
|
||||||
|
## File References
|
||||||
|
|
||||||
|
When referencing code locations, use `path/to/file.go:line` format (e.g., `core/core.go:23` for the worker function).
|
||||||
|
|
||||||
|
## Testing Guidelines
|
||||||
|
|
||||||
|
- Write tests for new functionality (place in `*_test.go` files)
|
||||||
|
- Test files should be in same package as code being tested
|
||||||
|
- Use table-driven tests for multiple test cases
|
||||||
|
- Mock external dependencies (databases, network calls)
|
||||||
|
- Aim for meaningful tests, not just coverage numbers
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Binary size matters: use `CGO_ENABLED=0` for static binaries
|
||||||
|
- FFmpeg is included in Docker images for media processing
|
||||||
|
- Build process supports cross-compilation (amd64/arm64, Linux/macOS/Windows)
|
||||||
|
- Documentation site uses Hugo; edit files in `docs/` directory
|
||||||
|
- Session data stored in SQLite; delete `data/session.db` if changing bot token
|
||||||
31
Dockerfile
31
Dockerfile
@@ -1,18 +1,39 @@
|
|||||||
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/config.Version=${VERSION}' \
|
||||||
|
-X 'github.com/krau/SaveAny-Bot/config.GitCommit=${GitCommit}' \
|
||||||
|
-X 'github.com/krau/SaveAny-Bot/config.BuildTime=${BuildTime}' \
|
||||||
|
-X 'github.com/krau/SaveAny-Bot/config.Docker=true' \
|
||||||
|
" \
|
||||||
|
-o saveany-bot .
|
||||||
|
|
||||||
FROM alpine:latest
|
FROM alpine:latest
|
||||||
|
|
||||||
|
RUN apk add --no-cache curl ffmpeg
|
||||||
|
|
||||||
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"]
|
||||||
|
|||||||
41
Dockerfile.micro
Normal file
41
Dockerfile.micro
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
FROM golang:alpine AS builder
|
||||||
|
|
||||||
|
ARG VERSION="dev"
|
||||||
|
ARG GitCommit="Unknown"
|
||||||
|
ARG BuildTime="Unknown"
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN --mount=type=cache,target=/go/pkg/mod \
|
||||||
|
go mod download
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
RUN --mount=type=cache,target=/root/.cache/go-build \
|
||||||
|
--mount=type=cache,target=/go/pkg \
|
||||||
|
CGO_ENABLED=0 \
|
||||||
|
go build -trimpath \
|
||||||
|
-tags=no_jsparser,no_minio,no_bubbletea \
|
||||||
|
-ldflags=" \
|
||||||
|
-s -w \
|
||||||
|
-X 'github.com/krau/SaveAny-Bot/config.Version=${VERSION}' \
|
||||||
|
-X 'github.com/krau/SaveAny-Bot/config.GitCommit=${GitCommit}' \
|
||||||
|
-X 'github.com/krau/SaveAny-Bot/config.BuildTime=${BuildTime}' \
|
||||||
|
-X 'github.com/krau/SaveAny-Bot/config.Docker=true' \
|
||||||
|
" \
|
||||||
|
-o saveany-bot .
|
||||||
|
|
||||||
|
FROM alpine:latest
|
||||||
|
|
||||||
|
RUN apk add --no-cache curl
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY --from=builder /app/saveany-bot .
|
||||||
|
COPY entrypoint.sh .
|
||||||
|
|
||||||
|
RUN chmod +x /app/saveany-bot && \
|
||||||
|
chmod +x /app/entrypoint.sh
|
||||||
|
|
||||||
|
ENTRYPOINT ["/app/entrypoint.sh"]
|
||||||
|
|
||||||
35
Dockerfile.pico
Normal file
35
Dockerfile.pico
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
# pico is the minimum build of SaveAnyBot, which disables all the optional features like JS parsing and MinIO support.
|
||||||
|
FROM golang:alpine AS builder
|
||||||
|
|
||||||
|
ARG VERSION="dev"
|
||||||
|
ARG GitCommit="Unknown"
|
||||||
|
ARG BuildTime="Unknown"
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN --mount=type=cache,target=/go/pkg/mod \
|
||||||
|
go mod download
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
RUN --mount=type=cache,target=/root/.cache/go-build \
|
||||||
|
--mount=type=cache,target=/go/pkg \
|
||||||
|
CGO_ENABLED=0 \
|
||||||
|
go build -trimpath \
|
||||||
|
-tags=no_jsparser,no_minio,sqlite_glebarez,no_bubbletea \
|
||||||
|
-ldflags=" \
|
||||||
|
-s -w \
|
||||||
|
-X 'github.com/krau/SaveAny-Bot/config.Version=${VERSION}' \
|
||||||
|
-X 'github.com/krau/SaveAny-Bot/config.GitCommit=${GitCommit}' \
|
||||||
|
-X 'github.com/krau/SaveAny-Bot/config.BuildTime=${BuildTime}' \
|
||||||
|
-X 'github.com/krau/SaveAny-Bot/config.Docker=true' \
|
||||||
|
" \
|
||||||
|
-o saveany-bot . && chmod +x saveany-bot
|
||||||
|
|
||||||
|
FROM scratch
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY --from=builder /app/saveany-bot .
|
||||||
|
|
||||||
|
ENTRYPOINT ["/app/saveany-bot"]
|
||||||
147
README.md
147
README.md
@@ -1,108 +1,93 @@
|
|||||||
<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** | [简体中文](./README_zh.md)
|
||||||
|
|
||||||
把 Telegram 的文件保存到各类存储端.
|
> **Save Any Telegram File to Anywhere 📂. Support restrict saving content and beyond telegram.**
|
||||||
|
|
||||||
> _就像 PikPak Bot 一样_
|
[](https://github.com/krau/saveany-bot/releases)
|
||||||
|
[](https://github.com/krau/saveany-bot/releases)
|
||||||
</div
|
[](https://github.com/krau/saveany-bot/actions/workflows/build-release.yml)
|
||||||
|
[](https://github.com/krau/saveany-bot/stargazers)
|
||||||
Demo Video:
|
[](https://github.com/krau/saveany-bot/releases)
|
||||||
|
[](https://github.com/krau/saveany-bot/issues)
|
||||||
<div align="center">
|
[](https://github.com/krau/saveany-bot/pulls)
|
||||||
|
[](./LICENSE)
|
||||||
[SaveAny-Bot 演示视频 | The Demo of SaveAny-Bot.webm](https://github.com/user-attachments/assets/a0de2453-a4d1-4a12-81fb-9d84856dce09)
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
## 部署
|
## 🎯 Features
|
||||||
|
|
||||||
### 从二进制文件部署
|
- Support documents / videos / photos / stickers… and even [Telegraph](https://telegra.ph/)
|
||||||
|
- Bypass "restrict saving content" media
|
||||||
|
- Batch download
|
||||||
|
- Streaming transfer
|
||||||
|
- Multi-user support
|
||||||
|
- Auto organize files based on storage rules
|
||||||
|
- Watch specified chats and auto-save messages, with filters
|
||||||
|
- Write JS parser plugins to save files from almost any website
|
||||||
|
- Storage backends:
|
||||||
|
- Alist
|
||||||
|
- S3
|
||||||
|
- WebDAV
|
||||||
|
- Local filesystem
|
||||||
|
- Telegram (re-upload to specified chats)
|
||||||
|
|
||||||
在 [Release](https://github.com/krau/SaveAny-Bot/releases) 页面下载对应平台的二进制文件.
|
## 📦 Quick Start
|
||||||
|
|
||||||
在解压后目录新建 `config.toml` 文件, 参考 [config.toml.example](https://github.com/krau/SaveAny-Bot/blob/main/config.example.toml) 编辑配置文件.
|
Create a `config.toml` file with the following content:
|
||||||
|
|
||||||
运行:
|
```toml
|
||||||
|
lang = "en" # Language setting, "en" for English
|
||||||
|
[telegram]
|
||||||
|
token = "" # Your bot token, obtained from @BotFather
|
||||||
|
[telegram.proxy]
|
||||||
|
# Enable proxy for Telegram
|
||||||
|
enable = false
|
||||||
|
url = "socks5://127.0.0.1:7890"
|
||||||
|
|
||||||
|
[[storages]]
|
||||||
|
name = "Local Disk"
|
||||||
|
type = "local"
|
||||||
|
enable = true
|
||||||
|
base_path = "./downloads"
|
||||||
|
|
||||||
|
[[users]]
|
||||||
|
id = 114514 # Your Telegram account id
|
||||||
|
storages = []
|
||||||
|
blacklist = true
|
||||||
|
```
|
||||||
|
|
||||||
|
Run Save Any Bot with Docker:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
chmod +x saveany-bot
|
|
||||||
./saveany-bot
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 添加为 systemd 服务
|
|
||||||
|
|
||||||
创建文件 `/etc/systemd/system/saveany-bot.service` 并写入以下内容:
|
|
||||||
|
|
||||||
```
|
|
||||||
[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
|
|
||||||
```
|
|
||||||
|
|
||||||
设为开机启动并启动服务:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
systemctl enable --now saveany-bot
|
|
||||||
```
|
|
||||||
|
|
||||||
### 使用 Docker 部署
|
|
||||||
|
|
||||||
#### Docker Compose
|
|
||||||
|
|
||||||
下载 [docker-compose.yml](https://github.com/krau/SaveAny-Bot/blob/main/docker-compose.yml) 文件, 在同目录下新建 `config.toml` 文件, 参考 [config.toml.example](https://github.com/krau/SaveAny-Bot/blob/main/config.example.toml) 编辑配置文件.
|
|
||||||
|
|
||||||
启动:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Docker
|
|
||||||
|
|
||||||
```shell
|
|
||||||
docker run -d --name saveany-bot \
|
docker run -d --name saveany-bot \
|
||||||
-v /path/to/config.toml:/app/config.toml \
|
-v ./config.toml:/app/config.toml \
|
||||||
-v /path/to/downloads:/app/downloads \
|
-v ./downloads:/app/downloads \
|
||||||
ghcr.io/krau/saveany-bot:latest
|
ghcr.io/krau/saveany-bot:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
## 更新
|
Please [**read the docs**](https://sabot.unv.app/en/) for more configuration options and usage.
|
||||||
|
|
||||||
使用 `upgrade` 或 `up` 升级到最新版
|
## Sponsors
|
||||||
|
|
||||||
```bash
|
This project is supported by [YxVM](https://yxvm.com/) and [NodeSupport](https://github.com/NodeSeekDev/NodeSupport).
|
||||||
./saveany-bot upgrade
|
|
||||||
```
|
|
||||||
|
|
||||||
如果是 Docker 部署, 使用以下命令更新:
|
If this project is helpful to you, consider sponsoring me via:
|
||||||
|
|
||||||
```bash
|
- [Afdian](https://afdian.com/a/unvapp)
|
||||||
docker pull ghcr.io/krau/saveany-bot:latest
|
|
||||||
docker restart saveany-bot
|
|
||||||
```
|
|
||||||
|
|
||||||
## 使用
|
## Thanks To
|
||||||
|
|
||||||
向 Bot 发送(转发)文件, 或发送公开频道的消息链接, 按照提示操作.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 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)
|
||||||
- All the dependencies
|
- [tdl](https://github.com/iyear/tdl)
|
||||||
|
- All the dependencies, contributors, sponsors and users.
|
||||||
|
|
||||||
|
## Contact
|
||||||
|
|
||||||
|
- [](https://t.me/ProjectSaveAny)
|
||||||
|
- [](https://github.com/krau/saveany-bot/discussions)
|
||||||
|
- [](https://t.me/acherkrau)
|
||||||
108
README_EN.md
108
README_EN.md
@@ -1,108 +0,0 @@
|
|||||||
<div align="center">
|
|
||||||
|
|
||||||
# <img src="docs/logo.jpg" width="45" align="center"> Save Any Bot
|
|
||||||
|
|
||||||
[简体中文](README.md) | **English**
|
|
||||||
|
|
||||||
Save Telegram files to various storage endpoints.
|
|
||||||
|
|
||||||
> _Just like PikPak Bot_
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
Demo Video:
|
|
||||||
|
|
||||||
<div align="center">
|
|
||||||
|
|
||||||
[SaveAny-Bot Demo Video.webm](https://github.com/user-attachments/assets/a0de2453-a4d1-4a12-81fb-9d84856dce09)
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
## Deployment
|
|
||||||
|
|
||||||
### Deploy from Binary
|
|
||||||
|
|
||||||
Download the binary file for your platform from the [Release](https://github.com/krau/SaveAny-Bot/releases) page.
|
|
||||||
|
|
||||||
Create a `config.toml` file in the extracted directory, refer to [config.toml.example](https://github.com/krau/SaveAny-Bot/blob/main/config.example.toml) for configuration.
|
|
||||||
|
|
||||||
Run:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
chmod +x saveany-bot
|
|
||||||
./saveany-bot
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Add as systemd Service
|
|
||||||
|
|
||||||
Create file `/etc/systemd/system/saveany-bot.service` and write the following content:
|
|
||||||
|
|
||||||
```
|
|
||||||
[Unit]
|
|
||||||
Description=SaveAnyBot
|
|
||||||
After=systemd-user-sessions.service
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=simple
|
|
||||||
WorkingDirectory=/yourpath/
|
|
||||||
ExecStart=/yourpath/saveany-bot
|
|
||||||
Restart=on-failure
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
```
|
|
||||||
|
|
||||||
Enable auto-start and start the service:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
systemctl enable --now saveany-bot
|
|
||||||
```
|
|
||||||
|
|
||||||
### Deploy with Docker
|
|
||||||
|
|
||||||
#### Docker Compose
|
|
||||||
|
|
||||||
Download [docker-compose.yml](https://github.com/krau/SaveAny-Bot/blob/main/docker-compose.yml) file and create a `config.toml` file in the same directory, refer to [config.toml.example](https://github.com/krau/SaveAny-Bot/blob/main/config.example.toml) for configuration.
|
|
||||||
|
|
||||||
Run:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Docker
|
|
||||||
|
|
||||||
```shell
|
|
||||||
docker run -d --name saveany-bot \
|
|
||||||
-v /path/to/config.toml:/app/config.toml \
|
|
||||||
-v /path/to/downloads:/app/downloads \
|
|
||||||
ghcr.io/krau/saveany-bot:latest
|
|
||||||
```
|
|
||||||
|
|
||||||
## Update
|
|
||||||
|
|
||||||
Use `upgrade` or `up` command to upgrade to the latest version:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./saveany-bot upgrade
|
|
||||||
```
|
|
||||||
|
|
||||||
If deployed with Docker, use the following commands to update:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker pull ghcr.io/krau/saveany-bot:latest
|
|
||||||
docker restart saveany-bot
|
|
||||||
```
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
Send (forward) files to the Bot and follow the prompts.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Thanks
|
|
||||||
|
|
||||||
- [gotd](https://github.com/gotd/td)
|
|
||||||
- [TG-FileStreamBot](https://github.com/EverythingSuckz/TG-FileStreamBot)
|
|
||||||
- [gotgproto](https://github.com/celestix/gotgproto)
|
|
||||||
- All the dependencies
|
|
||||||
90
README_zh.md
Normal file
90
README_zh.md
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
<div align="center">
|
||||||
|
|
||||||
|
# <img src="docs/static/logo.png" width="45" align="center"> Save Any Bot
|
||||||
|
|
||||||
|
> **把 Telegram 上的文件转存到多种存储端**
|
||||||
|
|
||||||
|
[](https://github.com/krau/saveany-bot/releases)
|
||||||
|
[](https://github.com/krau/saveany-bot/releases)
|
||||||
|
[](https://github.com/krau/saveany-bot/actions/workflows/build-release.yml)
|
||||||
|
[](https://github.com/krau/saveany-bot/stargazers)
|
||||||
|
[](https://github.com/krau/saveany-bot/releases)
|
||||||
|
[](https://github.com/krau/saveany-bot/issues)
|
||||||
|
[](https://github.com/krau/saveany-bot/pulls)
|
||||||
|
[](./LICENSE)
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
## 🎯 特性
|
||||||
|
|
||||||
|
- 支持文档/视频/图片/贴纸…甚至还有 [Telegraph](https://telegra.ph/)
|
||||||
|
- 破解禁止保存的文件
|
||||||
|
- 批量下载
|
||||||
|
- 流式传输
|
||||||
|
- 多用户使用
|
||||||
|
- 基于存储规则的自动整理
|
||||||
|
- 监听并自动转存指定聊天的消息, 支持过滤
|
||||||
|
- 使用 js 编写解析器插件以转存任意网站的文件
|
||||||
|
- 存储端支持:
|
||||||
|
- Alist
|
||||||
|
- S3
|
||||||
|
- WebDAV
|
||||||
|
- 本地磁盘
|
||||||
|
- Telegram (重传回指定聊天)
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
创建文件 `config.toml` 并填入以下内容:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[telegram]
|
||||||
|
token = "" # 你的 Bot Token, 在 @BotFather 获取
|
||||||
|
[telegram.proxy]
|
||||||
|
# 启用代理连接 telegram
|
||||||
|
enable = false
|
||||||
|
url = "socks5://127.0.0.1:7890"
|
||||||
|
|
||||||
|
[[storages]]
|
||||||
|
name = "本地磁盘"
|
||||||
|
type = "local"
|
||||||
|
enable = true
|
||||||
|
base_path = "./downloads"
|
||||||
|
|
||||||
|
[[users]]
|
||||||
|
id = 114514 # 你的 Telegram 账号 id
|
||||||
|
storages = []
|
||||||
|
blacklist = true
|
||||||
|
```
|
||||||
|
|
||||||
|
使用 Docker 运行 Save Any Bot:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run -d --name saveany-bot \
|
||||||
|
-v ./config.toml:/app/config.toml \
|
||||||
|
-v ./downloads:/app/downloads \
|
||||||
|
ghcr.io/krau/saveany-bot:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
请 [**查看文档**](https://sabot.unv.app/) 以获取更多配置选项和使用方法.
|
||||||
|
|
||||||
|
## 赞助
|
||||||
|
|
||||||
|
本项目受到 [YxVM](https://yxvm.com/) 与 [NodeSupport](https://github.com/NodeSeekDev/NodeSupport) 的支持.
|
||||||
|
|
||||||
|
如果这个项目对你有帮助, 你可以考虑通过以下方式赞助我:
|
||||||
|
|
||||||
|
- [爱发电](https://afdian.com/a/unvapp)
|
||||||
|
|
||||||
|
## 鸣谢
|
||||||
|
|
||||||
|
- [gotd](https://github.com/gotd/td)
|
||||||
|
- [TG-FileStreamBot](https://github.com/EverythingSuckz/TG-FileStreamBot)
|
||||||
|
- [gotgproto](https://github.com/celestix/gotgproto)
|
||||||
|
- [tdl](https://github.com/iyear/tdl)
|
||||||
|
- All the dependencies, contributors, sponsors and users.
|
||||||
|
|
||||||
|
## 社区和关于作者
|
||||||
|
|
||||||
|
- [](https://t.me/ProjectSaveAny)
|
||||||
|
- [](https://github.com/krau/saveany-bot/discussions)
|
||||||
|
- [](https://t.me/acherkrau)
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
package bootstrap
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
|
|
||||||
"github.com/krau/SaveAny-Bot/bot"
|
|
||||||
"github.com/krau/SaveAny-Bot/common"
|
|
||||||
"github.com/krau/SaveAny-Bot/config"
|
|
||||||
"github.com/krau/SaveAny-Bot/dao"
|
|
||||||
"github.com/krau/SaveAny-Bot/logger"
|
|
||||||
"github.com/krau/SaveAny-Bot/storage"
|
|
||||||
)
|
|
||||||
|
|
||||||
func InitAll() {
|
|
||||||
if err := config.Init(); err != nil {
|
|
||||||
fmt.Println("加载配置文件失败: ", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
logger.InitLogger()
|
|
||||||
logger.L.Info("正在启动 SaveAny-Bot...")
|
|
||||||
storage.LoadStorages()
|
|
||||||
common.Init()
|
|
||||||
dao.Init()
|
|
||||||
bot.Init()
|
|
||||||
}
|
|
||||||
100
bot/bot.go
100
bot/bot.go
@@ -1,100 +0,0 @@
|
|||||||
package bot
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"net/url"
|
|
||||||
"os"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/celestix/gotgproto"
|
|
||||||
"github.com/celestix/gotgproto/sessionMaker"
|
|
||||||
"github.com/glebarez/sqlite"
|
|
||||||
"github.com/gotd/td/telegram/dcs"
|
|
||||||
"github.com/gotd/td/tg"
|
|
||||||
"github.com/krau/SaveAny-Bot/config"
|
|
||||||
"github.com/krau/SaveAny-Bot/logger"
|
|
||||||
"golang.org/x/net/proxy"
|
|
||||||
)
|
|
||||||
|
|
||||||
var Client *gotgproto.Client
|
|
||||||
|
|
||||||
func newProxyDialer(proxyUrl string) (proxy.Dialer, error) {
|
|
||||||
url, err := url.Parse(proxyUrl)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return proxy.FromURL(url, proxy.Direct)
|
|
||||||
}
|
|
||||||
|
|
||||||
func Init() {
|
|
||||||
logger.L.Info("初始化 Telegram 客户端...")
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
resultChan := make(chan struct {
|
|
||||||
client *gotgproto.Client
|
|
||||||
err error
|
|
||||||
})
|
|
||||||
go func() {
|
|
||||||
var resolver dcs.Resolver
|
|
||||||
if config.Cfg.Telegram.Proxy.Enable && config.Cfg.Telegram.Proxy.URL != "" {
|
|
||||||
dialer, err := newProxyDialer(config.Cfg.Telegram.Proxy.URL)
|
|
||||||
if err != nil {
|
|
||||||
resultChan <- struct {
|
|
||||||
client *gotgproto.Client
|
|
||||||
err error
|
|
||||||
}{nil, err}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
resolver = dcs.Plain(dcs.PlainOptions{
|
|
||||||
Dial: dialer.(proxy.ContextDialer).DialContext,
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
resolver = dcs.DefaultResolver()
|
|
||||||
}
|
|
||||||
client, err := gotgproto.NewClient(config.Cfg.Telegram.AppID,
|
|
||||||
config.Cfg.Telegram.AppHash,
|
|
||||||
gotgproto.ClientTypeBot(config.Cfg.Telegram.Token),
|
|
||||||
&gotgproto.ClientOpts{
|
|
||||||
Session: sessionMaker.SqlSession(sqlite.Open("data/session.db")),
|
|
||||||
DisableCopyright: true,
|
|
||||||
Middlewares: FloodWaitMiddleware(),
|
|
||||||
Resolver: resolver,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
resultChan <- struct {
|
|
||||||
client *gotgproto.Client
|
|
||||||
err error
|
|
||||||
}{nil, err}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
_, err = client.API().BotsSetBotCommands(ctx, &tg.BotsSetBotCommandsRequest{
|
|
||||||
Scope: &tg.BotCommandScopeDefault{},
|
|
||||||
Commands: []tg.BotCommand{
|
|
||||||
{Command: "start", Description: "开始使用"},
|
|
||||||
{Command: "help", Description: "显示帮助"},
|
|
||||||
{Command: "silent", Description: "开启/关闭静默模式"},
|
|
||||||
{Command: "storage", Description: "设置默认存储端"},
|
|
||||||
{Command: "save", Description: "保存所回复的文件"},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
resultChan <- struct {
|
|
||||||
client *gotgproto.Client
|
|
||||||
err error
|
|
||||||
}{client, err}
|
|
||||||
}()
|
|
||||||
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
logger.L.Fatal("初始化客户端失败: 超时")
|
|
||||||
os.Exit(1)
|
|
||||||
case result := <-resultChan:
|
|
||||||
if result.err != nil {
|
|
||||||
logger.L.Fatalf("初始化客户端失败: %s", result.err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
Client = result.client
|
|
||||||
RegisterHandlers(Client.Dispatcher)
|
|
||||||
logger.L.Info("客户端初始化完成")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,110 +0,0 @@
|
|||||||
package bot
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"regexp"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/celestix/gotgproto/dispatcher"
|
|
||||||
"github.com/celestix/gotgproto/ext"
|
|
||||||
"github.com/gotd/td/tg"
|
|
||||||
"github.com/krau/SaveAny-Bot/dao"
|
|
||||||
"github.com/krau/SaveAny-Bot/logger"
|
|
||||||
"github.com/krau/SaveAny-Bot/storage"
|
|
||||||
"github.com/krau/SaveAny-Bot/types"
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
linkRegexString = `t.me/.*/\d+`
|
|
||||||
linkRegex = regexp.MustCompile(linkRegexString)
|
|
||||||
)
|
|
||||||
|
|
||||||
func handleLinkMessage(ctx *ext.Context, update *ext.Update) error {
|
|
||||||
logger.L.Trace("Got link message")
|
|
||||||
link := linkRegex.FindString(update.EffectiveMessage.Text)
|
|
||||||
if link == "" {
|
|
||||||
return dispatcher.ContinueGroups
|
|
||||||
}
|
|
||||||
strSlice := strings.Split(link, "/")
|
|
||||||
if len(strSlice) < 3 {
|
|
||||||
return dispatcher.ContinueGroups
|
|
||||||
}
|
|
||||||
messageID, err := strconv.Atoi(strSlice[2])
|
|
||||||
if err != nil {
|
|
||||||
logger.L.Errorf("解析消息 ID 失败: %s", err)
|
|
||||||
ctx.Reply(update, ext.ReplyTextString("无法解析消息 ID"), nil)
|
|
||||||
return dispatcher.EndGroups
|
|
||||||
}
|
|
||||||
chatUsername := strSlice[1]
|
|
||||||
linkChat, err := ctx.ResolveUsername(chatUsername)
|
|
||||||
if err != nil {
|
|
||||||
logger.L.Errorf("解析 Chat ID 失败: %s", err)
|
|
||||||
ctx.Reply(update, ext.ReplyTextString("无法解析 Chat ID"), nil)
|
|
||||||
return dispatcher.EndGroups
|
|
||||||
}
|
|
||||||
if linkChat == nil {
|
|
||||||
logger.L.Errorf("无法找到聊天: %s", chatUsername)
|
|
||||||
ctx.Reply(update, ext.ReplyTextString("无法找到聊天"), nil)
|
|
||||||
return dispatcher.EndGroups
|
|
||||||
}
|
|
||||||
user, err := dao.GetUserByChatID(update.GetUserChat().GetID())
|
|
||||||
if err != nil {
|
|
||||||
logger.L.Errorf("获取用户失败: %s", err)
|
|
||||||
ctx.Reply(update, ext.ReplyTextString("获取用户失败"), nil)
|
|
||||||
return dispatcher.EndGroups
|
|
||||||
}
|
|
||||||
storages := storage.GetUserStorages(user.ChatID)
|
|
||||||
|
|
||||||
if len(storages) == 0 {
|
|
||||||
ctx.Reply(update, ext.ReplyTextString("无可用的存储"), nil)
|
|
||||||
return dispatcher.EndGroups
|
|
||||||
}
|
|
||||||
replied, err := ctx.Reply(update, ext.ReplyTextString("正在获取文件..."), nil)
|
|
||||||
if err != nil {
|
|
||||||
logger.L.Errorf("回复失败: %s", err)
|
|
||||||
return dispatcher.EndGroups
|
|
||||||
}
|
|
||||||
|
|
||||||
file, err := FileFromMessage(ctx, linkChat.GetID(), messageID, "")
|
|
||||||
if err != nil {
|
|
||||||
logger.L.Errorf("获取文件失败: %s", err)
|
|
||||||
ctx.Reply(update, ext.ReplyTextString("获取文件失败: "+err.Error()), nil)
|
|
||||||
return dispatcher.EndGroups
|
|
||||||
}
|
|
||||||
// TODO: Better file name
|
|
||||||
if file.FileName == "" {
|
|
||||||
logger.L.Warnf("文件名为空,使用生成的名称")
|
|
||||||
file.FileName = fmt.Sprintf("%d_%d_%s", linkChat.GetID(), messageID, file.Hash())
|
|
||||||
}
|
|
||||||
|
|
||||||
receivedFile := &types.ReceivedFile{
|
|
||||||
Processing: false,
|
|
||||||
FileName: file.FileName,
|
|
||||||
ChatID: linkChat.GetID(),
|
|
||||||
MessageID: messageID,
|
|
||||||
ReplyMessageID: replied.ID,
|
|
||||||
ReplyChatID: update.GetUserChat().GetID(),
|
|
||||||
}
|
|
||||||
if err := dao.SaveReceivedFile(receivedFile); err != nil {
|
|
||||||
logger.L.Errorf("保存接收的文件失败: %s", err)
|
|
||||||
ctx.EditMessage(update.EffectiveChat().GetID(), &tg.MessagesEditMessageRequest{
|
|
||||||
Message: "无法保存文件: " + err.Error(),
|
|
||||||
ID: replied.ID,
|
|
||||||
})
|
|
||||||
return dispatcher.EndGroups
|
|
||||||
}
|
|
||||||
if !user.Silent || user.DefaultStorage == "" {
|
|
||||||
return ProvideSelectMessage(ctx, update, file, linkChat.GetID(), messageID, replied.ID)
|
|
||||||
}
|
|
||||||
return HandleSilentAddTask(ctx, update, user, &types.Task{
|
|
||||||
Ctx: ctx,
|
|
||||||
Status: types.Pending,
|
|
||||||
File: file,
|
|
||||||
StorageName: user.DefaultStorage,
|
|
||||||
FileChatID: linkChat.GetID(),
|
|
||||||
FileMessageID: messageID,
|
|
||||||
ReplyMessageID: replied.ID,
|
|
||||||
ReplyChatID: update.GetUserChat().GetID(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
package bot
|
|
||||||
|
|
||||||
import (
|
|
||||||
"sync"
|
|
||||||
)
|
|
||||||
|
|
||||||
type ConversationType string
|
|
||||||
|
|
||||||
type ConversationState struct {
|
|
||||||
sync.Mutex
|
|
||||||
conversationType ConversationType
|
|
||||||
InConversation bool
|
|
||||||
data map[ConversationType]map[string]interface{}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *ConversationState) Reset() {
|
|
||||||
c.Lock()
|
|
||||||
defer c.Unlock()
|
|
||||||
c.InConversation = false
|
|
||||||
c.conversationType = ""
|
|
||||||
c.data = make(map[ConversationType]map[string]interface{})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *ConversationState) SetConversationType(t ConversationType) {
|
|
||||||
c.Lock()
|
|
||||||
defer c.Unlock()
|
|
||||||
c.conversationType = t
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *ConversationState) GetData(key string) interface{} {
|
|
||||||
if c.data == nil || c.data[c.conversationType] == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return c.data[c.conversationType][key]
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *ConversationState) SetData(key string, value interface{}) {
|
|
||||||
c.Lock()
|
|
||||||
defer c.Unlock()
|
|
||||||
if c.data == nil {
|
|
||||||
c.data = make(map[ConversationType]map[string]interface{})
|
|
||||||
}
|
|
||||||
if c.data[c.conversationType] == nil {
|
|
||||||
c.data[c.conversationType] = make(map[string]interface{})
|
|
||||||
}
|
|
||||||
c.data[c.conversationType][key] = value
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: Implement conversation handling
|
|
||||||
// var userConversationState = make(map[int64]*ConversationState)
|
|
||||||
|
|
||||||
// func handleConversation(ctx *ext.Context, update *ext.Update) error {
|
|
||||||
// userID := update.EffectiveUser().GetID()
|
|
||||||
// state, ok := userConversationState[userID]
|
|
||||||
// if !ok {
|
|
||||||
// return dispatcher.ContinueGroups
|
|
||||||
// }
|
|
||||||
// if update.EffectiveMessage.Text == "/cancel" {
|
|
||||||
// state.Reset()
|
|
||||||
// ctx.Reply(update, ext.ReplyTextString("已取消"), nil)
|
|
||||||
// return dispatcher.EndGroups
|
|
||||||
// }
|
|
||||||
// if !state.InConversation {
|
|
||||||
// return dispatcher.ContinueGroups
|
|
||||||
// }
|
|
||||||
// return handleConversationState(ctx, update, state)
|
|
||||||
// }
|
|
||||||
|
|
||||||
// func handleConversationState(ctx *ext.Context, update *ext.Update, state *ConversationState) error {
|
|
||||||
// switch state.conversationType {
|
|
||||||
// default:
|
|
||||||
// logger.L.Errorf("Unknown conversation type: %s", state.conversationType)
|
|
||||||
// }
|
|
||||||
// return dispatcher.EndGroups
|
|
||||||
// }
|
|
||||||
437
bot/handlers.go
437
bot/handlers.go
@@ -1,437 +0,0 @@
|
|||||||
package bot
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/duke-git/lancet/v2/slice"
|
|
||||||
"github.com/gotd/td/telegram/message/entity"
|
|
||||||
"github.com/gotd/td/telegram/message/styling"
|
|
||||||
"github.com/gotd/td/tg"
|
|
||||||
|
|
||||||
"github.com/celestix/gotgproto/dispatcher"
|
|
||||||
"github.com/celestix/gotgproto/dispatcher/handlers"
|
|
||||||
"github.com/celestix/gotgproto/dispatcher/handlers/filters"
|
|
||||||
"github.com/celestix/gotgproto/ext"
|
|
||||||
"github.com/krau/SaveAny-Bot/config"
|
|
||||||
"github.com/krau/SaveAny-Bot/dao"
|
|
||||||
"github.com/krau/SaveAny-Bot/logger"
|
|
||||||
"github.com/krau/SaveAny-Bot/queue"
|
|
||||||
"github.com/krau/SaveAny-Bot/storage"
|
|
||||||
"github.com/krau/SaveAny-Bot/types"
|
|
||||||
)
|
|
||||||
|
|
||||||
func RegisterHandlers(dispatcher dispatcher.Dispatcher) {
|
|
||||||
dispatcher.AddHandler(handlers.NewMessage(filters.Message.All, checkPermission))
|
|
||||||
dispatcher.AddHandler(handlers.NewCommand("start", start))
|
|
||||||
dispatcher.AddHandler(handlers.NewCommand("help", help))
|
|
||||||
dispatcher.AddHandler(handlers.NewCommand("silent", silent))
|
|
||||||
dispatcher.AddHandler(handlers.NewCommand("storage", storageCmd))
|
|
||||||
dispatcher.AddHandler(handlers.NewCommand("save", saveCmd))
|
|
||||||
linkRegexFilter, err := filters.Message.Regex(linkRegexString)
|
|
||||||
if err != nil {
|
|
||||||
logger.L.Panicf("创建正则表达式过滤器失败: %s", err)
|
|
||||||
}
|
|
||||||
dispatcher.AddHandler(handlers.NewMessage(linkRegexFilter, handleLinkMessage))
|
|
||||||
dispatcher.AddHandler(handlers.NewCallbackQuery(filters.CallbackQuery.Prefix("add"), AddToQueue))
|
|
||||||
dispatcher.AddHandler(handlers.NewCallbackQuery(filters.CallbackQuery.Prefix("set_default"), setDefaultStorage))
|
|
||||||
dispatcher.AddHandler(handlers.NewMessage(filters.Message.Media, handleFileMessage))
|
|
||||||
}
|
|
||||||
|
|
||||||
const noPermissionText string = `
|
|
||||||
您不在白名单中, 无法使用此 Bot.
|
|
||||||
您可以部署自己的实例: https://github.com/krau/SaveAny-Bot
|
|
||||||
`
|
|
||||||
|
|
||||||
func checkPermission(ctx *ext.Context, update *ext.Update) error {
|
|
||||||
userID := update.GetUserChat().GetID()
|
|
||||||
if !slice.Contain(config.Cfg.Telegram.Admins, userID) {
|
|
||||||
ctx.Reply(update, ext.ReplyTextString(noPermissionText), nil)
|
|
||||||
return dispatcher.EndGroups
|
|
||||||
}
|
|
||||||
return dispatcher.ContinueGroups
|
|
||||||
}
|
|
||||||
|
|
||||||
func start(ctx *ext.Context, update *ext.Update) error {
|
|
||||||
if err := dao.CreateUser(update.GetUserChat().GetID()); err != nil {
|
|
||||||
logger.L.Errorf("创建用户失败: %s", err)
|
|
||||||
return dispatcher.EndGroups
|
|
||||||
}
|
|
||||||
return help(ctx, update)
|
|
||||||
}
|
|
||||||
|
|
||||||
const helpText string = `
|
|
||||||
Save Any Bot - 转存你的 Telegram 文件
|
|
||||||
命令:
|
|
||||||
/start - 开始使用
|
|
||||||
/help - 显示帮助
|
|
||||||
/silent - 开关静默模式
|
|
||||||
/storage - 设置默认存储位置
|
|
||||||
/save [自定义文件名] - 保存文件
|
|
||||||
|
|
||||||
静默模式: 开启后 Bot 直接保存到收到的文件到默认位置, 不再询问
|
|
||||||
|
|
||||||
默认存储位置: 在静默模式下保存到的位置
|
|
||||||
|
|
||||||
向 Bot 发送(转发)文件, 或发送一个公开频道的消息链接以保存文件
|
|
||||||
`
|
|
||||||
|
|
||||||
func help(ctx *ext.Context, update *ext.Update) error {
|
|
||||||
ctx.Reply(update, ext.ReplyTextString(helpText), nil)
|
|
||||||
return dispatcher.EndGroups
|
|
||||||
}
|
|
||||||
|
|
||||||
func silent(ctx *ext.Context, update *ext.Update) error {
|
|
||||||
user, err := dao.GetUserByChatID(update.GetUserChat().GetID())
|
|
||||||
if err != nil {
|
|
||||||
logger.L.Errorf("获取用户失败: %s", err)
|
|
||||||
return dispatcher.EndGroups
|
|
||||||
}
|
|
||||||
user.Silent = !user.Silent
|
|
||||||
if err := dao.UpdateUser(user); err != nil {
|
|
||||||
logger.L.Errorf("更新用户失败: %s", err)
|
|
||||||
ctx.Reply(update, ext.ReplyTextString("更新用户失败"), nil)
|
|
||||||
return dispatcher.EndGroups
|
|
||||||
}
|
|
||||||
ctx.Reply(update, ext.ReplyTextString(fmt.Sprintf("已%s静默模式", map[bool]string{true: "开启", false: "关闭"}[user.Silent])), nil)
|
|
||||||
return dispatcher.EndGroups
|
|
||||||
}
|
|
||||||
|
|
||||||
func saveCmd(ctx *ext.Context, update *ext.Update) error {
|
|
||||||
res, ok := update.EffectiveMessage.GetReplyTo()
|
|
||||||
if !ok || res == nil {
|
|
||||||
ctx.Reply(update, ext.ReplyTextString("请回复要保存的文件"), nil)
|
|
||||||
return dispatcher.EndGroups
|
|
||||||
}
|
|
||||||
replyHeader, ok := res.(*tg.MessageReplyHeader)
|
|
||||||
if !ok {
|
|
||||||
ctx.Reply(update, ext.ReplyTextString("请回复要保存的文件"), nil)
|
|
||||||
return dispatcher.EndGroups
|
|
||||||
}
|
|
||||||
replyToMsgID, ok := replyHeader.GetReplyToMsgID()
|
|
||||||
if !ok {
|
|
||||||
ctx.Reply(update, ext.ReplyTextString("请回复要保存的文件"), nil)
|
|
||||||
return dispatcher.EndGroups
|
|
||||||
}
|
|
||||||
|
|
||||||
user, err := dao.GetUserByChatID(update.GetUserChat().GetID())
|
|
||||||
if err != nil {
|
|
||||||
logger.L.Errorf("获取用户失败: %s", err)
|
|
||||||
ctx.Reply(update, ext.ReplyTextString("获取用户失败"), nil)
|
|
||||||
return dispatcher.EndGroups
|
|
||||||
}
|
|
||||||
|
|
||||||
storages := storage.GetUserStorages(user.ChatID)
|
|
||||||
|
|
||||||
if len(storages) == 0 {
|
|
||||||
ctx.Reply(update, ext.ReplyTextString("无可用的存储"), nil)
|
|
||||||
return dispatcher.EndGroups
|
|
||||||
}
|
|
||||||
|
|
||||||
msg, err := GetTGMessage(ctx, update.EffectiveChat().GetID(), replyToMsgID)
|
|
||||||
if err != nil {
|
|
||||||
logger.L.Errorf("获取消息失败: %s", err)
|
|
||||||
ctx.Reply(update, ext.ReplyTextString("无法获取消息"), nil)
|
|
||||||
return dispatcher.EndGroups
|
|
||||||
}
|
|
||||||
|
|
||||||
supported, _ := supportedMediaFilter(msg)
|
|
||||||
if !supported {
|
|
||||||
ctx.Reply(update, ext.ReplyTextString("不支持的消息类型或消息中没有文件"), nil)
|
|
||||||
return dispatcher.EndGroups
|
|
||||||
}
|
|
||||||
|
|
||||||
replied, err := ctx.Reply(update, ext.ReplyTextString("正在获取文件信息..."), nil)
|
|
||||||
if err != nil {
|
|
||||||
logger.L.Errorf("回复失败: %s", err)
|
|
||||||
return dispatcher.EndGroups
|
|
||||||
}
|
|
||||||
|
|
||||||
cmdText := update.EffectiveMessage.Text
|
|
||||||
customFileName := strings.TrimSpace(strings.TrimPrefix(cmdText, "/save"))
|
|
||||||
|
|
||||||
file, err := FileFromMessage(ctx, update.EffectiveChat().GetID(), msg.ID, customFileName)
|
|
||||||
if err != nil {
|
|
||||||
logger.L.Errorf("获取文件失败: %s", err)
|
|
||||||
ctx.EditMessage(update.EffectiveChat().GetID(), &tg.MessagesEditMessageRequest{
|
|
||||||
Message: fmt.Sprintf("获取文件失败: %s", err),
|
|
||||||
ID: replied.ID,
|
|
||||||
})
|
|
||||||
return dispatcher.EndGroups
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: better file name
|
|
||||||
if file.FileName == "" {
|
|
||||||
file.FileName = fmt.Sprintf("%d_%d_%s", update.EffectiveChat().GetID(), replyToMsgID, file.Hash())
|
|
||||||
}
|
|
||||||
receivedFile := &types.ReceivedFile{
|
|
||||||
Processing: false,
|
|
||||||
FileName: file.FileName,
|
|
||||||
ChatID: update.EffectiveChat().GetID(),
|
|
||||||
MessageID: replyToMsgID,
|
|
||||||
ReplyMessageID: replied.ID,
|
|
||||||
ReplyChatID: update.GetUserChat().GetID(),
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := dao.SaveReceivedFile(receivedFile); err != nil {
|
|
||||||
logger.L.Errorf("保存接收的文件失败: %s", err)
|
|
||||||
if _, err := ctx.EditMessage(update.EffectiveChat().GetID(), &tg.MessagesEditMessageRequest{
|
|
||||||
Message: fmt.Sprintf("保存接收的文件失败: %s", err),
|
|
||||||
ID: replied.ID,
|
|
||||||
}); err != nil {
|
|
||||||
logger.L.Errorf("编辑消息失败: %s", err)
|
|
||||||
}
|
|
||||||
return dispatcher.EndGroups
|
|
||||||
}
|
|
||||||
if !user.Silent || user.DefaultStorage == "" {
|
|
||||||
return ProvideSelectMessage(ctx, update, file, update.EffectiveChat().GetID(), msg.ID, replied.ID)
|
|
||||||
}
|
|
||||||
return HandleSilentAddTask(ctx, update, user, &types.Task{
|
|
||||||
Ctx: ctx,
|
|
||||||
Status: types.Pending,
|
|
||||||
File: file,
|
|
||||||
StorageName: user.DefaultStorage,
|
|
||||||
FileChatID: update.EffectiveChat().GetID(),
|
|
||||||
ReplyMessageID: replied.ID,
|
|
||||||
ReplyChatID: update.GetUserChat().GetID(),
|
|
||||||
FileMessageID: msg.ID,
|
|
||||||
UserID: user.ChatID,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func storageCmd(ctx *ext.Context, update *ext.Update) error {
|
|
||||||
user, err := dao.GetUserByChatID(update.GetUserChat().GetID())
|
|
||||||
if err != nil {
|
|
||||||
logger.L.Errorf("获取用户失败: %s", err)
|
|
||||||
ctx.Reply(update, ext.ReplyTextString("获取用户失败"), nil)
|
|
||||||
return dispatcher.EndGroups
|
|
||||||
}
|
|
||||||
storages := storage.GetUserStorages(user.ChatID)
|
|
||||||
if len(storages) == 0 {
|
|
||||||
ctx.Reply(update, ext.ReplyTextString("无可用的存储"), nil)
|
|
||||||
return dispatcher.EndGroups
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx.Reply(update, ext.ReplyTextString("请选择要设为默认的存储位置"), &ext.ReplyOpts{
|
|
||||||
Markup: getSetDefaultStorageMarkup(user.ChatID, storages),
|
|
||||||
})
|
|
||||||
|
|
||||||
return dispatcher.EndGroups
|
|
||||||
}
|
|
||||||
|
|
||||||
func setDefaultStorage(ctx *ext.Context, update *ext.Update) error {
|
|
||||||
args := strings.Split(string(update.CallbackQuery.Data), " ")
|
|
||||||
userID, _ := strconv.Atoi(args[1])
|
|
||||||
storageNameHash := args[2]
|
|
||||||
if userID != int(update.CallbackQuery.GetUserID()) {
|
|
||||||
ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{
|
|
||||||
QueryID: update.CallbackQuery.QueryID,
|
|
||||||
Alert: true,
|
|
||||||
Message: "你没有权限",
|
|
||||||
CacheTime: 5,
|
|
||||||
})
|
|
||||||
return dispatcher.EndGroups
|
|
||||||
}
|
|
||||||
storageName := storageHashName[storageNameHash]
|
|
||||||
selectedStorage, err := storage.GetStorageByName(storageName)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
logger.L.Errorf("获取指定存储失败: %s", err)
|
|
||||||
ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{
|
|
||||||
QueryID: update.CallbackQuery.QueryID,
|
|
||||||
Alert: true,
|
|
||||||
Message: "获取指定存储失败",
|
|
||||||
CacheTime: 5,
|
|
||||||
})
|
|
||||||
return dispatcher.EndGroups
|
|
||||||
}
|
|
||||||
user, err := dao.GetUserByChatID(int64(userID))
|
|
||||||
if err != nil {
|
|
||||||
logger.L.Errorf("Failed to get user: %s", err)
|
|
||||||
ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{
|
|
||||||
QueryID: update.CallbackQuery.QueryID,
|
|
||||||
Alert: true,
|
|
||||||
Message: "获取用户失败",
|
|
||||||
CacheTime: 5,
|
|
||||||
})
|
|
||||||
return dispatcher.EndGroups
|
|
||||||
}
|
|
||||||
user.DefaultStorage = storageName
|
|
||||||
if err := dao.UpdateUser(user); err != nil {
|
|
||||||
logger.L.Errorf("Failed to update user: %s", err)
|
|
||||||
ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{
|
|
||||||
QueryID: update.CallbackQuery.QueryID,
|
|
||||||
Alert: true,
|
|
||||||
Message: "更新用户失败",
|
|
||||||
CacheTime: 5,
|
|
||||||
})
|
|
||||||
return dispatcher.EndGroups
|
|
||||||
}
|
|
||||||
ctx.EditMessage(update.EffectiveChat().GetID(), &tg.MessagesEditMessageRequest{
|
|
||||||
Message: fmt.Sprintf("已将 %s (%s) 设为默认存储位置", selectedStorage.Name(), selectedStorage.Type()),
|
|
||||||
ID: update.CallbackQuery.GetMsgID(),
|
|
||||||
})
|
|
||||||
return dispatcher.EndGroups
|
|
||||||
}
|
|
||||||
|
|
||||||
func handleFileMessage(ctx *ext.Context, update *ext.Update) error {
|
|
||||||
logger.L.Trace("Got media: ", update.EffectiveMessage.Media.TypeName())
|
|
||||||
supported, err := supportedMediaFilter(update.EffectiveMessage.Message)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if !supported {
|
|
||||||
return dispatcher.EndGroups
|
|
||||||
}
|
|
||||||
|
|
||||||
user, err := dao.GetUserByChatID(update.GetUserChat().GetID())
|
|
||||||
if err != nil {
|
|
||||||
logger.L.Errorf("获取用户失败: %s", err)
|
|
||||||
ctx.Reply(update, ext.ReplyTextString("获取用户失败"), nil)
|
|
||||||
return dispatcher.EndGroups
|
|
||||||
}
|
|
||||||
storages := storage.GetUserStorages(user.ChatID)
|
|
||||||
if len(storages) == 0 {
|
|
||||||
ctx.Reply(update, ext.ReplyTextString("无可用的存储"), nil)
|
|
||||||
return dispatcher.EndGroups
|
|
||||||
}
|
|
||||||
|
|
||||||
msg, err := ctx.Reply(update, ext.ReplyTextString("正在获取文件信息..."), nil)
|
|
||||||
if err != nil {
|
|
||||||
logger.L.Errorf("回复失败: %s", err)
|
|
||||||
return dispatcher.EndGroups
|
|
||||||
}
|
|
||||||
media := update.EffectiveMessage.Media
|
|
||||||
file, err := FileFromMedia(media, "")
|
|
||||||
if err != nil {
|
|
||||||
logger.L.Errorf("获取文件失败: %s", err)
|
|
||||||
ctx.Reply(update, ext.ReplyTextString(fmt.Sprintf("获取文件失败: %s", err)), nil)
|
|
||||||
return dispatcher.EndGroups
|
|
||||||
}
|
|
||||||
if file.FileName == "" {
|
|
||||||
file.FileName = fmt.Sprintf("%d_%d_%s", update.EffectiveChat().GetID(), update.EffectiveMessage.ID, file.Hash())
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := dao.SaveReceivedFile(&types.ReceivedFile{
|
|
||||||
Processing: false,
|
|
||||||
FileName: file.FileName,
|
|
||||||
ChatID: update.EffectiveChat().GetID(),
|
|
||||||
MessageID: update.EffectiveMessage.ID,
|
|
||||||
ReplyMessageID: msg.ID,
|
|
||||||
ReplyChatID: update.GetUserChat().GetID(),
|
|
||||||
}); err != nil {
|
|
||||||
logger.L.Errorf("添加接收的文件失败: %s", err)
|
|
||||||
if _, err := ctx.EditMessage(update.EffectiveChat().GetID(), &tg.MessagesEditMessageRequest{
|
|
||||||
Message: fmt.Sprintf("添加接收的文件失败: %s", err),
|
|
||||||
ID: msg.ID,
|
|
||||||
}); err != nil {
|
|
||||||
logger.L.Errorf("编辑消息失败: %s", err)
|
|
||||||
}
|
|
||||||
return dispatcher.EndGroups
|
|
||||||
}
|
|
||||||
|
|
||||||
if !user.Silent || user.DefaultStorage == "" {
|
|
||||||
return ProvideSelectMessage(ctx, update, file, update.EffectiveChat().GetID(), update.EffectiveMessage.ID, msg.ID)
|
|
||||||
}
|
|
||||||
return HandleSilentAddTask(ctx, update, user, &types.Task{
|
|
||||||
Ctx: ctx,
|
|
||||||
Status: types.Pending,
|
|
||||||
File: file,
|
|
||||||
StorageName: user.DefaultStorage,
|
|
||||||
FileChatID: update.EffectiveChat().GetID(),
|
|
||||||
ReplyMessageID: msg.ID,
|
|
||||||
ReplyChatID: update.GetUserChat().GetID(),
|
|
||||||
FileMessageID: update.EffectiveMessage.ID,
|
|
||||||
UserID: user.ChatID,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func AddToQueue(ctx *ext.Context, update *ext.Update) error {
|
|
||||||
if !slice.Contain(config.Cfg.GetUsersID(), update.CallbackQuery.UserID) {
|
|
||||||
ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{
|
|
||||||
QueryID: update.CallbackQuery.QueryID,
|
|
||||||
Alert: true,
|
|
||||||
Message: "你没有权限",
|
|
||||||
CacheTime: 5,
|
|
||||||
})
|
|
||||||
return dispatcher.EndGroups
|
|
||||||
}
|
|
||||||
args := strings.Split(string(update.CallbackQuery.Data), " ")
|
|
||||||
fileChatID, _ := strconv.Atoi(args[1])
|
|
||||||
fileMessageID, _ := strconv.Atoi(args[2])
|
|
||||||
storageNameHash := args[3]
|
|
||||||
storageName := storageHashName[storageNameHash]
|
|
||||||
if storageName == "" {
|
|
||||||
logger.L.Errorf("未知存储位置哈希: %d", storageNameHash)
|
|
||||||
ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{
|
|
||||||
QueryID: update.CallbackQuery.QueryID,
|
|
||||||
Alert: true,
|
|
||||||
Message: "未知存储位置",
|
|
||||||
CacheTime: 5,
|
|
||||||
})
|
|
||||||
return dispatcher.EndGroups
|
|
||||||
}
|
|
||||||
logger.L.Tracef("Got add to queue: chatID: %d, messageID: %d, storage: %s", fileChatID, fileMessageID, storageName)
|
|
||||||
record, err := dao.GetReceivedFileByChatAndMessageID(int64(fileChatID), fileMessageID)
|
|
||||||
if err != nil {
|
|
||||||
logger.L.Errorf("获取记录失败: %s", err)
|
|
||||||
ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{
|
|
||||||
QueryID: update.CallbackQuery.QueryID,
|
|
||||||
Alert: true,
|
|
||||||
Message: "查询记录失败",
|
|
||||||
CacheTime: 5,
|
|
||||||
})
|
|
||||||
return dispatcher.EndGroups
|
|
||||||
}
|
|
||||||
if update.CallbackQuery.MsgID != record.ReplyMessageID {
|
|
||||||
record.ReplyMessageID = update.CallbackQuery.MsgID
|
|
||||||
if err := dao.SaveReceivedFile(record); err != nil {
|
|
||||||
logger.L.Errorf("更新接收的文件失败: %s", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
file, err := FileFromMessage(ctx, record.ChatID, record.MessageID, record.FileName)
|
|
||||||
if err != nil {
|
|
||||||
logger.L.Errorf("获取消息中的文件失败: %s", err)
|
|
||||||
ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{
|
|
||||||
QueryID: update.CallbackQuery.QueryID,
|
|
||||||
Alert: true,
|
|
||||||
Message: fmt.Sprintf("获取消息中的文件失败: %s", err),
|
|
||||||
CacheTime: 5,
|
|
||||||
})
|
|
||||||
return dispatcher.EndGroups
|
|
||||||
}
|
|
||||||
|
|
||||||
queue.AddTask(types.Task{
|
|
||||||
Ctx: ctx,
|
|
||||||
Status: types.Pending,
|
|
||||||
File: file,
|
|
||||||
StorageName: storageName,
|
|
||||||
FileChatID: record.ChatID,
|
|
||||||
ReplyMessageID: record.ReplyMessageID,
|
|
||||||
FileMessageID: record.MessageID,
|
|
||||||
ReplyChatID: record.ReplyChatID,
|
|
||||||
UserID: update.EffectiveUser().GetID(),
|
|
||||||
})
|
|
||||||
|
|
||||||
entityBuilder := entity.Builder{}
|
|
||||||
var entities []tg.MessageEntityClass
|
|
||||||
text := fmt.Sprintf("已添加到任务队列\n文件名: %s\n当前排队任务数: %d", record.FileName, queue.Len())
|
|
||||||
if err := styling.Perform(&entityBuilder,
|
|
||||||
styling.Plain("已添加到任务队列\n文件名: "),
|
|
||||||
styling.Code(record.FileName),
|
|
||||||
styling.Plain("\n当前排队任务数: "),
|
|
||||||
styling.Bold(strconv.Itoa(queue.Len())),
|
|
||||||
); err != nil {
|
|
||||||
logger.L.Errorf("Failed to build entity: %s", err)
|
|
||||||
} else {
|
|
||||||
text, entities = entityBuilder.Complete()
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx.EditMessage(update.EffectiveChat().GetID(), &tg.MessagesEditMessageRequest{
|
|
||||||
Message: text,
|
|
||||||
Entities: entities,
|
|
||||||
ID: record.ReplyMessageID,
|
|
||||||
})
|
|
||||||
return dispatcher.EndGroups
|
|
||||||
}
|
|
||||||
244
bot/utils.go
244
bot/utils.go
@@ -1,244 +0,0 @@
|
|||||||
package bot
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/celestix/gotgproto/dispatcher"
|
|
||||||
"github.com/celestix/gotgproto/ext"
|
|
||||||
"github.com/gotd/td/telegram/message/entity"
|
|
||||||
"github.com/gotd/td/telegram/message/styling"
|
|
||||||
"github.com/gotd/td/tg"
|
|
||||||
"github.com/krau/SaveAny-Bot/common"
|
|
||||||
"github.com/krau/SaveAny-Bot/dao"
|
|
||||||
"github.com/krau/SaveAny-Bot/logger"
|
|
||||||
"github.com/krau/SaveAny-Bot/queue"
|
|
||||||
"github.com/krau/SaveAny-Bot/storage"
|
|
||||||
"github.com/krau/SaveAny-Bot/types"
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
ErrEmptyDocument = errors.New("document is empty")
|
|
||||||
ErrEmptyPhoto = errors.New("photo is empty")
|
|
||||||
ErrEmptyPhotoSize = errors.New("photo size is empty")
|
|
||||||
ErrEmptyPhotoSizes = errors.New("photo size slice is empty")
|
|
||||||
ErrNoStorages = errors.New("no available storage")
|
|
||||||
)
|
|
||||||
|
|
||||||
func supportedMediaFilter(m *tg.Message) (bool, error) {
|
|
||||||
if not := m.Media == nil; not {
|
|
||||||
return false, dispatcher.EndGroups
|
|
||||||
}
|
|
||||||
switch m.Media.(type) {
|
|
||||||
case *tg.MessageMediaDocument:
|
|
||||||
return true, nil
|
|
||||||
case *tg.MessageMediaPhoto:
|
|
||||||
return true, nil
|
|
||||||
default:
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// for callback data
|
|
||||||
var storageHashName = map[string]string{}
|
|
||||||
|
|
||||||
func getSelectStorageMarkup(userChatID int64, fileChatID, fileMessageID int) (*tg.ReplyInlineMarkup, error) {
|
|
||||||
user, err := dao.GetUserByChatID(userChatID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
storages := storage.GetUserStorages(user.ChatID)
|
|
||||||
|
|
||||||
buttons := make([]tg.KeyboardButtonClass, 0)
|
|
||||||
for _, storage := range storages {
|
|
||||||
nameHash := common.HashString(storage.Name())
|
|
||||||
storageHashName[nameHash] = storage.Name()
|
|
||||||
buttons = append(buttons, &tg.KeyboardButtonCallback{
|
|
||||||
Text: storage.Name(),
|
|
||||||
Data: []byte(fmt.Sprintf("add %d %d %s", fileChatID, fileMessageID, nameHash)),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
markup := &tg.ReplyInlineMarkup{}
|
|
||||||
for i := 0; i < len(buttons); i += 3 {
|
|
||||||
row := tg.KeyboardButtonRow{}
|
|
||||||
row.Buttons = buttons[i:min(i+3, len(buttons))]
|
|
||||||
markup.Rows = append(markup.Rows, row)
|
|
||||||
}
|
|
||||||
return markup, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func getSetDefaultStorageMarkup(userChatID int64, storages []storage.Storage) *tg.ReplyInlineMarkup {
|
|
||||||
buttons := make([]tg.KeyboardButtonClass, 0)
|
|
||||||
for _, storage := range storages {
|
|
||||||
nameHash := common.HashString(storage.Name())
|
|
||||||
storageHashName[nameHash] = storage.Name()
|
|
||||||
buttons = append(buttons, &tg.KeyboardButtonCallback{
|
|
||||||
Text: storage.Name(),
|
|
||||||
Data: []byte(fmt.Sprintf("set_default %d %s", userChatID, nameHash)),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
markup := &tg.ReplyInlineMarkup{}
|
|
||||||
for i := 0; i < len(buttons); i += 3 {
|
|
||||||
row := tg.KeyboardButtonRow{}
|
|
||||||
row.Buttons = buttons[i:min(i+3, len(buttons))]
|
|
||||||
markup.Rows = append(markup.Rows, row)
|
|
||||||
}
|
|
||||||
return markup
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
func FileFromMedia(media tg.MessageMediaClass, customFileName string) (*types.File, error) {
|
|
||||||
switch media := media.(type) {
|
|
||||||
case *tg.MessageMediaDocument:
|
|
||||||
document, ok := media.Document.AsNotEmpty()
|
|
||||||
if !ok {
|
|
||||||
return nil, ErrEmptyDocument
|
|
||||||
}
|
|
||||||
if customFileName != "" {
|
|
||||||
return &types.File{
|
|
||||||
Location: document.AsInputDocumentFileLocation(),
|
|
||||||
FileSize: document.Size,
|
|
||||||
FileName: customFileName,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
fileName := ""
|
|
||||||
for _, attribute := range document.Attributes {
|
|
||||||
if name, ok := attribute.(*tg.DocumentAttributeFilename); ok {
|
|
||||||
fileName = name.GetFileName()
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return &types.File{
|
|
||||||
Location: document.AsInputDocumentFileLocation(),
|
|
||||||
FileSize: document.Size,
|
|
||||||
FileName: fileName,
|
|
||||||
}, nil
|
|
||||||
case *tg.MessageMediaPhoto:
|
|
||||||
photo, ok := media.Photo.AsNotEmpty()
|
|
||||||
if !ok {
|
|
||||||
return nil, ErrEmptyPhoto
|
|
||||||
}
|
|
||||||
sizes := photo.Sizes
|
|
||||||
if len(sizes) == 0 {
|
|
||||||
return nil, ErrEmptyPhotoSizes
|
|
||||||
}
|
|
||||||
photoSize := sizes[len(sizes)-1]
|
|
||||||
size, ok := photoSize.AsNotEmpty()
|
|
||||||
if !ok {
|
|
||||||
return nil, ErrEmptyPhotoSize
|
|
||||||
}
|
|
||||||
location := new(tg.InputPhotoFileLocation)
|
|
||||||
location.ID = photo.GetID()
|
|
||||||
location.AccessHash = photo.GetAccessHash()
|
|
||||||
location.FileReference = photo.GetFileReference()
|
|
||||||
location.ThumbSize = size.GetType()
|
|
||||||
fileName := customFileName
|
|
||||||
if fileName == "" {
|
|
||||||
fileName = fmt.Sprintf("photo_%s_%d.jpg", time.Now().Format("2006-01-02_15-04-05"), photo.GetID())
|
|
||||||
}
|
|
||||||
return &types.File{
|
|
||||||
Location: location,
|
|
||||||
FileSize: 0,
|
|
||||||
FileName: fileName,
|
|
||||||
}, nil
|
|
||||||
|
|
||||||
}
|
|
||||||
return nil, fmt.Errorf("unexpected type %T", media)
|
|
||||||
}
|
|
||||||
|
|
||||||
func FileFromMessage(ctx *ext.Context, chatID int64, messageID int, customFileName string) (*types.File, error) {
|
|
||||||
key := fmt.Sprintf("file:%d:%d", chatID, messageID)
|
|
||||||
logger.L.Debugf("Getting file: %s", key)
|
|
||||||
var cachedFile types.File
|
|
||||||
err := common.Cache.Get(key, &cachedFile)
|
|
||||||
if err == nil {
|
|
||||||
return &cachedFile, nil
|
|
||||||
}
|
|
||||||
message, err := GetTGMessage(ctx, chatID, messageID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
file, err := FileFromMedia(message.Media, customFileName)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if err := common.Cache.Set(key, file, 3600); err != nil {
|
|
||||||
logger.L.Errorf("Failed to cache file: %s", err)
|
|
||||||
}
|
|
||||||
return file, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func GetTGMessage(ctx *ext.Context, chatId int64, messageID int) (*tg.Message, error) {
|
|
||||||
logger.L.Debugf("Fetching message: %d", messageID)
|
|
||||||
messages, err := ctx.GetMessages(chatId, []tg.InputMessageClass{&tg.InputMessageID{ID: messageID}})
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if len(messages) == 0 {
|
|
||||||
return nil, errors.New("no messages found")
|
|
||||||
}
|
|
||||||
msg := messages[0]
|
|
||||||
tgMessage, ok := msg.(*tg.Message)
|
|
||||||
if !ok {
|
|
||||||
return nil, fmt.Errorf("unexpected message type: %T", msg)
|
|
||||||
}
|
|
||||||
return tgMessage, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func ProvideSelectMessage(ctx *ext.Context, update *ext.Update, file *types.File, chatID int64, fileMsgID, toEditMsgID int) error {
|
|
||||||
entityBuilder := entity.Builder{}
|
|
||||||
var entities []tg.MessageEntityClass
|
|
||||||
text := fmt.Sprintf("文件名: %s\n请选择存储位置", file.FileName)
|
|
||||||
if err := styling.Perform(&entityBuilder,
|
|
||||||
styling.Plain("文件名: "),
|
|
||||||
styling.Code(file.FileName),
|
|
||||||
styling.Plain("\n请选择存储位置"),
|
|
||||||
); err != nil {
|
|
||||||
logger.L.Errorf("Failed to build entity: %s", err)
|
|
||||||
} else {
|
|
||||||
text, entities = entityBuilder.Complete()
|
|
||||||
}
|
|
||||||
markup, err := getSelectStorageMarkup(update.EffectiveUser().GetID(), int(chatID), fileMsgID)
|
|
||||||
if errors.Is(err, ErrNoStorages) {
|
|
||||||
logger.L.Errorf("Failed to get select storage markup: %s", err)
|
|
||||||
ctx.EditMessage(update.EffectiveChat().GetID(), &tg.MessagesEditMessageRequest{
|
|
||||||
Message: "无可用存储",
|
|
||||||
ID: toEditMsgID,
|
|
||||||
})
|
|
||||||
return dispatcher.EndGroups
|
|
||||||
} else if err != nil {
|
|
||||||
logger.L.Errorf("Failed to get select storage markup: %s", err)
|
|
||||||
ctx.EditMessage(update.EffectiveChat().GetID(), &tg.MessagesEditMessageRequest{
|
|
||||||
Message: "无法获取存储",
|
|
||||||
ID: toEditMsgID,
|
|
||||||
})
|
|
||||||
return dispatcher.EndGroups
|
|
||||||
}
|
|
||||||
_, err = ctx.EditMessage(update.EffectiveChat().GetID(), &tg.MessagesEditMessageRequest{
|
|
||||||
Message: text,
|
|
||||||
Entities: entities,
|
|
||||||
ReplyMarkup: markup,
|
|
||||||
ID: toEditMsgID,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
logger.L.Errorf("Failed to reply: %s", err)
|
|
||||||
}
|
|
||||||
return dispatcher.EndGroups
|
|
||||||
}
|
|
||||||
|
|
||||||
func HandleSilentAddTask(ctx *ext.Context, update *ext.Update, user *types.User, task *types.Task) error {
|
|
||||||
if user.DefaultStorage == "" {
|
|
||||||
ctx.EditMessage(update.EffectiveChat().GetID(), &tg.MessagesEditMessageRequest{
|
|
||||||
Message: "请先使用 /storage 设置默认存储位置",
|
|
||||||
ID: task.ReplyMessageID,
|
|
||||||
})
|
|
||||||
return dispatcher.EndGroups
|
|
||||||
}
|
|
||||||
queue.AddTask(*task)
|
|
||||||
ctx.EditMessage(update.EffectiveChat().GetID(), &tg.MessagesEditMessageRequest{
|
|
||||||
Message: fmt.Sprintf("已添加到队列: %s\n当前排队任务数: %d", task.FileName(), queue.Len()),
|
|
||||||
ID: task.ReplyMessageID,
|
|
||||||
})
|
|
||||||
return dispatcher.EndGroups
|
|
||||||
}
|
|
||||||
102
client/bot/bot.go
Normal file
102
client/bot/bot.go
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
package bot
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/celestix/gotgproto"
|
||||||
|
"github.com/celestix/gotgproto/dispatcher"
|
||||||
|
"github.com/celestix/gotgproto/ext"
|
||||||
|
"github.com/celestix/gotgproto/sessionMaker"
|
||||||
|
"github.com/charmbracelet/log"
|
||||||
|
"github.com/gotd/td/tg"
|
||||||
|
"github.com/krau/SaveAny-Bot/client/bot/handlers"
|
||||||
|
"github.com/krau/SaveAny-Bot/client/middleware"
|
||||||
|
"github.com/krau/SaveAny-Bot/common/i18n"
|
||||||
|
"github.com/krau/SaveAny-Bot/common/utils/tgutil"
|
||||||
|
"github.com/krau/SaveAny-Bot/config"
|
||||||
|
"github.com/krau/SaveAny-Bot/database"
|
||||||
|
)
|
||||||
|
|
||||||
|
var ectx *ext.Context
|
||||||
|
|
||||||
|
func ExtContext() *ext.Context {
|
||||||
|
return ectx
|
||||||
|
}
|
||||||
|
|
||||||
|
func Init(ctx context.Context) <-chan struct{} {
|
||||||
|
log.FromContext(ctx).Info("Initializing Bot...")
|
||||||
|
resultChan := make(chan struct {
|
||||||
|
client *gotgproto.Client
|
||||||
|
err error
|
||||||
|
})
|
||||||
|
shouldRestart := make(chan struct{})
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
resolver, err := tgutil.NewConfigProxyResolver()
|
||||||
|
if err != nil {
|
||||||
|
resultChan <- struct {
|
||||||
|
client *gotgproto.Client
|
||||||
|
err error
|
||||||
|
}{nil, err}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
client, err := gotgproto.NewClient(
|
||||||
|
config.C().Telegram.AppID,
|
||||||
|
config.C().Telegram.AppHash,
|
||||||
|
gotgproto.ClientTypeBot(config.C().Telegram.Token),
|
||||||
|
&gotgproto.ClientOpts{
|
||||||
|
Session: sessionMaker.SqlSession(database.GetDialect(config.C().DB.Session)),
|
||||||
|
DisableCopyright: true,
|
||||||
|
Middlewares: middleware.NewDefaultMiddlewares(ctx, 5*time.Minute),
|
||||||
|
Resolver: resolver,
|
||||||
|
Context: ctx,
|
||||||
|
MaxRetries: config.C().Telegram.RpcRetry,
|
||||||
|
AutoFetchReply: true,
|
||||||
|
ErrorHandler: func(ctx *ext.Context, u *ext.Update, s string) error {
|
||||||
|
if s == "SAVEANTBOT-RESTART" {
|
||||||
|
shouldRestart <- struct{}{}
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
log.FromContext(ctx).Errorf("unhandled error: %s", s)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
resultChan <- struct {
|
||||||
|
client *gotgproto.Client
|
||||||
|
err error
|
||||||
|
}{nil, err}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
client.API().BotsSetBotCommands(ctx, &tg.BotsSetBotCommandsRequest{
|
||||||
|
Scope: &tg.BotCommandScopeDefault{},
|
||||||
|
})
|
||||||
|
commands := make([]tg.BotCommand, 0, len(handlers.CommandHandlers))
|
||||||
|
for _, info := range handlers.CommandHandlers {
|
||||||
|
commands = append(commands, tg.BotCommand{Command: info.Cmd, Description: i18n.T(info.Desc)})
|
||||||
|
}
|
||||||
|
_, err = client.API().BotsSetBotCommands(ctx, &tg.BotsSetBotCommandsRequest{
|
||||||
|
Scope: &tg.BotCommandScopeDefault{},
|
||||||
|
Commands: commands,
|
||||||
|
})
|
||||||
|
resultChan <- struct {
|
||||||
|
client *gotgproto.Client
|
||||||
|
err error
|
||||||
|
}{client, err}
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
log.FromContext(ctx).Errorf("Bot initialization cancelled: %s", ctx.Err())
|
||||||
|
case result := <-resultChan:
|
||||||
|
if result.err != nil {
|
||||||
|
log.FromContext(ctx).Fatalf("Failed to initialize Bot: %s", result.err)
|
||||||
|
}
|
||||||
|
handlers.Register(result.client.Dispatcher)
|
||||||
|
ectx = result.client.CreateContext()
|
||||||
|
log.FromContext(ctx).Info("Bot initialization completed.")
|
||||||
|
}
|
||||||
|
return shouldRestart
|
||||||
|
}
|
||||||
108
client/bot/handlers/add_task.go
Normal file
108
client/bot/handlers/add_task.go
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"path"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/celestix/gotgproto/dispatcher"
|
||||||
|
"github.com/celestix/gotgproto/ext"
|
||||||
|
"github.com/charmbracelet/log"
|
||||||
|
"github.com/gotd/td/tg"
|
||||||
|
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/msgelem"
|
||||||
|
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/shortcut"
|
||||||
|
"github.com/krau/SaveAny-Bot/common/i18n"
|
||||||
|
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
|
||||||
|
"github.com/krau/SaveAny-Bot/common/utils/fsutil"
|
||||||
|
"github.com/krau/SaveAny-Bot/database"
|
||||||
|
"github.com/krau/SaveAny-Bot/pkg/enums/tasktype"
|
||||||
|
"github.com/krau/SaveAny-Bot/pkg/tcbdata"
|
||||||
|
"github.com/krau/SaveAny-Bot/storage"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
func handleAddCallback(ctx *ext.Context, update *ext.Update) error {
|
||||||
|
dataid := strings.Split(string(update.CallbackQuery.Data), " ")[1]
|
||||||
|
data, err := shortcut.GetCallbackDataWithAnswer[tcbdata.Add](ctx, update, dataid)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
queryID := update.CallbackQuery.GetQueryID()
|
||||||
|
msgID := update.CallbackQuery.GetMsgID()
|
||||||
|
userID := update.CallbackQuery.GetUserID()
|
||||||
|
|
||||||
|
selectedStorage, err := storage.GetStorageByUserIDAndName(ctx, userID, data.SelectedStorName)
|
||||||
|
if err != nil {
|
||||||
|
log.FromContext(ctx).Errorf("Failed to get storage: %s", err)
|
||||||
|
ctx.AnswerCallback(msgelem.AlertCallbackAnswer(queryID, i18n.T(i18nk.BotMsgCommonErrorGetStorageFailed, map[string]any{
|
||||||
|
"Error": err.Error(),
|
||||||
|
})))
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
dirs, err := database.GetDirsByUserChatIDAndStorageName(ctx, userID, data.SelectedStorName)
|
||||||
|
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return fmt.Errorf("failed to get user directories: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !data.SettedDir && len(dirs) != 0 {
|
||||||
|
// ask for directory selection
|
||||||
|
markup, err := msgelem.BuildSetDirMarkupForAdd(dirs, dataid)
|
||||||
|
if err != nil {
|
||||||
|
log.FromContext(ctx).Errorf("Failed to build directory keyboard: %s", err)
|
||||||
|
ctx.AnswerCallback(msgelem.AlertCallbackAnswer(queryID, i18n.T(i18nk.BotMsgCommonErrorBuildStorageSelectKeyboardFailed, map[string]any{
|
||||||
|
"Error": err.Error(),
|
||||||
|
})))
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
|
||||||
|
ID: update.CallbackQuery.GetMsgID(),
|
||||||
|
Message: i18n.T(i18nk.BotMsgCommonPromptSelectDir, nil),
|
||||||
|
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, i18n.T(i18nk.BotMsgCommonErrorGetDirFailed, map[string]any{
|
||||||
|
"Error": err.Error(),
|
||||||
|
})))
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
dirPath = dir.Path
|
||||||
|
}
|
||||||
|
|
||||||
|
switch data.TaskType {
|
||||||
|
case tasktype.TaskTypeTgfiles:
|
||||||
|
if data.AsBatch {
|
||||||
|
return shortcut.CreateAndAddBatchTGFileTaskWithEdit(ctx, userID, selectedStorage, dirPath, data.Files, msgID)
|
||||||
|
}
|
||||||
|
return shortcut.CreateAndAddTGFileTaskWithEdit(ctx, userID, selectedStorage, dirPath, data.Files[0], msgID)
|
||||||
|
case tasktype.TaskTypeTphpics:
|
||||||
|
return shortcut.CreateAndAddtelegraphWithEdit(ctx, userID, data.TphPageNode, data.TphDirPath, data.TphPics, selectedStorage, msgID)
|
||||||
|
case tasktype.TaskTypeParseditem:
|
||||||
|
if len(data.ParsedItem.Resources) > 1 {
|
||||||
|
dirPath = path.Join(dirPath, fsutil.NormalizePathname(data.ParsedItem.Title))
|
||||||
|
}
|
||||||
|
shortcut.CreateAndAddParsedTaskWithEdit(ctx, selectedStorage, dirPath, data.ParsedItem, msgID, userID)
|
||||||
|
case tasktype.TaskTypeDirectlinks:
|
||||||
|
shortcut.CreateAndAddDirectTaskWithEdit(ctx, selectedStorage, dirPath, data.DirectLinks, msgID, userID)
|
||||||
|
case tasktype.TaskTypeAria2:
|
||||||
|
client := GetAria2Client()
|
||||||
|
if client == nil {
|
||||||
|
ctx.AnswerCallback(msgelem.AlertCallbackAnswer(queryID, i18n.T(i18nk.BotMsgAria2ErrorAria2ClientInitFailed, map[string]any{
|
||||||
|
"Error": "aria2 client not initialized",
|
||||||
|
})))
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
shortcut.CreateAndAddAria2TaskWithEdit(ctx, selectedStorage, dirPath, data.Aria2URIs, client, msgID, userID)
|
||||||
|
case tasktype.TaskTypeYtdlp:
|
||||||
|
shortcut.CreateAndAddYtdlpTaskWithEdit(ctx, selectedStorage, dirPath, data.YtdlpURLs, data.YtdlpFlags, msgID, userID)
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("unexcept task type: %s", data.TaskType)
|
||||||
|
}
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
53
client/bot/handlers/cancel_task.go
Normal file
53
client/bot/handlers/cancel_task.go
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
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/common/i18n"
|
||||||
|
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
|
||||||
|
"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("Failed to cancel task %s: %v", taskid, err)
|
||||||
|
ctx.AnswerCallback(msgelem.AlertCallbackAnswer(update.CallbackQuery.GetQueryID(), i18n.T(i18nk.BotMsgCancelErrorCancelFailed, map[string]any{
|
||||||
|
"Error": err.Error(),
|
||||||
|
})))
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.EditMessage(update.CallbackQuery.GetUserID(), &tg.MessagesEditMessageRequest{
|
||||||
|
ID: update.CallbackQuery.GetMsgID(),
|
||||||
|
Message: i18n.T(i18nk.BotMsgCancelInfoCancellingTask, nil),
|
||||||
|
})
|
||||||
|
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleCancelCmd(ctx *ext.Context, update *ext.Update) error {
|
||||||
|
logger := log.FromContext(ctx)
|
||||||
|
args := strings.Fields(update.EffectiveMessage.Text)
|
||||||
|
if len(args) < 2 {
|
||||||
|
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgCancelUsage, nil)), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
taskID := args[1]
|
||||||
|
if err := core.CancelTask(ctx, taskID); err != nil {
|
||||||
|
logger.Errorf("failed to cancel task %s: %v", taskID, err)
|
||||||
|
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgCancelErrorCancelFailed, map[string]any{
|
||||||
|
"Error": err.Error(),
|
||||||
|
})), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgCancelInfoCancelRequested, map[string]any{
|
||||||
|
"TaskID": taskID,
|
||||||
|
})), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
144
client/bot/handlers/config.go
Normal file
144
client/bot/handlers/config.go
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"text/template"
|
||||||
|
|
||||||
|
"github.com/celestix/gotgproto/dispatcher"
|
||||||
|
"github.com/celestix/gotgproto/ext"
|
||||||
|
"github.com/gotd/td/tg"
|
||||||
|
"github.com/krau/SaveAny-Bot/common/i18n"
|
||||||
|
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
|
||||||
|
"github.com/krau/SaveAny-Bot/config"
|
||||||
|
"github.com/krau/SaveAny-Bot/database"
|
||||||
|
"github.com/krau/SaveAny-Bot/pkg/enums/fnamest"
|
||||||
|
"github.com/krau/SaveAny-Bot/pkg/tcbdata"
|
||||||
|
)
|
||||||
|
|
||||||
|
func handleConfigCmd(ctx *ext.Context, update *ext.Update) error {
|
||||||
|
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgConfigPromptSelectOption)), &ext.ReplyOpts{
|
||||||
|
Markup: &tg.ReplyInlineMarkup{
|
||||||
|
Rows: []tg.KeyboardButtonRow{
|
||||||
|
{
|
||||||
|
Buttons: []tg.KeyboardButtonClass{
|
||||||
|
&tg.KeyboardButtonCallback{
|
||||||
|
Text: i18n.T(i18nk.BotMsgConfigButtonFilenameStrategy),
|
||||||
|
Data: fmt.Appendf(nil, "%s %s", tcbdata.TypeConfig, "fnamest"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleConfigCallback(ctx *ext.Context, update *ext.Update) error {
|
||||||
|
args := strings.Fields(string(update.CallbackQuery.Data))
|
||||||
|
invaildDataAnswer := func() error {
|
||||||
|
ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{
|
||||||
|
QueryID: update.CallbackQuery.GetQueryID(),
|
||||||
|
Alert: true,
|
||||||
|
Message: i18n.T(i18nk.BotMsgConfigErrorInvalidCallbackData),
|
||||||
|
CacheTime: 5,
|
||||||
|
})
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
if len(args) < 2 {
|
||||||
|
return invaildDataAnswer()
|
||||||
|
}
|
||||||
|
switch args[1] {
|
||||||
|
case "fnamest":
|
||||||
|
return handleConfigFnameSTCallback(ctx, update)
|
||||||
|
default:
|
||||||
|
return invaildDataAnswer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleConfigFnameSTCallback(ctx *ext.Context, update *ext.Update) error {
|
||||||
|
userID := update.CallbackQuery.GetUserID()
|
||||||
|
user, err := database.GetUserByChatID(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
args := strings.Fields(string(update.CallbackQuery.Data))
|
||||||
|
if len(args) == 3 {
|
||||||
|
selected := args[2]
|
||||||
|
st, err := fnamest.ParseFnameST(selected)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
user.FilenameStrategy = st.String()
|
||||||
|
if err := database.UpdateUser(ctx, user); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
|
||||||
|
ID: update.CallbackQuery.GetMsgID(),
|
||||||
|
Message: i18n.T(i18nk.BotMsgConfigInfoFilenameStrategySet, map[string]any{
|
||||||
|
"Strategy": fnamest.GetDisplay(st, config.C().Lang),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
opts := fnamest.FnameSTValues()
|
||||||
|
buttons := make([]tg.KeyboardButtonClass, 0, len(opts))
|
||||||
|
for _, opt := range opts {
|
||||||
|
buttons = append(buttons, &tg.KeyboardButtonCallback{
|
||||||
|
Text: fnamest.GetDisplay(opt, config.C().Lang),
|
||||||
|
Data: fmt.Appendf(nil, "%s %s %s", tcbdata.TypeConfig, "fnamest", opt),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
markup := &tg.ReplyInlineMarkup{Rows: []tg.KeyboardButtonRow{
|
||||||
|
{Buttons: buttons},
|
||||||
|
}}
|
||||||
|
currentStStr := user.FilenameStrategy
|
||||||
|
if currentStStr == "" {
|
||||||
|
currentStStr = fnamest.Default.String()
|
||||||
|
}
|
||||||
|
currentSt, err := fnamest.ParseFnameST(currentStStr)
|
||||||
|
if err != nil {
|
||||||
|
currentSt = fnamest.Default
|
||||||
|
}
|
||||||
|
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
|
||||||
|
ID: update.CallbackQuery.GetMsgID(),
|
||||||
|
Message: i18n.T(i18nk.BotMsgConfigPromptSelectFilenameStrategy, map[string]any{
|
||||||
|
"Strategy": fnamest.GetDisplay(currentSt, config.C().Lang),
|
||||||
|
}),
|
||||||
|
ReplyMarkup: markup,
|
||||||
|
})
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleConfigFnameTmpl(ctx *ext.Context, update *ext.Update) error {
|
||||||
|
userID := update.GetUserChat().GetID()
|
||||||
|
user, err := database.GetUserByChatID(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
args := strings.Fields(string(update.EffectiveMessage.Text))
|
||||||
|
if len(args) <= 1 {
|
||||||
|
text := i18n.T(i18nk.BotMsgConfigFnametmplHelp, nil)
|
||||||
|
if user.FilenameTemplate != "" {
|
||||||
|
text += "\n\n" + i18n.T(i18nk.BotMsgConfigInfoCurrentTemplatePrefix, map[string]any{
|
||||||
|
"Template": user.FilenameTemplate,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
ctx.Reply(update, ext.ReplyTextString(text), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
newTmpl := strings.Join(args[1:], " ")
|
||||||
|
_, err = template.New("filename").Parse(newTmpl)
|
||||||
|
if err != nil {
|
||||||
|
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgConfigErrorInvalidTemplate, map[string]any{
|
||||||
|
"Error": err.Error(),
|
||||||
|
})), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
user.FilenameTemplate = newTmpl
|
||||||
|
if err := database.UpdateUser(ctx, user); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgConfigInfoTemplateUpdated, nil)), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
76
client/bot/handlers/dir.go
Normal file
76
client/bot/handlers/dir.go
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
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/common/i18n"
|
||||||
|
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
|
||||||
|
"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("Failed to get user directories: %s", err)
|
||||||
|
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgDirErrorGetUserDirsFailed)), 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("Failed to get user: %s", err)
|
||||||
|
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgDirErrorGetUserFailed)), 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("Failed to create directory: %s", err)
|
||||||
|
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgDirErrorCreateDirFailed)), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgDirInfoCreateDirSuccess)), 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(i18n.T(i18nk.BotMsgDirErrorInvalidDirId)), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
if err := database.DeleteDirByID(ctx, uint(dirID)); err != nil {
|
||||||
|
logger.Errorf("Failed to delete directory: %s", err)
|
||||||
|
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgDirErrorDeleteDirFailed)), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgDirInfoDeleteDirSuccess)), nil)
|
||||||
|
default:
|
||||||
|
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgDirErrorUnknownOperation)), nil)
|
||||||
|
}
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
113
client/bot/handlers/dl.go
Normal file
113
client/bot/handlers/dl.go
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/celestix/gotgproto/ext"
|
||||||
|
"github.com/charmbracelet/log"
|
||||||
|
"github.com/duke-git/lancet/v2/slice"
|
||||||
|
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/msgelem"
|
||||||
|
"github.com/krau/SaveAny-Bot/common/i18n"
|
||||||
|
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
|
||||||
|
"github.com/krau/SaveAny-Bot/config"
|
||||||
|
"github.com/krau/SaveAny-Bot/pkg/aria2"
|
||||||
|
"github.com/krau/SaveAny-Bot/pkg/enums/tasktype"
|
||||||
|
"github.com/krau/SaveAny-Bot/pkg/tcbdata"
|
||||||
|
"github.com/krau/SaveAny-Bot/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
func handleDlCmd(ctx *ext.Context, update *ext.Update) error {
|
||||||
|
logger := log.FromContext(ctx)
|
||||||
|
args := strings.Split(update.EffectiveMessage.Text, " ")
|
||||||
|
if len(args) < 2 {
|
||||||
|
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgDlUsage)), nil)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
links := args[1:]
|
||||||
|
for i, link := range links {
|
||||||
|
links[i] = strings.TrimSpace(link)
|
||||||
|
u, err := url.Parse(link)
|
||||||
|
if err != nil || u.Scheme == "" || u.Host == "" {
|
||||||
|
logger.Warn("invaild link", link)
|
||||||
|
links[i] = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
links = slice.Compact(links)
|
||||||
|
if len(links) == 0 {
|
||||||
|
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgDlErrorNoValidLinks)), nil)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
markup, err := msgelem.BuildAddSelectStorageKeyboard(storage.GetUserStorages(ctx, update.GetUserChat().GetID()), tcbdata.Add{
|
||||||
|
TaskType: tasktype.TaskTypeDirectlinks,
|
||||||
|
DirectLinks: links,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgDlInfoFilesSelectStorage, map[string]any{
|
||||||
|
"Count": len(links),
|
||||||
|
})), &ext.ReplyOpts{
|
||||||
|
Markup: markup,
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var aria2ClientInitOnce sync.Once
|
||||||
|
var aria2ClientInitErr error
|
||||||
|
var aria2Client *aria2.Client
|
||||||
|
|
||||||
|
// GetAria2Client returns the shared aria2 client instance
|
||||||
|
func GetAria2Client() *aria2.Client {
|
||||||
|
return aria2Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleAria2DlCmd(ctx *ext.Context, update *ext.Update) error {
|
||||||
|
if !config.C().Aria2.Enable {
|
||||||
|
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgAria2ErrorAria2NotEnabled)), nil)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
logger := log.FromContext(ctx)
|
||||||
|
args := strings.Split(update.EffectiveMessage.Text, " ")
|
||||||
|
if len(args) < 2 {
|
||||||
|
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgDlUsage)), nil)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
links := args[1:]
|
||||||
|
for i, link := range links {
|
||||||
|
links[i] = strings.TrimSpace(link)
|
||||||
|
}
|
||||||
|
links = slice.Compact(links)
|
||||||
|
if len(links) == 0 {
|
||||||
|
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgDlErrorNoValidLinks)), nil)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
logger.Debug("Preparing aria2 download", "links", links)
|
||||||
|
|
||||||
|
// Initialize aria2 client to check connection
|
||||||
|
aria2ClientInitOnce.Do(func() {
|
||||||
|
aria2Client, aria2ClientInitErr = aria2.NewClient(config.C().Aria2.Url, config.C().Aria2.Secret)
|
||||||
|
})
|
||||||
|
if aria2ClientInitErr != nil {
|
||||||
|
logger.Error("Failed to initialize aria2 client", "error", aria2ClientInitErr)
|
||||||
|
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgAria2ErrorAria2ClientInitFailed, map[string]any{
|
||||||
|
"Error": aria2ClientInitErr.Error(),
|
||||||
|
})), nil)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build storage selection keyboard (don't add to aria2 yet)
|
||||||
|
markup, err := msgelem.BuildAddSelectStorageKeyboard(storage.GetUserStorages(ctx, update.GetUserChat().GetID()), tcbdata.Add{
|
||||||
|
TaskType: tasktype.TaskTypeAria2,
|
||||||
|
Aria2URIs: links,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgAria2InfoSelectStorage)), &ext.ReplyOpts{
|
||||||
|
Markup: markup,
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
20
client/bot/handlers/help.go
Normal file
20
client/bot/handlers/help.go
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/celestix/gotgproto/dispatcher"
|
||||||
|
"github.com/celestix/gotgproto/ext"
|
||||||
|
"github.com/krau/SaveAny-Bot/common/i18n"
|
||||||
|
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
|
||||||
|
"github.com/krau/SaveAny-Bot/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func handleHelpCmd(ctx *ext.Context, update *ext.Update) error {
|
||||||
|
shortHash := config.GitCommit
|
||||||
|
if len(shortHash) > 7 {
|
||||||
|
shortHash = shortHash[:7]
|
||||||
|
}
|
||||||
|
ctx.Reply(update, ext.ReplyTextString(fmt.Sprintf(i18n.T(i18nk.BotMsgHelpTextFmt), config.Version, shortHash)), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
182
client/bot/handlers/import.go
Normal file
182
client/bot/handlers/import.go
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
|
||||||
|
"github.com/celestix/gotgproto/dispatcher"
|
||||||
|
"github.com/celestix/gotgproto/ext"
|
||||||
|
"github.com/charmbracelet/log"
|
||||||
|
"github.com/gotd/td/tg"
|
||||||
|
"github.com/krau/SaveAny-Bot/common/i18n"
|
||||||
|
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
|
||||||
|
"github.com/krau/SaveAny-Bot/common/utils/strutil"
|
||||||
|
"github.com/krau/SaveAny-Bot/common/utils/tgutil"
|
||||||
|
"github.com/krau/SaveAny-Bot/config"
|
||||||
|
storconfig "github.com/krau/SaveAny-Bot/config/storage"
|
||||||
|
"github.com/krau/SaveAny-Bot/core"
|
||||||
|
"github.com/krau/SaveAny-Bot/core/tasks/batchimport"
|
||||||
|
"github.com/krau/SaveAny-Bot/pkg/storagetypes"
|
||||||
|
"github.com/krau/SaveAny-Bot/storage"
|
||||||
|
"github.com/rs/xid"
|
||||||
|
)
|
||||||
|
|
||||||
|
func handleImportCmd(ctx *ext.Context, update *ext.Update) error {
|
||||||
|
logger := log.FromContext(ctx)
|
||||||
|
args := strutil.ParseArgsRespectQuotes(update.EffectiveMessage.Text)
|
||||||
|
|
||||||
|
if len(args) < 3 {
|
||||||
|
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgImportUsage, nil)), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
|
||||||
|
storageName := args[1]
|
||||||
|
dirPath := args[2]
|
||||||
|
|
||||||
|
userID := update.GetUserChat().GetID()
|
||||||
|
|
||||||
|
stor, err := storage.GetStorageByUserIDAndName(ctx, userID, storageName)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("Failed to get storage by user ID and name: %s", err)
|
||||||
|
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgImportErrorStorageNotFound, map[string]any{
|
||||||
|
"StorageName": storageName,
|
||||||
|
"Error": err,
|
||||||
|
})), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
|
||||||
|
listable, ok := stor.(storage.StorageListable)
|
||||||
|
if !ok {
|
||||||
|
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgImportErrorStorageNotListable, map[string]any{
|
||||||
|
"StorageName": storageName,
|
||||||
|
})), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
|
||||||
|
_, ok = stor.(storage.StorageReadable)
|
||||||
|
if !ok {
|
||||||
|
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgImportErrorStorageNotReadable, map[string]any{
|
||||||
|
"StorageName": storageName,
|
||||||
|
})), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
|
||||||
|
telegramStorage, err := storage.GetTelegramStorageByUserID(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgImportErrorNoTelegramStorage, map[string]any{
|
||||||
|
"Error": err,
|
||||||
|
})), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
|
||||||
|
replied, err := ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgImportInfoFetchingFiles, nil)), nil)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("Failed to reply: %s", err)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
|
||||||
|
files, err := listable.ListFiles(ctx, dirPath)
|
||||||
|
if err != nil {
|
||||||
|
ctx.EditMessage(update.EffectiveChat().GetID(), &tg.MessagesEditMessageRequest{
|
||||||
|
ID: replied.ID,
|
||||||
|
Message: i18n.T(i18nk.BotMsgImportErrorListFilesFailed, map[string]any{"Error": err}),
|
||||||
|
})
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
|
||||||
|
var filter *regexp.Regexp
|
||||||
|
if len(args) >= 5 {
|
||||||
|
filter, err = regexp.Compile(args[4])
|
||||||
|
if err != nil {
|
||||||
|
ctx.EditMessage(update.EffectiveChat().GetID(), &tg.MessagesEditMessageRequest{
|
||||||
|
ID: replied.ID,
|
||||||
|
Message: i18n.T(i18nk.BotMsgImportErrorInvalidRegex, map[string]any{"Error": err}),
|
||||||
|
})
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
filteredFiles := make([]storagetypes.FileInfo, 0)
|
||||||
|
for _, file := range files {
|
||||||
|
if file.IsDir {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if filter != nil && !filter.MatchString(file.Name) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
filteredFiles = append(filteredFiles, file)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(filteredFiles) == 0 {
|
||||||
|
ctx.EditMessage(update.EffectiveChat().GetID(), &tg.MessagesEditMessageRequest{
|
||||||
|
ID: replied.ID,
|
||||||
|
Message: i18n.T(i18nk.BotMsgImportErrorNoFilesToImport, nil),
|
||||||
|
})
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get default chat_id from Telegram storage config
|
||||||
|
targetChatID := int64(0)
|
||||||
|
if telegramCfg := config.C().GetStorageByName(telegramStorage.Name()); telegramCfg != nil {
|
||||||
|
if tgCfg, ok := telegramCfg.(*storconfig.TelegramStorageConfig); ok {
|
||||||
|
targetChatID = tgCfg.ChatID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(args) >= 4 {
|
||||||
|
parsedChatID, err := tgutil.ParseChatID(ctx, args[3])
|
||||||
|
if err != nil {
|
||||||
|
ctx.EditMessage(update.EffectiveChat().GetID(), &tg.MessagesEditMessageRequest{
|
||||||
|
ID: replied.ID,
|
||||||
|
Message: i18n.T(i18nk.BotMsgImportErrorInvalidChatId, map[string]any{"Error": err}),
|
||||||
|
})
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
targetChatID = parsedChatID
|
||||||
|
}
|
||||||
|
|
||||||
|
if targetChatID == 0 {
|
||||||
|
ctx.EditMessage(update.EffectiveChat().GetID(), &tg.MessagesEditMessageRequest{
|
||||||
|
ID: replied.ID,
|
||||||
|
Message: i18n.T(i18nk.BotMsgImportErrorNoTargetChatId, nil),
|
||||||
|
})
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
|
||||||
|
elems := make([]batchimport.TaskElement, 0, len(filteredFiles))
|
||||||
|
var totalSize int64
|
||||||
|
for _, file := range filteredFiles {
|
||||||
|
elem := batchimport.NewTaskElement(stor, file, telegramStorage, targetChatID)
|
||||||
|
elems = append(elems, *elem)
|
||||||
|
totalSize += file.Size
|
||||||
|
}
|
||||||
|
|
||||||
|
taskID := xid.New().String()
|
||||||
|
injectCtx := tgutil.ExtWithContext(ctx.Context, ctx)
|
||||||
|
task := batchimport.NewBatchImportTask(
|
||||||
|
taskID,
|
||||||
|
injectCtx,
|
||||||
|
elems,
|
||||||
|
batchimport.NewProgressTracker(replied.ID, userID),
|
||||||
|
true, // IgnoreErrors
|
||||||
|
)
|
||||||
|
|
||||||
|
if err := core.AddTask(injectCtx, task); err != nil {
|
||||||
|
ctx.EditMessage(update.EffectiveChat().GetID(), &tg.MessagesEditMessageRequest{
|
||||||
|
ID: replied.ID,
|
||||||
|
Message: i18n.T(i18nk.BotMsgImportErrorAddTaskFailed, map[string]any{"Error": err}),
|
||||||
|
})
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.EditMessage(update.EffectiveChat().GetID(), &tg.MessagesEditMessageRequest{
|
||||||
|
ID: replied.ID,
|
||||||
|
Message: i18n.T(i18nk.BotMsgImportInfoTaskAdded, map[string]any{
|
||||||
|
"Count": len(elems),
|
||||||
|
"SizeMB": fmt.Sprintf("%.2f", float64(totalSize)/(1024*1024)),
|
||||||
|
"TaskID": taskID,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
63
client/bot/handlers/link.go
Normal file
63
client/bot/handlers/link.go
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/celestix/gotgproto/dispatcher"
|
||||||
|
"github.com/celestix/gotgproto/ext"
|
||||||
|
"github.com/charmbracelet/log"
|
||||||
|
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/dirutil"
|
||||||
|
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/msgelem"
|
||||||
|
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/shortcut"
|
||||||
|
"github.com/krau/SaveAny-Bot/common/i18n"
|
||||||
|
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
|
||||||
|
"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("Failed to build storage selection message: %s", err)
|
||||||
|
editReplied(i18n.T(i18nk.BotMsgCommonErrorBuildStorageSelectMessageFailed, map[string]any{
|
||||||
|
"Error": 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("Failed to build storage selection keyboard: %s", err)
|
||||||
|
editReplied(i18n.T(i18nk.BotMsgCommonErrorBuildStorageSelectKeyboardFailed, map[string]any{
|
||||||
|
"Error": err.Error(),
|
||||||
|
}), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
editReplied(i18n.T(i18nk.BotMsgCommonInfoFoundFilesSelectStorage, map[string]any{
|
||||||
|
"Count": len(files),
|
||||||
|
}), markup)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleSilentSaveLink(ctx *ext.Context, update *ext.Update) error {
|
||||||
|
stor := storage.FromContext(ctx)
|
||||||
|
replied, files, _, err := shortcut.GetFilesFromUpdateLinkMessageWithReplyEdit(ctx, update)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
userId := update.GetUserChat().GetID()
|
||||||
|
if len(files) == 1 {
|
||||||
|
return shortcut.CreateAndAddTGFileTaskWithEdit(ctx, userId, stor, dirutil.PathFromContext(ctx), files[0], replied.ID)
|
||||||
|
}
|
||||||
|
return shortcut.CreateAndAddBatchTGFileTaskWithEdit(ctx, userId, stor, dirutil.PathFromContext(ctx), files, replied.ID)
|
||||||
|
}
|
||||||
69
client/bot/handlers/media.go
Normal file
69
client/bot/handlers/media.go
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/celestix/gotgproto/dispatcher"
|
||||||
|
"github.com/celestix/gotgproto/ext"
|
||||||
|
"github.com/charmbracelet/log"
|
||||||
|
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/dirutil"
|
||||||
|
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/mediautil"
|
||||||
|
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/msgelem"
|
||||||
|
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/shortcut"
|
||||||
|
"github.com/krau/SaveAny-Bot/common/i18n"
|
||||||
|
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
|
||||||
|
"github.com/krau/SaveAny-Bot/database"
|
||||||
|
"github.com/krau/SaveAny-Bot/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
func handleMediaMessage(ctx *ext.Context, update *ext.Update) error {
|
||||||
|
logger := log.FromContext(ctx)
|
||||||
|
message := update.EffectiveMessage.Message
|
||||||
|
groupID, isGroup := message.GetGroupedID()
|
||||||
|
if isGroup && groupID != 0 {
|
||||||
|
return handleGroupMediaMessage(ctx, update, message, groupID)
|
||||||
|
}
|
||||||
|
logger.Debugf("Got media: %s", message.Media.TypeName())
|
||||||
|
userId := update.GetUserChat().GetID()
|
||||||
|
userDB, err := database.GetUserByChatID(ctx, userId)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tfOpts := mediautil.TfileOptions(ctx, userDB, message)
|
||||||
|
msg, file, err := shortcut.GetFileFromMessageWithReply(ctx, update, message, tfOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
stors := storage.GetUserStorages(ctx, userId)
|
||||||
|
req, err := msgelem.BuildAddOneSelectStorageMessage(ctx, stors, file, msg.ID)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("Failed to build storage selection message: %s", err)
|
||||||
|
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgCommonErrorBuildStorageSelectMessageFailed, map[string]any{
|
||||||
|
"Error": 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)
|
||||||
|
message := update.EffectiveMessage.Message
|
||||||
|
groupID, isGroup := message.GetGroupedID()
|
||||||
|
if isGroup && groupID != 0 {
|
||||||
|
return handleGroupMediaMessage(ctx, update, message, groupID)
|
||||||
|
}
|
||||||
|
logger.Debugf("Got media: %s", message.Media.TypeName())
|
||||||
|
userID := update.GetUserChat().GetID()
|
||||||
|
userDB, err := database.GetUserByChatID(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tfOpts := mediautil.TfileOptions(ctx, userDB, message)
|
||||||
|
msg, file, err := shortcut.GetFileFromMessageWithReply(ctx, update, message, tfOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return shortcut.CreateAndAddTGFileTaskWithEdit(ctx, userID, stor, dirutil.PathFromContext(ctx), file, msg.ID)
|
||||||
|
}
|
||||||
131
client/bot/handlers/media_group.go
Normal file
131
client/bot/handlers/media_group.go
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"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/i18n"
|
||||||
|
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
|
||||||
|
"github.com/krau/SaveAny-Bot/common/utils/tgutil"
|
||||||
|
"github.com/krau/SaveAny-Bot/config"
|
||||||
|
"github.com/krau/SaveAny-Bot/pkg/tcbdata"
|
||||||
|
"github.com/krau/SaveAny-Bot/pkg/tfile"
|
||||||
|
"github.com/krau/SaveAny-Bot/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
type MediaGroupHandler struct {
|
||||||
|
groups map[int64][]tfile.TGFileMessage
|
||||||
|
timers map[int64]*time.Timer
|
||||||
|
mu sync.Mutex
|
||||||
|
timeout time.Duration
|
||||||
|
setupOnce sync.Once
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MediaGroupHandler) SetupTimeout(timeoutSec int) {
|
||||||
|
m.setupOnce.Do(func() {
|
||||||
|
if timeoutSec < 1 {
|
||||||
|
timeoutSec = 1
|
||||||
|
}
|
||||||
|
m.timeout = time.Duration(timeoutSec) * time.Second
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
mediaGroupHandler = &MediaGroupHandler{
|
||||||
|
groups: make(map[int64][]tfile.TGFileMessage),
|
||||||
|
timers: make(map[int64]*time.Timer),
|
||||||
|
mu: sync.Mutex{},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
func handleGroupMediaMessage(ctx *ext.Context, update *ext.Update, message *tg.Message, groupID int64) error {
|
||||||
|
mediaGroupHandler.SetupTimeout(max(config.C().Telegram.MediaGroupTimeout, 1))
|
||||||
|
logger := log.FromContext(ctx)
|
||||||
|
media := message.Media
|
||||||
|
supported := mediautil.IsSupported(media)
|
||||||
|
if !supported {
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
file, err := tfile.FromMediaMessage(media, ctx.Raw, message, tfile.WithNameIfEmpty(
|
||||||
|
tgutil.GenFileNameFromMessage(*message),
|
||||||
|
))
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("Failed to get file from media: %s", err)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
mediaGroupHandler.mu.Lock()
|
||||||
|
defer mediaGroupHandler.mu.Unlock()
|
||||||
|
if mediaGroupHandler.groups[groupID] == nil {
|
||||||
|
mediaGroupHandler.groups[groupID] = make([]tfile.TGFileMessage, 0)
|
||||||
|
}
|
||||||
|
mediaGroupHandler.groups[groupID] = append(mediaGroupHandler.groups[groupID], file)
|
||||||
|
|
||||||
|
if timer, exists := mediaGroupHandler.timers[groupID]; exists {
|
||||||
|
timer.Stop()
|
||||||
|
}
|
||||||
|
mediaGroupHandler.timers[groupID] = time.AfterFunc(mediaGroupHandler.timeout, func() {
|
||||||
|
processMediaGroup(ctx, update, groupID)
|
||||||
|
})
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
|
||||||
|
func processMediaGroup(ctx *ext.Context, update *ext.Update, groupID int64) {
|
||||||
|
logger := log.FromContext(ctx)
|
||||||
|
mediaGroupHandler.mu.Lock()
|
||||||
|
items := mediaGroupHandler.groups[groupID]
|
||||||
|
delete(mediaGroupHandler.groups, groupID)
|
||||||
|
delete(mediaGroupHandler.timers, groupID)
|
||||||
|
mediaGroupHandler.mu.Unlock()
|
||||||
|
if len(items) == 0 {
|
||||||
|
logger.Warn("No media items to process for group", "groupID", groupID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
logger.Debugf("Processing media group %d with %d items", groupID, len(items))
|
||||||
|
|
||||||
|
userId := update.GetUserChat().GetID()
|
||||||
|
msg, err := ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgMediaGroupInfoSavingFiles, nil)), 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("Failed to build storage selection keyboard: %s", err)
|
||||||
|
ctx.EditMessage(userId, &tg.MessagesEditMessageRequest{
|
||||||
|
ID: msg.ID,
|
||||||
|
Message: i18n.T(i18nk.BotMsgMediaGroupErrorBuildStorageSelectKeyboardFailed, map[string]any{
|
||||||
|
"Error": err.Error(),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx.EditMessage(userId, &tg.MessagesEditMessageRequest{
|
||||||
|
ID: msg.ID,
|
||||||
|
Message: i18n.T(i18nk.BotMsgMediaGroupInfoGroupFoundFilesSelectStorage, map[string]any{
|
||||||
|
"Count": len(items),
|
||||||
|
}),
|
||||||
|
ReplyMarkup: markup,
|
||||||
|
})
|
||||||
|
}
|
||||||
62
client/bot/handlers/middleware.go
Normal file
62
client/bot/handlers/middleware.go
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/celestix/gotgproto/dispatcher"
|
||||||
|
"github.com/celestix/gotgproto/ext"
|
||||||
|
"github.com/duke-git/lancet/v2/slice"
|
||||||
|
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/dirutil"
|
||||||
|
"github.com/krau/SaveAny-Bot/common/i18n"
|
||||||
|
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
|
||||||
|
"github.com/krau/SaveAny-Bot/config"
|
||||||
|
"github.com/krau/SaveAny-Bot/database"
|
||||||
|
"github.com/krau/SaveAny-Bot/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
func checkPermission(ctx *ext.Context, update *ext.Update) error {
|
||||||
|
userID := update.GetUserChat().GetID()
|
||||||
|
if !slice.Contain(config.C().GetUsersID(), userID) {
|
||||||
|
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgCommonErrorNoPermission, nil)), 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(i18n.T(i18nk.BotMsgCommonErrorGetUserInfoFailed, map[string]any{
|
||||||
|
"Error": err.Error(),
|
||||||
|
})), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
if !user.Silent {
|
||||||
|
return next(ctx, update)
|
||||||
|
}
|
||||||
|
if user.DefaultStorage == "" {
|
||||||
|
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgCommonErrorDefaultStorageNotSet, nil)), nil)
|
||||||
|
return next(ctx, update)
|
||||||
|
}
|
||||||
|
stor, err := storage.GetStorageByUserIDAndName(ctx, userID, user.DefaultStorage)
|
||||||
|
if err != nil {
|
||||||
|
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgCommonErrorGetStorageFailed, map[string]any{
|
||||||
|
"Error": err.Error(),
|
||||||
|
})), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
if user.DefaultDir != 0 {
|
||||||
|
dir, err := database.GetDirByID(ctx, user.DefaultDir)
|
||||||
|
if err != nil {
|
||||||
|
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgCommonErrorGetDirFailed, map[string]any{
|
||||||
|
"Error": err.Error(),
|
||||||
|
})), nil)
|
||||||
|
return next(ctx, update)
|
||||||
|
}
|
||||||
|
ctx.Context = dirutil.WithContext(ctx.Context, dir)
|
||||||
|
}
|
||||||
|
ctx.Context = storage.WithContext(ctx.Context, stor)
|
||||||
|
return handler(ctx, update)
|
||||||
|
}
|
||||||
|
}
|
||||||
149
client/bot/handlers/parse.go
Normal file
149
client/bot/handlers/parse.go
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
// 处理任意文本消息, 用于通用地从外部源下载文件
|
||||||
|
|
||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"path"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/celestix/gotgproto/dispatcher"
|
||||||
|
"github.com/celestix/gotgproto/ext"
|
||||||
|
"github.com/charmbracelet/log"
|
||||||
|
"github.com/gotd/td/tg"
|
||||||
|
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/dirutil"
|
||||||
|
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/msgelem"
|
||||||
|
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/shortcut"
|
||||||
|
"github.com/krau/SaveAny-Bot/common/i18n"
|
||||||
|
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
|
||||||
|
"github.com/krau/SaveAny-Bot/common/utils/fsutil"
|
||||||
|
"github.com/krau/SaveAny-Bot/common/utils/tgutil"
|
||||||
|
"github.com/krau/SaveAny-Bot/parsers"
|
||||||
|
"github.com/krau/SaveAny-Bot/pkg/enums/tasktype"
|
||||||
|
"github.com/krau/SaveAny-Bot/pkg/tcbdata"
|
||||||
|
"github.com/krau/SaveAny-Bot/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
func handleTextMessage(ctx *ext.Context, u *ext.Update) error {
|
||||||
|
logger := log.FromContext(ctx)
|
||||||
|
text := u.EffectiveMessage.Text
|
||||||
|
entityUrls := tgutil.ExtractMessageEntityUrls(u.EffectiveMessage.Message)
|
||||||
|
if len(entityUrls) > 0 {
|
||||||
|
text += "\n" + strings.Join(entityUrls, "\n")
|
||||||
|
}
|
||||||
|
// read lines and remove empty lines & duplicates
|
||||||
|
lines := strings.Split(text, "\n")
|
||||||
|
seen := make(map[string]struct{})
|
||||||
|
var processedLines []string
|
||||||
|
for _, line := range lines {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := seen[line]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[line] = struct{}{}
|
||||||
|
processedLines = append(processedLines, line)
|
||||||
|
}
|
||||||
|
source := strings.TrimSpace(strings.Join(processedLines, "\n"))
|
||||||
|
ok, pser := parsers.CanHandle(source)
|
||||||
|
if !ok {
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
msg, err := ctx.Reply(u, ext.ReplyTextString(i18n.T(i18nk.BotMsgParseInfoParsing, nil)), nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
item, err := pser.Parse(ctx, source)
|
||||||
|
if errors.Is(err, parsers.ErrNoParserFound) {
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
logger.Error("Failed to parse text", "error", err)
|
||||||
|
ctx.Reply(u, ext.ReplyTextString(i18n.T(i18nk.BotMsgParseErrorParseTextFailed, map[string]any{
|
||||||
|
"Error": err.Error(),
|
||||||
|
})), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
logger.Debug("Parsed item from text message", "title", item.Title, "url", item.URL)
|
||||||
|
userID := u.GetUserChat().GetID()
|
||||||
|
markup, err := msgelem.BuildAddSelectStorageKeyboard(storage.GetUserStorages(ctx, userID), tcbdata.Add{
|
||||||
|
TaskType: tasktype.TaskTypeParseditem,
|
||||||
|
ParsedItem: item,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("Failed to build storage selection keyboard: %s", err)
|
||||||
|
ctx.Reply(u, ext.ReplyTextString(i18n.T(i18nk.BotMsgParseErrorBuildStorageSelectKeyboardFailed, map[string]any{
|
||||||
|
"Error": err.Error(),
|
||||||
|
})), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
text, entities, err := msgelem.BuildParsedTextEntity(*item)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("Failed to build parsed text entity: %s", err)
|
||||||
|
ctx.Reply(u, ext.ReplyTextString(i18n.T(i18nk.BotMsgParseErrorBuildParsedTextEntityFailed, map[string]any{
|
||||||
|
"Error": err.Error(),
|
||||||
|
})), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
|
||||||
|
Message: text,
|
||||||
|
ReplyMarkup: markup,
|
||||||
|
Entities: entities,
|
||||||
|
ID: msg.ID,
|
||||||
|
})
|
||||||
|
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleSilentSaveText(ctx *ext.Context, u *ext.Update) error {
|
||||||
|
logger := log.FromContext(ctx)
|
||||||
|
stor := storage.FromContext(ctx)
|
||||||
|
text := u.EffectiveMessage.Text
|
||||||
|
if text == "" {
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
item, err := parsers.ParseWithContext(ctx, text)
|
||||||
|
if errors.Is(err, parsers.ErrNoParserFound) {
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
logger.Error("Failed to parse text", "error", err)
|
||||||
|
ctx.Reply(u, ext.ReplyTextString(i18n.T(i18nk.BotMsgParseErrorParseTextFailed, map[string]any{
|
||||||
|
"Error": err.Error(),
|
||||||
|
})), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
logger.Debug("Parsed item from text message", "title", item.Title, "url", item.URL)
|
||||||
|
userID := u.GetUserChat().GetID()
|
||||||
|
text, entities, err := msgelem.BuildParsedTextEntity(*item)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("Failed to build parsed text entity: %s", err)
|
||||||
|
ctx.Reply(u, ext.ReplyTextString(i18n.T(i18nk.BotMsgParseErrorBuildParsedTextEntityFailed, map[string]any{
|
||||||
|
"Error": err.Error(),
|
||||||
|
})), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
msg, err := ctx.SendMessage(userID, &tg.MessagesSendMessageRequest{
|
||||||
|
Message: text,
|
||||||
|
Entities: entities,
|
||||||
|
ReplyTo: &tg.InputReplyToMessage{
|
||||||
|
ReplyToMsgID: u.EffectiveMessage.ID,
|
||||||
|
ReplyToPeerID: u.GetUserChat().AsInputPeer(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("Failed to send message: %s", err)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
dirPath := ""
|
||||||
|
if len(item.Resources) > 1 {
|
||||||
|
dirPath = fsutil.NormalizePathname(item.Title)
|
||||||
|
}
|
||||||
|
if p := dirutil.PathFromContext(ctx); p != "" {
|
||||||
|
dirPath = path.Join(p, dirPath)
|
||||||
|
}
|
||||||
|
return shortcut.CreateAndAddParsedTaskWithEdit(ctx, stor, dirPath, item, msg.ID, userID)
|
||||||
|
}
|
||||||
101
client/bot/handlers/parser.go
Normal file
101
client/bot/handlers/parser.go
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/celestix/gotgproto/dispatcher"
|
||||||
|
"github.com/celestix/gotgproto/ext"
|
||||||
|
"github.com/gotd/td/tg"
|
||||||
|
"github.com/krau/SaveAny-Bot/common/i18n"
|
||||||
|
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
|
||||||
|
"github.com/krau/SaveAny-Bot/config"
|
||||||
|
"github.com/krau/SaveAny-Bot/parsers"
|
||||||
|
)
|
||||||
|
|
||||||
|
func handleParserCmd(ctx *ext.Context, u *ext.Update) error {
|
||||||
|
args := strings.Split(u.EffectiveMessage.Text, " ")
|
||||||
|
help := i18n.T(i18nk.BotMsgParserHelpText, nil)
|
||||||
|
if len(args) < 2 {
|
||||||
|
ctx.Reply(u, ext.ReplyTextString(help), nil)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
switch args[1] {
|
||||||
|
// case "list":
|
||||||
|
// return handleParserListCmd(ctx, u)
|
||||||
|
case "install":
|
||||||
|
return handleParserInstallCmd(ctx, u)
|
||||||
|
// case "uninstall":
|
||||||
|
// return handleParserUninstallCmd(ctx, u)
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleParserInstallCmd(ctx *ext.Context, u *ext.Update) error {
|
||||||
|
if !config.C().Parser.PluginEnable {
|
||||||
|
ctx.Reply(u, ext.ReplyTextString(i18n.T(i18nk.BotMsgParserPluginNotEnabled, nil)), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
if u.EffectiveMessage.ReplyToMessage == nil || u.EffectiveMessage.ReplyToMessage.Media == nil {
|
||||||
|
ctx.Reply(u, ext.ReplyTextString(i18n.T(i18nk.BotMsgParserPromptReplyWithParserFile, nil)), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
media := u.EffectiveMessage.ReplyToMessage.Media
|
||||||
|
document, ok := media.(*tg.MessageMediaDocument)
|
||||||
|
if !ok {
|
||||||
|
ctx.Reply(u, ext.ReplyTextString(i18n.T(i18nk.BotMsgParserErrorNoValidFileInReply, nil)), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
value, ok := document.GetDocument()
|
||||||
|
if !ok {
|
||||||
|
ctx.Reply(u, ext.ReplyTextString(i18n.T(i18nk.BotMsgParserErrorNoValidFileInReply, nil)), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
doc, ok := value.AsNotEmpty()
|
||||||
|
if !ok {
|
||||||
|
ctx.Reply(u, ext.ReplyTextString(i18n.T(i18nk.BotMsgParserErrorNoValidFileInReply, nil)), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(doc.MimeType, "text/") {
|
||||||
|
ctx.Reply(u, ext.ReplyTextString(i18n.T(i18nk.BotMsgParserErrorWrongFileType, nil)), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
if doc.Size > 1024*1024*10 {
|
||||||
|
ctx.Reply(u, ext.ReplyTextString(i18n.T(i18nk.BotMsgParserErrorFileTooLarge, nil)), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
var fileName string
|
||||||
|
for _, attr := range doc.Attributes {
|
||||||
|
if fileNameAttr, ok := attr.(*tg.DocumentAttributeFilename); ok {
|
||||||
|
fileName = fileNameAttr.FileName
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if fileName == "" {
|
||||||
|
ctx.Reply(u, ext.ReplyTextString(i18n.T(i18nk.BotMsgParserErrorGetFilenameFailed, nil)), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
if !strings.HasSuffix(fileName, ".js") {
|
||||||
|
ctx.Reply(u, ext.ReplyTextString(i18n.T(i18nk.BotMsgParserErrorOnlyJsSupported, nil)), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
data := bytes.NewBuffer(nil)
|
||||||
|
_, err := ctx.DownloadMedia(media, ext.DownloadOutputStream{Writer: data}, nil)
|
||||||
|
if err != nil {
|
||||||
|
ctx.Reply(u, ext.ReplyTextString(i18n.T(i18nk.BotMsgParserErrorDownloadFileFailed, map[string]any{
|
||||||
|
"Error": err.Error(),
|
||||||
|
})), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
if err := parsers.AddPlugin(ctx, data.String(), fileName); err != nil {
|
||||||
|
ctx.Reply(u, ext.ReplyTextString(i18n.T(i18nk.BotMsgParserErrorInstallPluginFailed, map[string]any{
|
||||||
|
"Error": err.Error(),
|
||||||
|
})), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
ctx.Reply(u, ext.ReplyTextString(i18n.T(i18nk.BotMsgParserInfoInstallPluginSuccess, map[string]any{
|
||||||
|
"Name": fileName,
|
||||||
|
})), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
72
client/bot/handlers/register.go
Normal file
72
client/bot/handlers/register.go
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"regexp"
|
||||||
|
|
||||||
|
"github.com/celestix/gotgproto/dispatcher"
|
||||||
|
"github.com/celestix/gotgproto/dispatcher/handlers"
|
||||||
|
"github.com/celestix/gotgproto/dispatcher/handlers/filters"
|
||||||
|
"github.com/celestix/gotgproto/ext"
|
||||||
|
sabotfilters "github.com/krau/SaveAny-Bot/client/bot/handlers/utils/filters"
|
||||||
|
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/re"
|
||||||
|
userclient "github.com/krau/SaveAny-Bot/client/user"
|
||||||
|
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
|
||||||
|
"github.com/krau/SaveAny-Bot/config"
|
||||||
|
"github.com/krau/SaveAny-Bot/pkg/tcbdata"
|
||||||
|
)
|
||||||
|
|
||||||
|
type DescCommandHandler struct {
|
||||||
|
Cmd string
|
||||||
|
Desc i18nk.Key
|
||||||
|
handler func(ctx *ext.Context, u *ext.Update) error
|
||||||
|
}
|
||||||
|
|
||||||
|
var CommandHandlers = []DescCommandHandler{
|
||||||
|
{"start", i18nk.BotMsgCmdStart, handleHelpCmd},
|
||||||
|
{"silent", i18nk.BotMsgCmdSilent, handleSilentCmd},
|
||||||
|
{"storage", i18nk.BotMsgCmdStorage, handleStorageCmd},
|
||||||
|
{"dir", i18nk.BotMsgCmdDir, handleDirCmd},
|
||||||
|
{"rule", i18nk.BotMsgCmdRule, handleRuleCmd},
|
||||||
|
{"save", i18nk.BotMsgCmdSave, handleSilentMode(handleSaveCmd, handleSilentSaveReplied)},
|
||||||
|
{"dl", i18nk.BotMsgCmdDl, handleDlCmd},
|
||||||
|
{"aria2dl", i18nk.BotMsgCmdAria2dl, handleAria2DlCmd},
|
||||||
|
{"ytdlp", i18nk.BotMsgCmdYtdlp, handleYtdlpCmd},
|
||||||
|
{"import", i18nk.BotMsgCmdImport, handleImportCmd},
|
||||||
|
{"task", i18nk.BotMsgCmdTask, handleTaskCmd},
|
||||||
|
{"cancel", i18nk.BotMsgCmdCancel, handleCancelCmd},
|
||||||
|
{"config", i18nk.BotMsgCmdConfig, handleConfigCmd},
|
||||||
|
{"fnametmpl", i18nk.BotMsgCmdFnametmpl, handleConfigFnameTmpl},
|
||||||
|
{"help", i18nk.BotMsgCmdHelp, handleHelpCmd},
|
||||||
|
{"parser", i18nk.BotMsgCmdParser, handleParserCmd},
|
||||||
|
{"watch", i18nk.BotMsgCmdWatch, handleWatchCmd},
|
||||||
|
{"unwatch", i18nk.BotMsgCmdUnwatch, handleUnwatchCmd},
|
||||||
|
{"lswatch", i18nk.BotMsgCmdLswatch, handleLswatchCmd},
|
||||||
|
{"syncpeers", i18nk.BotMsgCmdSyncpeers, handleSyncpeersCmd},
|
||||||
|
{"update", i18nk.BotMsgCmdUpdate, handleUpdateCmd},
|
||||||
|
}
|
||||||
|
|
||||||
|
func Register(disp dispatcher.Dispatcher) {
|
||||||
|
disp.AddHandler(handlers.NewMessage(filters.Message.ChatType(filters.ChatTypeChannel), func(ctx *ext.Context, u *ext.Update) error {
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}))
|
||||||
|
disp.AddHandler(handlers.NewMessage(filters.Message.ChatType(filters.ChatTypeChat), func(ctx *ext.Context, u *ext.Update) error {
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}))
|
||||||
|
disp.AddHandler(handlers.NewMessage(filters.Message.All, checkPermission))
|
||||||
|
for _, info := range CommandHandlers {
|
||||||
|
disp.AddHandler(handlers.NewCommand(info.Cmd, info.handler))
|
||||||
|
}
|
||||||
|
disp.AddHandler(handlers.NewCallbackQuery(filters.CallbackQuery.Prefix("update"), handleUpdateCallback))
|
||||||
|
disp.AddHandler(handlers.NewCallbackQuery(filters.CallbackQuery.Prefix(tcbdata.TypeAdd), handleAddCallback))
|
||||||
|
disp.AddHandler(handlers.NewCallbackQuery(filters.CallbackQuery.Prefix(tcbdata.TypeSetDefault), handleSetDefaultCallback))
|
||||||
|
disp.AddHandler(handlers.NewCallbackQuery(filters.CallbackQuery.Prefix(tcbdata.TypeCancel), handleCancelCallback))
|
||||||
|
disp.AddHandler(handlers.NewCallbackQuery(filters.CallbackQuery.Prefix(tcbdata.TypeConfig), handleConfigCallback))
|
||||||
|
disp.AddHandler(handlers.NewMessage(sabotfilters.RegexUrl(regexp.MustCompile(re.TgMessageLinkRegexString)), handleSilentMode(handleMessageLink, handleSilentSaveLink)))
|
||||||
|
disp.AddHandler(handlers.NewMessage(sabotfilters.RegexUrl(regexp.MustCompile(re.TelegraphUrlRegexString)), handleSilentMode(handleTelegraphUrlMessage, handleSilentSaveTelegraph)))
|
||||||
|
disp.AddHandler(handlers.NewMessage(filters.Message.Media, handleSilentMode(handleMediaMessage, handleSilentSaveMedia)))
|
||||||
|
disp.AddHandler(handlers.NewMessage(filters.Message.Text, handleSilentMode(handleTextMessage, handleSilentSaveText)))
|
||||||
|
|
||||||
|
if config.C().Telegram.Userbot.Enable {
|
||||||
|
go listenMediaMessageEvent(userclient.GetMediaMessageCh())
|
||||||
|
}
|
||||||
|
}
|
||||||
110
client/bot/handlers/rule.go
Normal file
110
client/bot/handlers/rule.go
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/celestix/gotgproto/dispatcher"
|
||||||
|
"github.com/celestix/gotgproto/ext"
|
||||||
|
"github.com/charmbracelet/log"
|
||||||
|
"github.com/duke-git/lancet/v2/slice"
|
||||||
|
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/msgelem"
|
||||||
|
"github.com/krau/SaveAny-Bot/common/i18n"
|
||||||
|
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
|
||||||
|
"github.com/krau/SaveAny-Bot/common/utils/strutil"
|
||||||
|
"github.com/krau/SaveAny-Bot/database"
|
||||||
|
"github.com/krau/SaveAny-Bot/pkg/rule"
|
||||||
|
)
|
||||||
|
|
||||||
|
func handleRuleCmd(ctx *ext.Context, update *ext.Update) error {
|
||||||
|
logger := log.FromContext(ctx)
|
||||||
|
args := strutil.ParseArgsRespectQuotes(update.EffectiveMessage.Text)
|
||||||
|
userChatID := update.GetUserChat().GetID()
|
||||||
|
user, err := database.GetUserByChatID(ctx, userChatID)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("Failed to get user rules: %s", err)
|
||||||
|
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgRuleErrorGetUserRulesFailed, nil)), 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 {
|
||||||
|
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgRuleErrorUpdateUserFailed, nil)), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
if applyRule {
|
||||||
|
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgRuleInfoRuleModeEnabled, nil)), nil)
|
||||||
|
} else {
|
||||||
|
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgRuleInfoRuleModeDisabled, nil)), 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("invalid rule type: %s\navailable: %v", ruleTypeArg, slice.Join(rule.Values(), ", "))
|
||||||
|
}()
|
||||||
|
if err != nil {
|
||||||
|
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgRuleErrorInvalidRuleType, map[string]any{
|
||||||
|
"Type": ruleTypeArg,
|
||||||
|
"Available": slice.Join(rule.Values(), ", "),
|
||||||
|
})), 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("failed to create rule: %s", err)
|
||||||
|
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgRuleErrorCreateRuleFailed, nil)), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgRuleInfoCreateRuleSuccess, nil)), nil)
|
||||||
|
case "del":
|
||||||
|
// /rule del <id>
|
||||||
|
if len(args) < 3 {
|
||||||
|
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgRulePromptProvideRuleId, nil)), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
ruleID := args[2]
|
||||||
|
id, err := strconv.Atoi(ruleID)
|
||||||
|
if err != nil {
|
||||||
|
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgRuleErrorInvalidRuleId, nil)), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
if err := database.DeleteRule(ctx, uint(id)); err != nil {
|
||||||
|
logger.Errorf("failed to delete rule %d: %s", id, err)
|
||||||
|
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgRuleErrorDeleteRuleFailed, nil)), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgRuleInfoDeleteRuleSuccess, nil)), nil)
|
||||||
|
default:
|
||||||
|
ctx.Reply(update, ext.ReplyTextStyledTextArray(msgelem.BuildRuleHelpStyling(user.ApplyRule, user.Rules)), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
195
client/bot/handlers/save.go
Normal file
195
client/bot/handlers/save.go
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/celestix/gotgproto/dispatcher"
|
||||||
|
"github.com/celestix/gotgproto/ext"
|
||||||
|
"github.com/charmbracelet/log"
|
||||||
|
"github.com/duke-git/lancet/v2/validator"
|
||||||
|
"github.com/gotd/td/tg"
|
||||||
|
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/dirutil"
|
||||||
|
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/mediautil"
|
||||||
|
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/msgelem"
|
||||||
|
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/shortcut"
|
||||||
|
"github.com/krau/SaveAny-Bot/client/user"
|
||||||
|
"github.com/krau/SaveAny-Bot/common/i18n"
|
||||||
|
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
|
||||||
|
"github.com/krau/SaveAny-Bot/common/utils/strutil"
|
||||||
|
"github.com/krau/SaveAny-Bot/common/utils/tgutil"
|
||||||
|
"github.com/krau/SaveAny-Bot/database"
|
||||||
|
"github.com/krau/SaveAny-Bot/pkg/tcbdata"
|
||||||
|
"github.com/krau/SaveAny-Bot/pkg/tfile"
|
||||||
|
|
||||||
|
"github.com/krau/SaveAny-Bot/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
func handleSaveCmd(ctx *ext.Context, update *ext.Update) error {
|
||||||
|
logger := log.FromContext(ctx)
|
||||||
|
args := strings.Split(update.EffectiveMessage.Text, " ")
|
||||||
|
if len(args) >= 3 {
|
||||||
|
return handleBatchSave(ctx, update, args[1:])
|
||||||
|
}
|
||||||
|
replyTo := update.EffectiveMessage.ReplyToMessage
|
||||||
|
if replyTo == nil || replyTo.Message == nil {
|
||||||
|
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgSaveHelpText)), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
userDB, err := database.GetUserByChatID(ctx, update.GetUserChat().GetID())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
opts := mediautil.TfileOptions(ctx, userDB, replyTo.Message)
|
||||||
|
if len(args) > 1 {
|
||||||
|
// custom filename via command arg
|
||||||
|
opts = append(opts, tfile.WithName(strings.Join(args[1:], " ")))
|
||||||
|
}
|
||||||
|
msg, file, err := shortcut.GetFileFromMessageWithReply(ctx, update, replyTo.Message, opts...)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
userId := update.GetUserChat().GetID()
|
||||||
|
stors := storage.GetUserStorages(ctx, userId)
|
||||||
|
req, err := msgelem.BuildAddOneSelectStorageMessage(ctx, stors, file, msg.ID)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("Failed to build storage selection message: %s", err)
|
||||||
|
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgCommonErrorBuildStorageSelectMessageFailed, map[string]any{"Error": 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:])
|
||||||
|
}
|
||||||
|
stor := storage.FromContext(ctx)
|
||||||
|
replyTo := update.EffectiveMessage.ReplyToMessage
|
||||||
|
if replyTo == nil || replyTo.Message == nil {
|
||||||
|
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgSaveHelpText)), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
userDB, err := database.GetUserByChatID(ctx, update.GetUserChat().GetID())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
opts := mediautil.TfileOptions(ctx, userDB, replyTo.Message)
|
||||||
|
if len(args) > 1 {
|
||||||
|
// custom filename via command arg
|
||||||
|
opts = append(opts, tfile.WithName(strings.Join(args[1:], " ")))
|
||||||
|
}
|
||||||
|
msg, file, err := shortcut.GetFileFromMessageWithReply(ctx, update, replyTo.Message, opts...)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return shortcut.CreateAndAddTGFileTaskWithEdit(ctx, update.GetUserChat().GetID(), stor, dirutil.PathFromContext(ctx), file, msg.GetID())
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleBatchSave(ctx *ext.Context, update *ext.Update, args []string) error {
|
||||||
|
chatArg := args[0]
|
||||||
|
msgIdRangeArg := args[1]
|
||||||
|
var filterStr string
|
||||||
|
var filter *regexp.Regexp
|
||||||
|
if len(args) > 2 {
|
||||||
|
filterStr = args[2]
|
||||||
|
var err error
|
||||||
|
filter, err = regexp.Compile(filterStr)
|
||||||
|
if err != nil {
|
||||||
|
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgCommonErrorInvalidRegex, map[string]any{"Error": err.Error()})), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
}
|
||||||
|
startID, endID, err := strutil.ParseIntStrRange(msgIdRangeArg, "-")
|
||||||
|
if err != nil {
|
||||||
|
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgCommonErrorInvalidMsgIdRange, map[string]any{"Error": err.Error()})), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
tctx := ctx
|
||||||
|
uctx := user.GetCtx()
|
||||||
|
if uctx != nil && validator.IsIntStr(chatArg) {
|
||||||
|
tctx = uctx
|
||||||
|
}
|
||||||
|
chatID, err := tgutil.ParseChatID(tctx, chatArg)
|
||||||
|
if err != nil {
|
||||||
|
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgCommonErrorInvalidIdOrUsername, map[string]any{"Error": err.Error()})), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
|
||||||
|
replied, err := ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgCommonInfoFetchingMessages)), nil)
|
||||||
|
if err != nil {
|
||||||
|
log.FromContext(ctx).Errorf("Failed to reply: %s", err)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
|
||||||
|
// [TODO]: generator istead of get all messages
|
||||||
|
msgs, err := tgutil.GetMessagesRange(tctx, chatID, int(startID), int(endID))
|
||||||
|
if err != nil {
|
||||||
|
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgCommonErrorGetMessagesFailed, map[string]any{"Error": err.Error()})), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
if len(msgs) == 0 {
|
||||||
|
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgCommonErrorNoMessagesInRange)), 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, tctx.Raw, msg, tfile.WithNameIfEmpty(tgutil.GenFileNameFromMessage(*msg)))
|
||||||
|
if err != nil {
|
||||||
|
log.FromContext(ctx).Errorf("Failed to get file from message: %s", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if filter != nil {
|
||||||
|
sb.Reset()
|
||||||
|
sb.WriteString(msg.GetMessage())
|
||||||
|
sb.WriteString(" ")
|
||||||
|
fn, _ := tgutil.GetMediaFileName(media)
|
||||||
|
sb.WriteString(fn)
|
||||||
|
if !filter.MatchString(sb.String()) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
files = append(files, file)
|
||||||
|
}
|
||||||
|
if len(files) == 0 {
|
||||||
|
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgCommonErrorNoSavableMessagesInRange)), 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("Failed to build storage selection keyboard: %s", err)
|
||||||
|
ctx.EditMessage(update.EffectiveChat().GetID(), &tg.MessagesEditMessageRequest{
|
||||||
|
ID: replied.ID,
|
||||||
|
Message: i18n.T(i18nk.BotMsgCommonErrorBuildStorageSelectKeyboardFailed, map[string]any{"Error": err.Error()}),
|
||||||
|
})
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
ctx.EditMessage(update.EffectiveChat().GetID(), &tg.MessagesEditMessageRequest{
|
||||||
|
ID: replied.ID,
|
||||||
|
Message: i18n.T(i18nk.BotMsgCommonInfoFoundFilesSelectStorage, map[string]any{"Count": len(files)}),
|
||||||
|
ReplyMarkup: markup,
|
||||||
|
})
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
return shortcut.CreateAndAddBatchTGFileTaskWithEdit(ctx, update.GetUserChat().GetID(), stor, "", files, replied.ID)
|
||||||
|
}
|
||||||
150
client/bot/handlers/silent.go
Normal file
150
client/bot/handlers/silent.go
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
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/common/i18n"
|
||||||
|
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
|
||||||
|
"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(i18n.T(i18nk.BotMsgCommonErrorGetUserInfoFailed, map[string]any{
|
||||||
|
"Error": err.Error(),
|
||||||
|
})), nil)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !user.Silent && user.DefaultStorage == "" {
|
||||||
|
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgCommonErrorDefaultStorageNotSet, nil)), nil)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
user.Silent = !user.Silent
|
||||||
|
if err := database.UpdateUser(ctx, user); err != nil {
|
||||||
|
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgCommonErrorUpdateUserInfoFailed, map[string]any{
|
||||||
|
"Error": err.Error(),
|
||||||
|
})), nil)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if user.Silent {
|
||||||
|
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgCommonInfoSilentModeOn, nil)), nil)
|
||||||
|
} else {
|
||||||
|
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgCommonInfoSilentModeOff, nil)), nil)
|
||||||
|
}
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleSetDefaultCallback(ctx *ext.Context, update *ext.Update) error {
|
||||||
|
dataid := strings.Split(string(update.CallbackQuery.Data), " ")[1]
|
||||||
|
data, ok := cache.Get[tcbdata.SetDefaultStorage](dataid)
|
||||||
|
|
||||||
|
failedAnswer := func(message string) error {
|
||||||
|
ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{
|
||||||
|
QueryID: update.CallbackQuery.GetQueryID(),
|
||||||
|
Alert: true,
|
||||||
|
Message: message,
|
||||||
|
CacheTime: 5,
|
||||||
|
})
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
|
||||||
|
if !ok {
|
||||||
|
return failedAnswer(i18n.T(i18nk.BotMsgCommonErrorDataExpired, nil))
|
||||||
|
}
|
||||||
|
userID := update.CallbackQuery.GetUserID()
|
||||||
|
|
||||||
|
storageName := data.StorageName
|
||||||
|
selectedStorage, err := storage.GetStorageByUserIDAndName(ctx, userID, storageName)
|
||||||
|
if err != nil {
|
||||||
|
return failedAnswer(i18n.T(i18nk.BotMsgCommonErrorGetStorageFailed, map[string]any{
|
||||||
|
"Error": err.Error(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
user, err := database.GetUserByChatID(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
return failedAnswer(i18n.T(i18nk.BotMsgCommonErrorGetUserInfoFailed, map[string]any{
|
||||||
|
"Error": err.Error(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
var dir *database.Dir
|
||||||
|
if data.DirID != 0 {
|
||||||
|
// 已经选择了文件夹
|
||||||
|
var err error
|
||||||
|
dir, err = database.GetDirByID(ctx, data.DirID)
|
||||||
|
if err != nil {
|
||||||
|
return failedAnswer(i18n.T(i18nk.BotMsgDirErrorGetUserDirsFailed, nil))
|
||||||
|
}
|
||||||
|
user.DefaultDir = dir.ID
|
||||||
|
} else {
|
||||||
|
// 检查是否有可用的文件夹
|
||||||
|
dirs, err := database.GetDirsByUserIDAndStorageName(ctx, user.ID, storageName)
|
||||||
|
if err != nil {
|
||||||
|
return failedAnswer(i18n.T(i18nk.BotMsgCommonErrorGetDirFailed, map[string]any{
|
||||||
|
"Error": err.Error(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
if len(dirs) > 0 {
|
||||||
|
// 要求选择文件夹
|
||||||
|
markup, err := msgelem.BuildSetDefaultDirMarkup(ctx, storageName, dirs)
|
||||||
|
if err != nil {
|
||||||
|
return failedAnswer(i18n.T(i18nk.BotMsgCommonErrorBuildDirSelectKeyboardFailed, map[string]any{
|
||||||
|
"Error": err.Error(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
|
||||||
|
ID: update.CallbackQuery.GetMsgID(),
|
||||||
|
Message: i18n.T(i18nk.BotMsgCommonPromptSelectDefaultDir, nil),
|
||||||
|
ReplyMarkup: markup,
|
||||||
|
})
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
}
|
||||||
|
user.DefaultStorage = selectedStorage.Name()
|
||||||
|
if err := database.UpdateUser(ctx, user); err != nil {
|
||||||
|
return failedAnswer(i18n.T(i18nk.BotMsgCommonErrorUpdateUserInfoFailed, map[string]any{
|
||||||
|
"Error": err.Error(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
msg := i18n.T(i18nk.BotMsgCommonInfoDefaultStorageSet, map[string]any{
|
||||||
|
"Name": selectedStorage.Name(),
|
||||||
|
})
|
||||||
|
if dir != nil {
|
||||||
|
msg = i18n.T(i18nk.BotMsgCommonInfoDefaultStorageWithDirSet, map[string]any{
|
||||||
|
"Name": selectedStorage.Name(),
|
||||||
|
"Dir": strings.TrimPrefix(dir.Path, "/"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
|
||||||
|
ID: update.CallbackQuery.GetMsgID(),
|
||||||
|
Message: msg,
|
||||||
|
})
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleStorageCmd(ctx *ext.Context, update *ext.Update) error {
|
||||||
|
userID := update.GetUserChat().GetID()
|
||||||
|
storages := storage.GetUserStorages(ctx, userID)
|
||||||
|
if len(storages) == 0 {
|
||||||
|
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgCommonErrorNoAvailableStorage, nil)), nil)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
markup, err := msgelem.BuildSetDefaultStorageMarkup(ctx, storages)
|
||||||
|
if err != nil {
|
||||||
|
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgCommonErrorGetStorageFailed, map[string]any{
|
||||||
|
"Error": err.Error(),
|
||||||
|
})), nil)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgCommonPromptSelectDefaultStorage, nil)), &ext.ReplyOpts{
|
||||||
|
Markup: markup,
|
||||||
|
})
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
62
client/bot/handlers/sync_peers.go
Normal file
62
client/bot/handlers/sync_peers.go
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/celestix/gotgproto/dispatcher"
|
||||||
|
"github.com/celestix/gotgproto/ext"
|
||||||
|
"github.com/celestix/gotgproto/storage"
|
||||||
|
"github.com/charmbracelet/log"
|
||||||
|
"github.com/gotd/td/telegram/query/dialogs"
|
||||||
|
"github.com/krau/SaveAny-Bot/client/user"
|
||||||
|
"github.com/krau/SaveAny-Bot/common/i18n"
|
||||||
|
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
|
||||||
|
"github.com/krau/SaveAny-Bot/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
var syncpeerMu sync.Mutex
|
||||||
|
|
||||||
|
func handleSyncpeersCmd(ctx *ext.Context, u *ext.Update) error {
|
||||||
|
if !config.C().Telegram.Userbot.Enable {
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
syncpeerMu.Lock()
|
||||||
|
defer syncpeerMu.Unlock()
|
||||||
|
uctx := user.GetCtx()
|
||||||
|
if uctx == nil {
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
ctx.Reply(u, ext.ReplyTextString(i18n.T(i18nk.BotMsgSyncpeersStart)), nil)
|
||||||
|
tapi := uctx.Raw
|
||||||
|
peerStorage := uctx.PeerStorage
|
||||||
|
log.FromContext(ctx).Info("Starting to sync peers...")
|
||||||
|
count := 0
|
||||||
|
err := dialogs.NewQueryBuilder(tapi).GetDialogs().BatchSize(50).ForEach(ctx, func(ctx context.Context, e dialogs.Elem) error {
|
||||||
|
for cid, channel := range e.Entities.Channels() {
|
||||||
|
peerStorage.AddPeer(cid, channel.AccessHash, storage.TypeChannel, channel.Username)
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
for uid, user := range e.Entities.Users() {
|
||||||
|
peerStorage.AddPeer(uid, user.AccessHash, storage.TypeUser, user.Username)
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
for gid := range e.Entities.Chats() {
|
||||||
|
peerStorage.AddPeer(gid, storage.DefaultAccessHash, storage.TypeChat, storage.DefaultUsername)
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.FromContext(ctx).Error("Failed to sync peers", "error", err)
|
||||||
|
ctx.Reply(u, ext.ReplyTextString(i18n.T(i18nk.BotMsgSyncpeersFailed, map[string]any{
|
||||||
|
"Error": err.Error(),
|
||||||
|
})), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
log.FromContext(ctx).Info("Finished syncing peers")
|
||||||
|
ctx.Reply(u, ext.ReplyTextString(i18n.T(i18nk.BotMsgSyncpeersSuccess, map[string]any{
|
||||||
|
"Count": count,
|
||||||
|
})), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
114
client/bot/handlers/tasks.go
Normal file
114
client/bot/handlers/tasks.go
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/celestix/gotgproto/dispatcher"
|
||||||
|
"github.com/celestix/gotgproto/ext"
|
||||||
|
"github.com/charmbracelet/log"
|
||||||
|
"github.com/gotd/td/telegram/message/styling"
|
||||||
|
"github.com/krau/SaveAny-Bot/common/i18n"
|
||||||
|
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
|
||||||
|
"github.com/krau/SaveAny-Bot/core"
|
||||||
|
)
|
||||||
|
|
||||||
|
func handleTaskCmd(ctx *ext.Context, update *ext.Update) error {
|
||||||
|
logger := log.FromContext(ctx)
|
||||||
|
args := strings.Fields(update.EffectiveMessage.Text)
|
||||||
|
if len(args) == 1 {
|
||||||
|
showRunningTasks(ctx, update)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
|
||||||
|
switch args[1] {
|
||||||
|
case "running", "run", "r":
|
||||||
|
showRunningTasks(ctx, update)
|
||||||
|
case "queued", "queue", "q", "waiting":
|
||||||
|
showQueuedTasks(ctx, update)
|
||||||
|
case "cancel", "c":
|
||||||
|
if len(args) < 3 {
|
||||||
|
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgTasksUsageCancel)), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
taskID := args[2]
|
||||||
|
if err := core.CancelTask(ctx, taskID); err != nil {
|
||||||
|
logger.Errorf("Failed to cancel task %s: %v", taskID, err)
|
||||||
|
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgTasksCancelFailed, map[string]any{"Error": err.Error()})), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
ctx.Reply(update, ext.ReplyTextStyledTextArray([]styling.StyledTextOption{
|
||||||
|
styling.Plain(i18n.T(i18nk.BotMsgTasksCancelRequestedPrefix)),
|
||||||
|
styling.Code(taskID),
|
||||||
|
}), nil)
|
||||||
|
default:
|
||||||
|
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgTasksUsage)), nil)
|
||||||
|
}
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
|
||||||
|
func showRunningTasks(ctx *ext.Context, update *ext.Update) {
|
||||||
|
tasks := core.GetRunningTasks(ctx)
|
||||||
|
if len(tasks) == 0 {
|
||||||
|
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgTasksRunningEmpty)), nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
opts := make([]styling.StyledTextOption, 0, 2+len(tasks)*4)
|
||||||
|
opts = append(opts,
|
||||||
|
styling.Bold(i18n.T(i18nk.BotMsgTasksRunningTitle)),
|
||||||
|
styling.Plain(i18n.T(i18nk.BotMsgTasksTotalPrefix, map[string]any{"Count": len(tasks)})),
|
||||||
|
)
|
||||||
|
for _, t := range tasks {
|
||||||
|
created := t.Created.In(time.Local).Format("2006-01-02 15:04:05")
|
||||||
|
status := i18n.T(i18nk.BotMsgTasksStatusRunning)
|
||||||
|
if t.Cancelled {
|
||||||
|
status = i18n.T(i18nk.BotMsgTasksStatusCancelRequested)
|
||||||
|
}
|
||||||
|
opts = append(opts,
|
||||||
|
styling.Plain("\n"+i18n.T(i18nk.BotMsgTasksFieldId)),
|
||||||
|
styling.Code(t.ID),
|
||||||
|
styling.Plain("\n"+i18n.T(i18nk.BotMsgTasksFieldTitle)),
|
||||||
|
styling.Code(t.Title),
|
||||||
|
styling.Plain("\n"+i18n.T(i18nk.BotMsgTasksFieldCreated)),
|
||||||
|
styling.Code(created),
|
||||||
|
styling.Plain("\n"+i18n.T(i18nk.BotMsgTasksFieldStatus)),
|
||||||
|
styling.Code(status),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
ctx.Reply(update, ext.ReplyTextStyledTextArray(opts), nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func showQueuedTasks(ctx *ext.Context, update *ext.Update) {
|
||||||
|
tasks := core.GetQueuedTasks(ctx)
|
||||||
|
if len(tasks) == 0 {
|
||||||
|
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgTasksQueuedEmpty)), nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
opts := make([]styling.StyledTextOption, 0, 2+len(tasks)*3)
|
||||||
|
opts = append(opts,
|
||||||
|
styling.Bold(i18n.T(i18nk.BotMsgTasksQueuedTitle)),
|
||||||
|
styling.Plain(i18n.T(i18nk.BotMsgTasksTotalPrefix, map[string]any{"Count": len(tasks)})),
|
||||||
|
)
|
||||||
|
for _, t := range tasks {
|
||||||
|
created := t.Created.In(time.Local).Format("2006-01-02 15:04:05")
|
||||||
|
status := i18n.T(i18nk.BotMsgTasksStatusQueued)
|
||||||
|
if t.Cancelled {
|
||||||
|
status = i18n.T(i18nk.BotMsgTasksStatusCancelRequested)
|
||||||
|
}
|
||||||
|
opts = append(opts,
|
||||||
|
styling.Plain("\n"+i18n.T(i18nk.BotMsgTasksFieldId)),
|
||||||
|
styling.Code(t.ID),
|
||||||
|
styling.Plain("\n"+i18n.T(i18nk.BotMsgTasksFieldTitle)),
|
||||||
|
styling.Code(t.Title),
|
||||||
|
styling.Plain("\n"+i18n.T(i18nk.BotMsgTasksFieldCreated)),
|
||||||
|
styling.Code(created),
|
||||||
|
styling.Plain("\n"+i18n.T(i18nk.BotMsgTasksFieldStatus)),
|
||||||
|
styling.Code(status),
|
||||||
|
)
|
||||||
|
if len(tasks) > 10 {
|
||||||
|
opts = append(opts, styling.Plain("\n"+i18n.T(i18nk.BotMsgTasksTruncatedNote, map[string]any{"Count": len(tasks)})))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ctx.Reply(update, ext.ReplyTextStyledTextArray(opts), nil)
|
||||||
|
}
|
||||||
80
client/bot/handlers/telegraph.go
Normal file
80
client/bot/handlers/telegraph.go
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"path"
|
||||||
|
|
||||||
|
"github.com/celestix/gotgproto/dispatcher"
|
||||||
|
"github.com/celestix/gotgproto/ext"
|
||||||
|
"github.com/charmbracelet/log"
|
||||||
|
"github.com/gotd/td/telegram/message/entity"
|
||||||
|
"github.com/gotd/td/telegram/message/styling"
|
||||||
|
"github.com/gotd/td/tg"
|
||||||
|
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/dirutil"
|
||||||
|
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/msgelem"
|
||||||
|
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/shortcut"
|
||||||
|
"github.com/krau/SaveAny-Bot/common/i18n"
|
||||||
|
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
|
||||||
|
"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("Failed to build storage selection keyboard: %s", err)
|
||||||
|
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgTelegraphErrorBuildStorageSelectKeyboardFailed, map[string]any{
|
||||||
|
"Error": err.Error(),
|
||||||
|
})), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
|
||||||
|
eb := entity.Builder{}
|
||||||
|
if err := styling.Perform(&eb,
|
||||||
|
styling.Plain(i18n.T(i18nk.BotMsgTelegraphInfoTitlePrefix, nil)),
|
||||||
|
styling.Code(result.Page.Title),
|
||||||
|
styling.Plain(i18n.T(i18nk.BotMsgTelegraphInfoPicCountPrefix, nil)),
|
||||||
|
styling.Code(fmt.Sprintf("%d", len(result.Pics))),
|
||||||
|
styling.Plain(i18n.T(i18nk.BotMsgTelegraphInfoPromptSelectStorage, nil)),
|
||||||
|
); 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 {
|
||||||
|
stor := storage.FromContext(ctx)
|
||||||
|
msg, result, err := shortcut.GetTphPicsFromMessageWithReply(ctx, update)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
userID := update.GetUserChat().GetID()
|
||||||
|
dirpath := result.TphDir
|
||||||
|
if p := dirutil.PathFromContext(ctx); p != "" {
|
||||||
|
dirpath = path.Join(p, dirpath)
|
||||||
|
}
|
||||||
|
return shortcut.CreateAndAddtelegraphWithEdit(ctx, userID, result.Page, dirpath, result.Pics, stor, msg.ID)
|
||||||
|
|
||||||
|
}
|
||||||
128
client/bot/handlers/update.go
Normal file
128
client/bot/handlers/update.go
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/blang/semver"
|
||||||
|
"github.com/celestix/gotgproto/dispatcher"
|
||||||
|
"github.com/celestix/gotgproto/ext"
|
||||||
|
"github.com/gotd/td/telegram/message/html"
|
||||||
|
"github.com/gotd/td/tg"
|
||||||
|
"github.com/krau/SaveAny-Bot/common/i18n"
|
||||||
|
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
|
||||||
|
"github.com/krau/SaveAny-Bot/config"
|
||||||
|
"github.com/unvgo/ghselfupdate"
|
||||||
|
)
|
||||||
|
|
||||||
|
func handleUpdateCmd(ctx *ext.Context, u *ext.Update) error {
|
||||||
|
currentV, err := semver.Parse(config.Version)
|
||||||
|
if err != nil {
|
||||||
|
ctx.Reply(u, ext.ReplyTextString(i18n.T(i18nk.BotMsgUpdateErrorVersionVarInvalid, map[string]any{
|
||||||
|
"Error": err.Error(),
|
||||||
|
})), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
latest, ok, err := ghselfupdate.DetectLatest(config.GitRepo)
|
||||||
|
if err != nil {
|
||||||
|
ctx.Reply(u, ext.ReplyTextString(i18n.T(i18nk.BotMsgUpdateErrorCheckLatestFailed, map[string]any{
|
||||||
|
"Error": err.Error(),
|
||||||
|
})), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
ctx.Reply(u, ext.ReplyTextString(i18n.T(i18nk.BotMsgUpdateErrorNoReleaseFound, nil)), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
if latest.Version.Major != currentV.Major {
|
||||||
|
ctx.Reply(u, ext.ReplyTextString(i18n.T(i18nk.BotMsgUpdateInfoMajorUpgradeRequired, map[string]any{
|
||||||
|
"Current": currentV.String(),
|
||||||
|
"Latest": latest.Version.String(),
|
||||||
|
})), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
if latest.Version.LT(currentV) || latest.Version.Equals(currentV) {
|
||||||
|
ctx.Reply(u, ext.ReplyTextString(i18n.T(i18nk.BotMsgUpdateInfoAlreadyLatest, map[string]any{
|
||||||
|
"Version": config.Version,
|
||||||
|
})), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
indocker := config.Docker == "true"
|
||||||
|
ctx.Sender.To(u.GetUserChat().AsInputPeer()).StyledText(ctx, html.String(nil, func() string {
|
||||||
|
md := latest.ReleaseNotes
|
||||||
|
md = regexp.MustCompile(`(?m)^###\s+ (.+)$`).ReplaceAllString(md, "<b>$1</b>")
|
||||||
|
md = regexp.MustCompile(`(?m)^#####\s+ (.+)$`).ReplaceAllString(md, "<i>$1</i>")
|
||||||
|
|
||||||
|
md = regexp.MustCompile(`(?m)^- `).ReplaceAllString(md, "• ")
|
||||||
|
|
||||||
|
md = regexp.MustCompile(`\[\((\w{6,})\)\]\((https?://[^\s)]+)\)`).ReplaceAllString(md, `(<a href="$2">$1</a>)`)
|
||||||
|
|
||||||
|
md = regexp.MustCompile(`\[(.+?)\]\((https?://[^\s)]+)\)`).ReplaceAllString(md, `<a href="$2">$1</a>`)
|
||||||
|
|
||||||
|
md = strings.ReplaceAll(md, " ", " ")
|
||||||
|
|
||||||
|
return `<blockquote expandable>` + md + `</blockquote>`
|
||||||
|
}()))
|
||||||
|
if indocker {
|
||||||
|
text := i18n.T(i18nk.BotMsgUpdateInfoNewVersionInDocker, map[string]any{
|
||||||
|
"Latest": latest.Version.String(),
|
||||||
|
"Current": config.Version,
|
||||||
|
"PublishedAt": latest.PublishedAt.Format("2006-01-02 15:04:05"),
|
||||||
|
})
|
||||||
|
ctx.Reply(u, ext.ReplyTextString(text), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
text := i18n.T(i18nk.BotMsgUpdateInfoNewVersionPromptUpgrade, map[string]any{
|
||||||
|
"Latest": latest.Version.String(),
|
||||||
|
"Current": config.Version,
|
||||||
|
"SizeMB": float64(latest.AssetByteSize) / (1024 * 1024),
|
||||||
|
"URL": latest.AssetURL,
|
||||||
|
"PublishedAt": latest.PublishedAt.Format("2006-01-02 15:04:05"),
|
||||||
|
})
|
||||||
|
ctx.Reply(u, ext.ReplyTextString(text), &ext.ReplyOpts{
|
||||||
|
Markup: &tg.ReplyInlineMarkup{
|
||||||
|
Rows: []tg.KeyboardButtonRow{
|
||||||
|
{
|
||||||
|
Buttons: []tg.KeyboardButtonClass{
|
||||||
|
&tg.KeyboardButtonCallback{
|
||||||
|
Text: i18n.T(i18nk.BotMsgUpdateButtonUpgrade, nil),
|
||||||
|
Data: []byte("update"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleUpdateCallback(ctx *ext.Context, u *ext.Update) error {
|
||||||
|
currentV, err := semver.Parse(config.Version)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
ctx.EditMessage(u.GetUserChat().GetID(), &tg.MessagesEditMessageRequest{
|
||||||
|
ID: u.CallbackQuery.GetMsgID(),
|
||||||
|
Message: i18n.T(i18nk.BotMsgUpdateInfoUpgradingWithVersion, map[string]any{
|
||||||
|
"Current": config.Version,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
latest, err := ghselfupdate.UpdateSelf(currentV, config.GitRepo)
|
||||||
|
if err != nil {
|
||||||
|
ctx.EditMessage(u.GetUserChat().GetID(), &tg.MessagesEditMessageRequest{
|
||||||
|
ID: u.CallbackQuery.GetMsgID(),
|
||||||
|
Message: i18n.T(i18nk.BotMsgUpdateErrorUpgradeFailed, map[string]any{
|
||||||
|
"Error": err.Error(),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
ctx.EditMessage(u.GetUserChat().GetID(), &tg.MessagesEditMessageRequest{
|
||||||
|
ID: u.CallbackQuery.GetMsgID(),
|
||||||
|
Message: i18n.T(i18nk.BotMsgUpdateInfoUpgradeSuccess, map[string]any{
|
||||||
|
"Version": latest.Version.String(),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
return errors.New("SAVEANTBOT-RESTART")
|
||||||
|
}
|
||||||
37
client/bot/handlers/utils/dirutil/context.go
Normal file
37
client/bot/handlers/utils/dirutil/context.go
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
package dirutil
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/krau/SaveAny-Bot/database"
|
||||||
|
)
|
||||||
|
|
||||||
|
type contextKey struct{}
|
||||||
|
|
||||||
|
var dirContextKey = contextKey{}
|
||||||
|
|
||||||
|
func WithContext(ctx context.Context, dir *database.Dir) context.Context {
|
||||||
|
if dir == nil {
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
|
return context.WithValue(ctx, dirContextKey, dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
func FromContext(ctx context.Context) *database.Dir {
|
||||||
|
dir, ok := ctx.Value(dirContextKey).(*database.Dir)
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return dir
|
||||||
|
}
|
||||||
|
|
||||||
|
// PathFromContext returns the directory path stored in the context.
|
||||||
|
//
|
||||||
|
// If no directory is found, an empty string is returned.
|
||||||
|
func PathFromContext(ctx context.Context) string {
|
||||||
|
dir := FromContext(ctx)
|
||||||
|
if dir == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return dir.Path
|
||||||
|
}
|
||||||
26
client/bot/handlers/utils/filters/url.go
Normal file
26
client/bot/handlers/utils/filters/url.go
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
package filters
|
||||||
|
|
||||||
|
import (
|
||||||
|
"regexp"
|
||||||
|
"slices"
|
||||||
|
|
||||||
|
"github.com/celestix/gotgproto/dispatcher/handlers/filters"
|
||||||
|
"github.com/celestix/gotgproto/types"
|
||||||
|
"github.com/krau/SaveAny-Bot/common/utils/tgutil"
|
||||||
|
)
|
||||||
|
|
||||||
|
func RegexUrl(r *regexp.Regexp) filters.MessageFilter {
|
||||||
|
return func(m *types.Message) bool {
|
||||||
|
if m.Text == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if r.MatchString(m.Text) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
urls := tgutil.ExtractMessageEntityUrls(m.Message)
|
||||||
|
if len(urls) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return slices.ContainsFunc(urls, r.MatchString)
|
||||||
|
}
|
||||||
|
}
|
||||||
145
client/bot/handlers/utils/mediautil/media.go
Normal file
145
client/bot/handlers/utils/mediautil/media.go
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
package mediautil
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"text/template"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/log"
|
||||||
|
"github.com/gotd/td/tg"
|
||||||
|
"github.com/krau/SaveAny-Bot/common/utils/strutil"
|
||||||
|
"github.com/krau/SaveAny-Bot/common/utils/tgutil"
|
||||||
|
"github.com/krau/SaveAny-Bot/database"
|
||||||
|
"github.com/krau/SaveAny-Bot/pkg/enums/fnamest"
|
||||||
|
"github.com/krau/SaveAny-Bot/pkg/tfile"
|
||||||
|
)
|
||||||
|
|
||||||
|
func IsSupported(media tg.MessageMediaClass) bool {
|
||||||
|
switch media.(type) {
|
||||||
|
case *tg.MessageMediaDocument, *tg.MessageMediaPhoto:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type FilenameTemplateData struct {
|
||||||
|
MsgID string `json:"msgid,omitempty"`
|
||||||
|
MsgTags string `json:"msgtags,omitempty"`
|
||||||
|
MsgGen string `json:"msggen,omitempty"`
|
||||||
|
MsgDate string `json:"msgdate,omitempty"`
|
||||||
|
MsgRaw string `json:"msgraw,omitempty"`
|
||||||
|
OrigName string `json:"origname,omitempty"`
|
||||||
|
ChatID string `json:"chatid,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f FilenameTemplateData) ToMap() map[string]string {
|
||||||
|
return map[string]string{
|
||||||
|
"msgid": f.MsgID,
|
||||||
|
"msgtags": f.MsgTags,
|
||||||
|
"msggen": f.MsgGen,
|
||||||
|
"msgraw": f.MsgRaw,
|
||||||
|
"msgdate": f.MsgDate,
|
||||||
|
"origname": f.OrigName,
|
||||||
|
"chatid": f.ChatID,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TfileOptions(ctx context.Context, user *database.User, message *tg.Message) []tfile.TGFileOption {
|
||||||
|
opts := make([]tfile.TGFileOption, 0)
|
||||||
|
var fnameOpt tfile.TGFileOption
|
||||||
|
switch user.FilenameStrategy {
|
||||||
|
case fnamest.Message.String():
|
||||||
|
fnameOpt = tfile.WithName(tgutil.GenFileNameFromMessage(*message))
|
||||||
|
case fnamest.Template.String():
|
||||||
|
if user.FilenameTemplate == "" {
|
||||||
|
log.FromContext(ctx).Warnf("empty filename template")
|
||||||
|
fnameOpt = tfile.WithNameIfEmpty(tgutil.GenFileNameFromMessage(*message))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
tmpl, err := template.New("filename").Parse(user.FilenameTemplate)
|
||||||
|
if err != nil {
|
||||||
|
log.FromContext(ctx).Errorf("failed to parse filename template: %s", err)
|
||||||
|
fnameOpt = tfile.WithNameIfEmpty(tgutil.GenFileNameFromMessage(*message))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
data := BuildFilenameTemplateData(message)
|
||||||
|
var sb strings.Builder
|
||||||
|
err = tmpl.Execute(&sb, data)
|
||||||
|
if err != nil {
|
||||||
|
log.FromContext(ctx).Errorf("failed to execute filename template: %s", err)
|
||||||
|
fnameOpt = tfile.WithNameIfEmpty(tgutil.GenFileNameFromMessage(*message))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
fnameOpt = tfile.WithName(sb.String())
|
||||||
|
default:
|
||||||
|
fnameOpt = tfile.WithNameIfEmpty(tgutil.GenFileNameFromMessage(*message))
|
||||||
|
}
|
||||||
|
opts = append(opts, fnameOpt, tfile.WithMessage(message))
|
||||||
|
return opts
|
||||||
|
}
|
||||||
|
|
||||||
|
func BuildFilenameTemplateData(message *tg.Message) map[string]string {
|
||||||
|
data := FilenameTemplateData{
|
||||||
|
MsgID: func() string {
|
||||||
|
id := message.GetID()
|
||||||
|
if id == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%d", id)
|
||||||
|
}(),
|
||||||
|
MsgTags: func() string {
|
||||||
|
tags := strutil.ExtractTagsFromText(message.GetMessage())
|
||||||
|
if len(tags) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.Join(tags, "_")
|
||||||
|
}(),
|
||||||
|
MsgGen: tgutil.GenFileNameFromMessage(*message),
|
||||||
|
OrigName: func() string {
|
||||||
|
f, _ := tgutil.GetMediaFileName(message.Media)
|
||||||
|
return f
|
||||||
|
}(),
|
||||||
|
MsgDate: func() string {
|
||||||
|
date := message.GetDate()
|
||||||
|
if date == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
t := time.Unix(int64(date), 0)
|
||||||
|
return t.Format("2006-01-02_15-04-05")
|
||||||
|
}(),
|
||||||
|
MsgRaw: message.GetMessage(),
|
||||||
|
ChatID: func() string {
|
||||||
|
// 如果消息是频道的(从消息链接中fetch的) 直接使用其chat id,
|
||||||
|
// 无论它是否是从其他来源转发的
|
||||||
|
if message.GetPost() {
|
||||||
|
peer := message.GetPeerID()
|
||||||
|
switch p := peer.(type) {
|
||||||
|
case *tg.PeerChannel:
|
||||||
|
return intToStringOmitZero(p.ChannelID)
|
||||||
|
default: // impossible case
|
||||||
|
return intToStringOmitZero(tgutil.ChatIdFromPeer(peer))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fwdHeader, ok := message.GetFwdFrom()
|
||||||
|
if !ok {
|
||||||
|
return intToStringOmitZero(tgutil.ChatIdFromPeer(message.GetPeerID()))
|
||||||
|
}
|
||||||
|
fwdFrom, ok := fwdHeader.GetFromID()
|
||||||
|
if !ok {
|
||||||
|
return intToStringOmitZero(tgutil.ChatIdFromPeer(message.GetPeerID()))
|
||||||
|
}
|
||||||
|
return intToStringOmitZero(tgutil.ChatIdFromPeer(fwdFrom))
|
||||||
|
}(),
|
||||||
|
}.ToMap()
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
func intToStringOmitZero(i int64) string {
|
||||||
|
if i == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%d", i)
|
||||||
|
}
|
||||||
12
client/bot/handlers/utils/msgelem/callback.go
Normal file
12
client/bot/handlers/utils/msgelem/callback.go
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
package msgelem
|
||||||
|
|
||||||
|
import "github.com/gotd/td/tg"
|
||||||
|
|
||||||
|
func AlertCallbackAnswer(queryID int64, text string) *tg.MessagesSetBotCallbackAnswerRequest {
|
||||||
|
return &tg.MessagesSetBotCallbackAnswerRequest{
|
||||||
|
QueryID: queryID,
|
||||||
|
Alert: true,
|
||||||
|
Message: text,
|
||||||
|
CacheTime: 5,
|
||||||
|
}
|
||||||
|
}
|
||||||
38
client/bot/handlers/utils/msgelem/dir.go
Normal file
38
client/bot/handlers/utils/msgelem/dir.go
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
package msgelem
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gotd/td/telegram/message/styling"
|
||||||
|
"github.com/krau/SaveAny-Bot/common/i18n"
|
||||||
|
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
|
||||||
|
"github.com/krau/SaveAny-Bot/database"
|
||||||
|
)
|
||||||
|
|
||||||
|
func BuildDirHelpStyling(dirs []database.Dir) []styling.StyledTextOption {
|
||||||
|
return []styling.StyledTextOption{
|
||||||
|
styling.Bold(i18n.T(i18nk.BotMsgDirHelpUsage, nil)),
|
||||||
|
styling.Plain(i18n.T(i18nk.BotMsgDirHelpAvailableOps, nil)),
|
||||||
|
styling.Code("add"),
|
||||||
|
styling.Plain(i18n.T(i18nk.BotMsgDirHelpAddSuffix, nil)),
|
||||||
|
styling.Code("del"),
|
||||||
|
styling.Plain(i18n.T(i18nk.BotMsgDirHelpDelSuffix, nil)),
|
||||||
|
styling.Plain(i18n.T(i18nk.BotMsgDirHelpAddExamplePrefix, nil)),
|
||||||
|
styling.Code(i18n.T(i18nk.BotMsgDirHelpAddExampleCmd, nil)),
|
||||||
|
styling.Plain(i18n.T(i18nk.BotMsgDirHelpDelExamplePrefix, nil)),
|
||||||
|
styling.Code(i18n.T(i18nk.BotMsgDirHelpDelExampleCmd, nil)),
|
||||||
|
styling.Plain(i18n.T(i18nk.BotMsgDirHelpExistingDirsPrefix, nil)),
|
||||||
|
styling.Blockquote(func() string {
|
||||||
|
var sb strings.Builder
|
||||||
|
for _, dir := range dirs {
|
||||||
|
fmt.Fprintf(&sb, "%d: ", dir.ID)
|
||||||
|
sb.WriteString(dir.StorageName)
|
||||||
|
sb.WriteString(" - ")
|
||||||
|
sb.WriteString(dir.Path)
|
||||||
|
sb.WriteString("\n")
|
||||||
|
}
|
||||||
|
return sb.String()
|
||||||
|
}(), true),
|
||||||
|
}
|
||||||
|
}
|
||||||
41
client/bot/handlers/utils/msgelem/parse.go
Normal file
41
client/bot/handlers/utils/msgelem/parse.go
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
package msgelem
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/duke-git/lancet/v2/strutil"
|
||||||
|
"github.com/gotd/td/telegram/message/entity"
|
||||||
|
"github.com/gotd/td/telegram/message/styling"
|
||||||
|
"github.com/gotd/td/tg"
|
||||||
|
"github.com/krau/SaveAny-Bot/common/i18n"
|
||||||
|
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
|
||||||
|
"github.com/krau/SaveAny-Bot/pkg/parser"
|
||||||
|
)
|
||||||
|
|
||||||
|
func BuildParsedTextEntity(item parser.Item) (string, []tg.MessageEntityClass, error) {
|
||||||
|
eb := entity.Builder{}
|
||||||
|
if err := styling.Perform(&eb,
|
||||||
|
styling.Bold(fmt.Sprintf("[%s]%s", item.Site, item.Title)),
|
||||||
|
styling.Plain(i18n.T(i18nk.BotMsgParseInfoLinkPrefix, nil)),
|
||||||
|
styling.Code(item.URL),
|
||||||
|
styling.Plain(i18n.T(i18nk.BotMsgParseInfoAuthorPrefix, nil)),
|
||||||
|
styling.Code(item.Author),
|
||||||
|
styling.Plain(i18n.T(i18nk.BotMsgParseInfoDescriptionPrefix, nil)),
|
||||||
|
styling.Blockquote(strutil.Ellipsis(item.Description, 233), true),
|
||||||
|
styling.Plain(i18n.T(i18nk.BotMsgParseInfoFileCountPrefix, nil)),
|
||||||
|
styling.Code(fmt.Sprintf("%d", len(item.Resources))),
|
||||||
|
styling.Plain(i18n.T(i18nk.BotMsgParseInfoTotalSizePrefix, nil)),
|
||||||
|
styling.Code(fmt.Sprintf("%.2f MB", func() float64 {
|
||||||
|
var totalSize int64
|
||||||
|
for _, res := range item.Resources {
|
||||||
|
totalSize += res.Size
|
||||||
|
}
|
||||||
|
return float64(totalSize) / 1024 / 1024
|
||||||
|
}())),
|
||||||
|
styling.Plain(i18n.T(i18nk.BotMsgParseInfoPromptSelectStorage, nil)),
|
||||||
|
); err != nil {
|
||||||
|
return "", nil, fmt.Errorf("failed to build parsed text entity: %w", err)
|
||||||
|
}
|
||||||
|
text, entities := eb.Complete()
|
||||||
|
return text, entities, nil
|
||||||
|
}
|
||||||
39
client/bot/handlers/utils/msgelem/rule.go
Normal file
39
client/bot/handlers/utils/msgelem/rule.go
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
package msgelem
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gotd/td/telegram/message/styling"
|
||||||
|
"github.com/krau/SaveAny-Bot/common/i18n"
|
||||||
|
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
|
||||||
|
"github.com/krau/SaveAny-Bot/database"
|
||||||
|
)
|
||||||
|
|
||||||
|
func BuildRuleHelpStyling(enabled bool, rules []database.Rule) []styling.StyledTextOption {
|
||||||
|
return []styling.StyledTextOption{
|
||||||
|
styling.Bold(i18n.T(i18nk.BotMsgRuleHelpUsage, nil)),
|
||||||
|
styling.Bold(func() string {
|
||||||
|
if enabled {
|
||||||
|
return i18n.T(i18nk.BotMsgRuleHelpCurrentModeEnabled, nil)
|
||||||
|
}
|
||||||
|
return i18n.T(i18nk.BotMsgRuleHelpCurrentModeDisabled, nil)
|
||||||
|
}()),
|
||||||
|
styling.Plain(i18n.T(i18nk.BotMsgRuleHelpAvailableOps, nil)),
|
||||||
|
styling.Code("switch"),
|
||||||
|
styling.Plain(i18n.T(i18nk.BotMsgRuleHelpSwitchSuffix, nil)),
|
||||||
|
styling.Code("add"),
|
||||||
|
styling.Plain(i18n.T(i18nk.BotMsgRuleHelpAddSuffix, nil)),
|
||||||
|
styling.Code("del"),
|
||||||
|
styling.Plain(i18n.T(i18nk.BotMsgRuleHelpDelSuffix, nil)),
|
||||||
|
styling.Plain(i18n.T(i18nk.BotMsgRuleHelpExistingRulesPrefix, nil)),
|
||||||
|
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),
|
||||||
|
}
|
||||||
|
}
|
||||||
207
client/bot/handlers/utils/msgelem/storage.go
Normal file
207
client/bot/handlers/utils/msgelem/storage.go
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
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/common/i18n"
|
||||||
|
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
|
||||||
|
"github.com/krau/SaveAny-Bot/database"
|
||||||
|
"github.com/krau/SaveAny-Bot/pkg/enums/tasktype"
|
||||||
|
"github.com/krau/SaveAny-Bot/pkg/tcbdata"
|
||||||
|
"github.com/krau/SaveAny-Bot/pkg/tfile"
|
||||||
|
"github.com/krau/SaveAny-Bot/storage"
|
||||||
|
"github.com/rs/xid"
|
||||||
|
)
|
||||||
|
|
||||||
|
func BuildAddSelectStorageKeyboard(stors []storage.Storage, adddata tcbdata.Add) (*tg.ReplyInlineMarkup, error) {
|
||||||
|
taskType := adddata.TaskType
|
||||||
|
if taskType == "" {
|
||||||
|
if len(adddata.Files) > 0 {
|
||||||
|
taskType = tasktype.TaskTypeTgfiles
|
||||||
|
} else if adddata.TphPageNode != nil {
|
||||||
|
taskType = tasktype.TaskTypeTphpics
|
||||||
|
} else if adddata.ParsedItem != nil {
|
||||||
|
taskType = tasktype.TaskTypeParseditem
|
||||||
|
} else {
|
||||||
|
return nil, fmt.Errorf("unknown task type: %s", taskType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
buttons := make([]tg.KeyboardButtonClass, 0)
|
||||||
|
for _, storage := range stors {
|
||||||
|
data := tcbdata.Add{
|
||||||
|
TaskType: taskType,
|
||||||
|
SelectedStorName: storage.Name(),
|
||||||
|
|
||||||
|
Files: adddata.Files,
|
||||||
|
AsBatch: len(adddata.Files) > 1,
|
||||||
|
|
||||||
|
TphPageNode: adddata.TphPageNode,
|
||||||
|
TphPics: adddata.TphPics,
|
||||||
|
TphDirPath: adddata.TphDirPath,
|
||||||
|
|
||||||
|
ParsedItem: adddata.ParsedItem,
|
||||||
|
|
||||||
|
DirectLinks: adddata.DirectLinks,
|
||||||
|
|
||||||
|
Aria2URIs: adddata.Aria2URIs,
|
||||||
|
YtdlpURLs: adddata.YtdlpURLs,
|
||||||
|
YtdlpFlags: adddata.YtdlpFlags,
|
||||||
|
}
|
||||||
|
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 := i18n.T(i18nk.BotMsgTasksInfoAddedToQueueFull, map[string]any{
|
||||||
|
"Filename": file.Name(),
|
||||||
|
"QueueLength": 0,
|
||||||
|
})
|
||||||
|
if err := styling.Perform(&eb,
|
||||||
|
styling.Plain(i18n.T(i18nk.BotMsgStorageInfoFilenamePrefix, nil)),
|
||||||
|
styling.Code(file.Name()),
|
||||||
|
styling.Plain(i18n.T(i18nk.BotMsgStorageInfoPromptSelectStorage, nil)),
|
||||||
|
); err != nil {
|
||||||
|
log.FromContext(ctx).Errorf("Failed to build entity: %s", err)
|
||||||
|
} else {
|
||||||
|
text, entities = eb.Complete()
|
||||||
|
}
|
||||||
|
markup, err := BuildAddSelectStorageKeyboard(stors, tcbdata.Add{
|
||||||
|
TaskType: tasktype.TaskTypeTgfiles,
|
||||||
|
Files: []tfile.TGFileMessage{file},
|
||||||
|
AsBatch: false,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to build storage keyboard: %w", err)
|
||||||
|
}
|
||||||
|
return &tg.MessagesEditMessageRequest{
|
||||||
|
Message: text,
|
||||||
|
Entities: entities,
|
||||||
|
ReplyMarkup: markup,
|
||||||
|
ID: msgId,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Builds the inline keyboard for setting default storage
|
||||||
|
func BuildSetDefaultStorageMarkup(
|
||||||
|
ctx context.Context,
|
||||||
|
stors []storage.Storage) (*tg.ReplyInlineMarkup, error) {
|
||||||
|
buttons := make([]tg.KeyboardButtonClass, 0)
|
||||||
|
for _, storage := range stors {
|
||||||
|
data := tcbdata.SetDefaultStorage{
|
||||||
|
StorageName: storage.Name(),
|
||||||
|
}
|
||||||
|
dataid := xid.New().String()
|
||||||
|
err := cache.Set(dataid, data)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
buttons = append(buttons, &tg.KeyboardButtonCallback{
|
||||||
|
Text: storage.Name(),
|
||||||
|
Data: fmt.Appendf(nil, "%s %s", tcbdata.TypeSetDefault, dataid),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
markup := &tg.ReplyInlineMarkup{}
|
||||||
|
for i := 0; i < len(buttons); i += 3 {
|
||||||
|
row := tg.KeyboardButtonRow{}
|
||||||
|
row.Buttons = buttons[i:min(i+3, len(buttons))]
|
||||||
|
markup.Rows = append(markup.Rows, row)
|
||||||
|
}
|
||||||
|
return markup, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func BuildSetDefaultDirMarkup(ctx context.Context,
|
||||||
|
seletedStorage string,
|
||||||
|
dirs []database.Dir) (*tg.ReplyInlineMarkup, error) {
|
||||||
|
buttons := make([]tg.KeyboardButtonClass, 0)
|
||||||
|
for _, dir := range dirs {
|
||||||
|
dataid := xid.New().String()
|
||||||
|
data := tcbdata.SetDefaultStorage{
|
||||||
|
StorageName: seletedStorage,
|
||||||
|
DirID: dir.ID,
|
||||||
|
}
|
||||||
|
err := cache.Set(dataid, data)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
buttons = append(buttons, &tg.KeyboardButtonCallback{
|
||||||
|
Text: dir.Path,
|
||||||
|
Data: fmt.Appendf(nil, "%s %s", tcbdata.TypeSetDefault, dataid),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
markup := &tg.ReplyInlineMarkup{}
|
||||||
|
for i := 0; i < len(buttons); i += 3 {
|
||||||
|
row := tg.KeyboardButtonRow{}
|
||||||
|
row.Buttons = buttons[i:min(i+3, len(buttons))]
|
||||||
|
markup.Rows = append(markup.Rows, row)
|
||||||
|
}
|
||||||
|
return markup, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func BuildSetDirMarkupForAdd(dirs []database.Dir, dataid string) (*tg.ReplyInlineMarkup, error) {
|
||||||
|
data, ok := cache.Get[tcbdata.Add](dataid)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("failed to get data from cache: %s", dataid)
|
||||||
|
}
|
||||||
|
if data.DirID != 0 || data.SettedDir {
|
||||||
|
log.Warnf("Data already has a directory set: %d, %t", data.DirID, data.SettedDir)
|
||||||
|
return nil, fmt.Errorf("data already has a directory set")
|
||||||
|
}
|
||||||
|
buttons := make([]tg.KeyboardButtonClass, 0)
|
||||||
|
for _, dir := range dirs {
|
||||||
|
dirDataId := xid.New().String()
|
||||||
|
dirData := data
|
||||||
|
dirData.DirID = dir.ID
|
||||||
|
dirData.SettedDir = true
|
||||||
|
err := cache.Set(dirDataId, dirData)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to set directory data in cache: %w", err)
|
||||||
|
}
|
||||||
|
buttons = append(buttons, &tg.KeyboardButtonCallback{
|
||||||
|
Text: dir.Path,
|
||||||
|
Data: fmt.Appendf(nil, "%s %s", tcbdata.TypeAdd, dirDataId),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
dirDefaultDataId := xid.New().String()
|
||||||
|
dirDefaultData := data
|
||||||
|
dirDefaultData.DirID = 0
|
||||||
|
dirDefaultData.SettedDir = true
|
||||||
|
err := cache.Set(dirDefaultDataId, dirDefaultData)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to set default directory data in cache: %w", err)
|
||||||
|
}
|
||||||
|
buttons = append(buttons, &tg.KeyboardButtonCallback{
|
||||||
|
Text: i18n.T(i18nk.BotMsgDirButtonDefault, nil),
|
||||||
|
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
|
||||||
|
}
|
||||||
38
client/bot/handlers/utils/msgelem/task.go
Normal file
38
client/bot/handlers/utils/msgelem/task.go
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
package msgelem
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"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/i18n"
|
||||||
|
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
|
||||||
|
)
|
||||||
|
|
||||||
|
func BuildTaskAddedEntities(
|
||||||
|
ctx context.Context,
|
||||||
|
filename string,
|
||||||
|
queueLength int,
|
||||||
|
) (string, []tg.MessageEntityClass) {
|
||||||
|
entityBuilder := entity.Builder{}
|
||||||
|
var entities []tg.MessageEntityClass
|
||||||
|
text := i18n.T(i18nk.BotMsgTasksInfoAddedToQueueFull, map[string]any{
|
||||||
|
"Filename": filename,
|
||||||
|
"QueueLength": queueLength,
|
||||||
|
})
|
||||||
|
if err := styling.Perform(&entityBuilder,
|
||||||
|
styling.Plain(i18n.T(i18nk.BotMsgTasksInfoAddedToQueuePrefix, nil)),
|
||||||
|
styling.Plain(i18n.T(i18nk.BotMsgTasksInfoFilenamePrefix, nil)),
|
||||||
|
styling.Code(filename),
|
||||||
|
styling.Plain(i18n.T(i18nk.BotMsgTasksInfoQueueLengthPrefix, nil)),
|
||||||
|
styling.Bold(strconv.Itoa(queueLength)),
|
||||||
|
); err != nil {
|
||||||
|
log.FromContext(ctx).Errorf("Failed to build entity: %s", err)
|
||||||
|
} else {
|
||||||
|
text, entities = entityBuilder.Complete()
|
||||||
|
}
|
||||||
|
return text, entities
|
||||||
|
}
|
||||||
10
client/bot/handlers/utils/re/regexp.go
Normal file
10
client/bot/handlers/utils/re/regexp.go
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
package re
|
||||||
|
|
||||||
|
import "regexp"
|
||||||
|
|
||||||
|
var (
|
||||||
|
TgMessageLinkRegexString = `https?://t\.me/(?:c/\d+|[A-Za-z0-9_]+)/\d+(?:/\d+)?(?:\?[^\s#]*[A-Za-z0-9_])?\b`
|
||||||
|
TgMessageLinkRegexp = regexp.MustCompile(TgMessageLinkRegexString)
|
||||||
|
TelegraphUrlRegexString = `https://telegra.ph/.*`
|
||||||
|
TelegraphUrlRegexp = regexp.MustCompile(TelegraphUrlRegexString)
|
||||||
|
)
|
||||||
113
client/bot/handlers/utils/ruleutil/rule.go
Normal file
113
client/bot/handlers/utils/ruleutil/rule.go
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
package ruleutil
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/duke-git/lancet/v2/convertor"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/log"
|
||||||
|
"github.com/krau/SaveAny-Bot/database"
|
||||||
|
"github.com/krau/SaveAny-Bot/pkg/rule"
|
||||||
|
"github.com/krau/SaveAny-Bot/pkg/tfile"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ruleInput struct {
|
||||||
|
File tfile.TGFileMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
type ruleInputOption func(*ruleInput)
|
||||||
|
|
||||||
|
func NewInput(file tfile.TGFileMessage, opts ...ruleInputOption) *ruleInput {
|
||||||
|
input := &ruleInput{
|
||||||
|
File: file,
|
||||||
|
}
|
||||||
|
for _, opt := range opts {
|
||||||
|
opt(input)
|
||||||
|
}
|
||||||
|
return input
|
||||||
|
}
|
||||||
|
|
||||||
|
type matchedStorName string
|
||||||
|
|
||||||
|
func (m matchedStorName) String() string {
|
||||||
|
return string(m)
|
||||||
|
}
|
||||||
|
|
||||||
|
// can we use this storage name directly?
|
||||||
|
func (m matchedStorName) Usable() bool {
|
||||||
|
return m != "" && m != rule.RuleStorNameChosen
|
||||||
|
}
|
||||||
|
|
||||||
|
type MatchedDirPath string
|
||||||
|
|
||||||
|
func (m MatchedDirPath) String() string {
|
||||||
|
return string(m)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m MatchedDirPath) NeedNewForAlbum() bool {
|
||||||
|
return m != "" && m == rule.RuleDirPathNewForAlbum
|
||||||
|
}
|
||||||
|
|
||||||
|
func ApplyRule(ctx context.Context, rules []database.Rule, inputs *ruleInput) (matched bool, matchedStorageName matchedStorName, dirPath MatchedDirPath) {
|
||||||
|
if inputs == nil || len(rules) == 0 {
|
||||||
|
return false, "", ""
|
||||||
|
}
|
||||||
|
logger := log.FromContext(ctx)
|
||||||
|
for _, ur := range rules {
|
||||||
|
switch ur.Type {
|
||||||
|
case rule.FileNameRegex.String():
|
||||||
|
ru, err := rule.NewRuleFileNameRegex(ur.StorageName, ur.DirPath, ur.Data)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("Failed to create rule: %s", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ok, err := ru.Match(inputs.File)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("Failed to match rule: %s", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if ok {
|
||||||
|
dirPath = MatchedDirPath(ru.StoragePath())
|
||||||
|
matchedStorageName = matchedStorName(ru.StorageName())
|
||||||
|
}
|
||||||
|
case rule.MessageRegex.String():
|
||||||
|
ru, err := rule.NewRuleMessageRegex(ur.StorageName, ur.DirPath, ur.Data)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("Failed to create rule: %s", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ok, err := ru.Match(inputs.File.Message().GetMessage())
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("Failed to match rule: %s", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if ok {
|
||||||
|
dirPath = MatchedDirPath(ru.StoragePath())
|
||||||
|
matchedStorageName = matchedStorName(ru.StorageName())
|
||||||
|
}
|
||||||
|
case rule.IsAlbum.String():
|
||||||
|
matchAlbum, err := convertor.ToBool(ur.Data)
|
||||||
|
if err != nil {
|
||||||
|
matchAlbum = false
|
||||||
|
}
|
||||||
|
ru, err := rule.NewRuleMediaType(ur.StorageName, ur.DirPath, matchAlbum)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("Failed to create rule: %s", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ok, err := ru.Match(inputs.File.Message().GroupedID != 0)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("Failed to match rule: %s", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if ok {
|
||||||
|
dirPath = MatchedDirPath(ru.StoragePath())
|
||||||
|
matchedStorageName = matchedStorName(ru.StorageName())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if matchedStorageName != "" || dirPath != "" {
|
||||||
|
return true, matchedStorageName, dirPath
|
||||||
|
}
|
||||||
|
return false, "", ""
|
||||||
|
}
|
||||||
65
client/bot/handlers/utils/shortcut/aria2.go
Normal file
65
client/bot/handlers/utils/shortcut/aria2.go
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
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/common/i18n"
|
||||||
|
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
|
||||||
|
"github.com/krau/SaveAny-Bot/common/utils/tgutil"
|
||||||
|
"github.com/krau/SaveAny-Bot/core"
|
||||||
|
"github.com/krau/SaveAny-Bot/core/tasks/aria2dl"
|
||||||
|
"github.com/krau/SaveAny-Bot/pkg/aria2"
|
||||||
|
"github.com/krau/SaveAny-Bot/storage"
|
||||||
|
"github.com/rs/xid"
|
||||||
|
)
|
||||||
|
|
||||||
|
func CreateAndAddAria2TaskWithEdit(ctx *ext.Context, stor storage.Storage, dirPath string, uris []string, aria2Client *aria2.Client, msgID int, userID int64) error {
|
||||||
|
logger := log.FromContext(ctx)
|
||||||
|
injectCtx := tgutil.ExtWithContext(ctx.Context, ctx)
|
||||||
|
|
||||||
|
// Now add to aria2 after user selected storage
|
||||||
|
logger.Infof("Adding download to aria2, uris type: %T, value: %+v", uris, uris)
|
||||||
|
|
||||||
|
// Ensure uris is valid
|
||||||
|
if len(uris) == 0 {
|
||||||
|
logger.Error("URIs list is empty")
|
||||||
|
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
|
||||||
|
ID: msgID,
|
||||||
|
Message: i18n.T(i18nk.BotMsgDlErrorNoValidLinks, nil),
|
||||||
|
})
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
|
||||||
|
gid, err := aria2Client.AddURI(ctx, uris, nil)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("Failed to add aria2 download: %s", err)
|
||||||
|
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
|
||||||
|
ID: msgID,
|
||||||
|
Message: i18n.T(i18nk.BotMsgAria2ErrorAddingAria2Download, map[string]any{
|
||||||
|
"Error": err.Error(),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
logger.Infof("Aria2 download added with GID: %s", gid)
|
||||||
|
|
||||||
|
// Create task with the GID
|
||||||
|
task := aria2dl.NewTask(xid.New().String(), injectCtx, gid, uris, aria2Client, stor, stor.JoinStoragePath(dirPath), aria2dl.NewProgress(msgID, userID))
|
||||||
|
if err := core.AddTask(injectCtx, task); err != nil {
|
||||||
|
logger.Errorf("Failed to add task: %s", err)
|
||||||
|
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
|
||||||
|
ID: msgID,
|
||||||
|
Message: i18n.T(i18nk.BotMsgCommonErrorTaskAddFailed, map[string]any{
|
||||||
|
"Error": err.Error(),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
|
||||||
|
ID: msgID,
|
||||||
|
Message: i18n.T(i18nk.BotMsgCommonInfoTaskAdded, nil),
|
||||||
|
})
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
34
client/bot/handlers/utils/shortcut/directlinks.go
Normal file
34
client/bot/handlers/utils/shortcut/directlinks.go
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
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/common/i18n"
|
||||||
|
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
|
||||||
|
"github.com/krau/SaveAny-Bot/common/utils/tgutil"
|
||||||
|
"github.com/krau/SaveAny-Bot/core"
|
||||||
|
"github.com/krau/SaveAny-Bot/core/tasks/directlinks"
|
||||||
|
"github.com/krau/SaveAny-Bot/storage"
|
||||||
|
"github.com/rs/xid"
|
||||||
|
)
|
||||||
|
|
||||||
|
func CreateAndAddDirectTaskWithEdit(ctx *ext.Context, stor storage.Storage, dirPath string, links []string, msgID int, userID int64) error {
|
||||||
|
injectCtx := tgutil.ExtWithContext(ctx.Context, ctx)
|
||||||
|
task := directlinks.NewTask(xid.New().String(), injectCtx, links, stor, stor.JoinStoragePath(dirPath), directlinks.NewProgress(msgID, userID))
|
||||||
|
if err := core.AddTask(injectCtx, task); err != nil {
|
||||||
|
log.FromContext(ctx).Errorf("Failed to add task: %s", err)
|
||||||
|
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
|
||||||
|
ID: msgID,
|
||||||
|
Message: i18n.T(i18nk.BotMsgCommonErrorTaskAddFailed, map[string]any{
|
||||||
|
"Error": err.Error(),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
|
||||||
|
Message: i18n.T(i18nk.BotMsgCommonInfoTaskAdded, nil),
|
||||||
|
})
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
250
client/bot/handlers/utils/shortcut/message.go
Normal file
250
client/bot/handlers/utils/shortcut/message.go
Normal file
@@ -0,0 +1,250 @@
|
|||||||
|
// Some shortcuts for duplicate code in handlers, they should return dispatcher errors
|
||||||
|
package shortcut
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/celestix/gotgproto/dispatcher"
|
||||||
|
"github.com/celestix/gotgproto/ext"
|
||||||
|
"github.com/celestix/gotgproto/types"
|
||||||
|
"github.com/charmbracelet/log"
|
||||||
|
"github.com/gotd/td/telegram/downloader"
|
||||||
|
"github.com/gotd/td/tg"
|
||||||
|
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/mediautil"
|
||||||
|
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/msgelem"
|
||||||
|
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/re"
|
||||||
|
uc "github.com/krau/SaveAny-Bot/client/user"
|
||||||
|
"github.com/krau/SaveAny-Bot/common/cache"
|
||||||
|
"github.com/krau/SaveAny-Bot/common/i18n"
|
||||||
|
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
|
||||||
|
"github.com/krau/SaveAny-Bot/common/utils/tgutil"
|
||||||
|
"github.com/krau/SaveAny-Bot/common/utils/tphutil"
|
||||||
|
"github.com/krau/SaveAny-Bot/config"
|
||||||
|
"github.com/krau/SaveAny-Bot/database"
|
||||||
|
"github.com/krau/SaveAny-Bot/pkg/telegraph"
|
||||||
|
"github.com/krau/SaveAny-Bot/pkg/tfile"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 获取消息中的文件并回复等待消息, 返回等待消息, 获取到的文件
|
||||||
|
func GetFileFromMessageWithReply(ctx *ext.Context, update *ext.Update, message *tg.Message, tfileopts ...tfile.TGFileOption) (replied *types.Message,
|
||||||
|
file tfile.TGFileMessage, err error,
|
||||||
|
) {
|
||||||
|
logger := log.FromContext(ctx)
|
||||||
|
media := message.Media
|
||||||
|
supported := mediautil.IsSupported(media)
|
||||||
|
if !supported {
|
||||||
|
return nil, nil, dispatcher.ContinueGroups
|
||||||
|
}
|
||||||
|
|
||||||
|
replied, err = ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgCommonInfoFetchingFileInfo, nil)), nil)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("Failed to reply: %s", err)
|
||||||
|
return nil, nil, dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
// options := []tfile.TGFileOption{
|
||||||
|
// tfile.WithMessage(message),
|
||||||
|
// }
|
||||||
|
// if len(tfileopts) > 0 {
|
||||||
|
// options = append(options, tfileopts...)
|
||||||
|
// } else {
|
||||||
|
// options = append(options, tfile.WithNameIfEmpty(tgutil.GenFileNameFromMessage(*message)))
|
||||||
|
// }
|
||||||
|
file, err = tfile.FromMediaMessage(media, ctx.Raw, message, tfileopts...)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("Failed to get file from media: %s", err)
|
||||||
|
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgCommonErrorGetFileFailed, map[string]any{
|
||||||
|
"Error": err.Error(),
|
||||||
|
})), nil)
|
||||||
|
return nil, nil, dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
return replied, file, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type EditMessageFunc func(text string, markup tg.ReplyMarkupClass)
|
||||||
|
|
||||||
|
// 获取链接中的文件并回复等待消息
|
||||||
|
func GetFilesFromUpdateLinkMessageWithReplyEdit(ctx *ext.Context, update *ext.Update) (replied *types.Message, files []tfile.TGFileMessage, editReplied EditMessageFunc, err error) {
|
||||||
|
logger := log.FromContext(ctx)
|
||||||
|
msgLinks := re.TgMessageLinkRegexp.FindAllString(tgutil.ExtractMessageEntityUrlsText(update.EffectiveMessage.Message), -1)
|
||||||
|
if len(msgLinks) == 0 {
|
||||||
|
logger.Warn("no matched message links but called handleMessageLink")
|
||||||
|
return nil, nil, nil, dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
replied, err = ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgCommonInfoFetchingMessages, nil)), nil)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("failed to reply: %s", err)
|
||||||
|
return nil, nil, nil, dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
editReplied = func(text string, markup tg.ReplyMarkupClass) {
|
||||||
|
if _, err := ctx.EditMessage(update.EffectiveChat().GetID(), &tg.MessagesEditMessageRequest{
|
||||||
|
ID: replied.ID,
|
||||||
|
Message: text,
|
||||||
|
ReplyMarkup: markup,
|
||||||
|
}); err != nil {
|
||||||
|
logger.Errorf("failed to edit message: %s", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
user, err := database.GetUserByChatID(ctx, update.GetUserChat().GetID())
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("failed to get user from db: %s", err)
|
||||||
|
editReplied(i18n.T(i18nk.BotMsgCommonErrorGetUserInfoFailed, map[string]any{
|
||||||
|
"Error": err.Error(),
|
||||||
|
}), nil)
|
||||||
|
return nil, nil, nil, dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
files = make([]tfile.TGFileMessage, 0, len(msgLinks))
|
||||||
|
addFile := func(client downloader.Client, msg *tg.Message) {
|
||||||
|
if msg == nil || msg.Media == nil {
|
||||||
|
logger.Warn("message is nil, skipping")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
media, ok := msg.GetMedia()
|
||||||
|
if !ok {
|
||||||
|
logger.Debugf("message %d has no media", msg.GetID())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
opts := mediautil.TfileOptions(ctx, user, msg)
|
||||||
|
file, err := tfile.FromMediaMessage(media, client, msg, opts...)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("failed to create file from media: %s", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
files = append(files, file)
|
||||||
|
}
|
||||||
|
|
||||||
|
tctx := ctx
|
||||||
|
if config.C().Telegram.Userbot.Enable {
|
||||||
|
if uc.GetCtx() != nil {
|
||||||
|
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.Error(err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
groupID, isGroup := msg.GetGroupedID()
|
||||||
|
if isGroup && groupID != 0 && !linkUrl.Query().Has("single") {
|
||||||
|
gmsgs, err := tgutil.GetGroupedMessages(tctx, chatId, msg)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("failed to get grouped messages: %s", err)
|
||||||
|
} else {
|
||||||
|
for _, gmsg := range gmsgs {
|
||||||
|
addFile(tctx.Raw, gmsg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
addFile(tctx.Raw, msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(files) == 0 {
|
||||||
|
editReplied(i18n.T(i18nk.BotMsgCommonErrorNoSavableFilesFound, nil), 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, i18n.T(i18nk.BotMsgCommonErrorDataExpired, nil)))
|
||||||
|
var zero DataType
|
||||||
|
return zero, dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type TelegraphResult struct {
|
||||||
|
Pics []string `json:"pics"` // image urls
|
||||||
|
TphDir string `json:"tph_dir"` // telegraph path, unescaped
|
||||||
|
Page *telegraph.Page `json:"page"` // telegraph page node
|
||||||
|
}
|
||||||
|
|
||||||
|
// return replied message, image urls, telegraph path(unescaped), error
|
||||||
|
func GetTphPicsFromMessageWithReply(ctx *ext.Context, update *ext.Update) (*types.Message, *TelegraphResult, error) {
|
||||||
|
logger := log.FromContext(ctx)
|
||||||
|
tphurl := re.TelegraphUrlRegexp.FindString(tgutil.ExtractMessageEntityUrlsText(update.EffectiveMessage.Message))
|
||||||
|
if tphurl == "" {
|
||||||
|
logger.Warnf("No telegraph url found but called handleTelegraph")
|
||||||
|
return nil, nil, dispatcher.ContinueGroups
|
||||||
|
}
|
||||||
|
pagepath := strings.Split(tphurl, "/")[len(strings.Split(tphurl, "/"))-1]
|
||||||
|
tphdir, err := url.PathUnescape(pagepath)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("Failed to unescape telegraph path: %s", err)
|
||||||
|
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgCommonErrorParseTelegraphPathFailed, map[string]any{
|
||||||
|
"Error": err.Error(),
|
||||||
|
})), nil)
|
||||||
|
return nil, nil, dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
tphdir = strings.TrimSpace(tphdir)
|
||||||
|
msg, err := ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgCommonInfoFetchingTelegraphPage, nil)), nil)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("Failed to reply to update: %s", err)
|
||||||
|
return nil, nil, dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
logger.Debugf("Fetching telegraph page: %s", pagepath)
|
||||||
|
page, err := tphutil.DefaultClient().GetPage(ctx, pagepath)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("Failed to get telegraph page: %s", err)
|
||||||
|
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgCommonErrorGetTelegraphPageFailed, map[string]any{
|
||||||
|
"Error": err.Error(),
|
||||||
|
})), nil)
|
||||||
|
return nil, nil, dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
imgs := make([]string, 0)
|
||||||
|
for _, elem := range page.Content {
|
||||||
|
var node telegraph.NodeElement
|
||||||
|
data, err := json.Marshal(elem)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("Failed to marshal element: %s", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
err = json.Unmarshal(data, &node)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("Failed to unmarshal element: %s", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(node.Children) != 0 {
|
||||||
|
for _, child := range node.Children {
|
||||||
|
imgs = append(imgs, tphutil.GetNodeImages(child)...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if node.Tag == "img" {
|
||||||
|
if src, ok := node.Attrs["src"]; ok {
|
||||||
|
if strings.HasPrefix(src, "/file/") {
|
||||||
|
// handle images on telegra.ph server
|
||||||
|
src = "https://telegra.ph" + src
|
||||||
|
}
|
||||||
|
imgs = append(imgs, src)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(imgs) == 0 {
|
||||||
|
logger.Warn("No images found in telegraph page")
|
||||||
|
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgCommonErrorNoImagesInTelegraphPage, nil)), nil)
|
||||||
|
return nil, nil, dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
return msg, &TelegraphResult{
|
||||||
|
Pics: imgs,
|
||||||
|
TphDir: tphdir,
|
||||||
|
Page: page,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
39
client/bot/handlers/utils/shortcut/parsed.go
Normal file
39
client/bot/handlers/utils/shortcut/parsed.go
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
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/i18n"
|
||||||
|
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
|
||||||
|
"github.com/krau/SaveAny-Bot/common/utils/tgutil"
|
||||||
|
"github.com/krau/SaveAny-Bot/core"
|
||||||
|
parsed "github.com/krau/SaveAny-Bot/core/tasks/parsed"
|
||||||
|
"github.com/krau/SaveAny-Bot/pkg/parser"
|
||||||
|
"github.com/krau/SaveAny-Bot/storage"
|
||||||
|
"github.com/rs/xid"
|
||||||
|
)
|
||||||
|
|
||||||
|
func CreateAndAddParsedTaskWithEdit(ctx *ext.Context, stor storage.Storage, dirPath string, item *parser.Item, msgID int, userID int64) error {
|
||||||
|
injectCtx := tgutil.ExtWithContext(ctx.Context, ctx)
|
||||||
|
task := parsed.NewTask(xid.New().String(), injectCtx, stor, stor.JoinStoragePath(dirPath), item, parsed.NewProgress(msgID, userID))
|
||||||
|
if err := core.AddTask(injectCtx, task); err != nil {
|
||||||
|
log.FromContext(ctx).Errorf("Failed to add task: %s", err)
|
||||||
|
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
|
||||||
|
ID: msgID,
|
||||||
|
Message: i18n.T(i18nk.BotMsgCommonErrorTaskAddFailed, map[string]any{
|
||||||
|
"Error": err.Error(),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
text, entities := msgelem.BuildTaskAddedEntities(ctx, item.Title, core.GetLength(ctx))
|
||||||
|
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
|
||||||
|
ID: msgID,
|
||||||
|
Message: text,
|
||||||
|
Entities: entities,
|
||||||
|
})
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
228
client/bot/handlers/utils/shortcut/tftask.go
Normal file
228
client/bot/handlers/utils/shortcut/tftask.go
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
package shortcut
|
||||||
|
|
||||||
|
import (
|
||||||
|
"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/i18n"
|
||||||
|
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
|
||||||
|
"github.com/krau/SaveAny-Bot/common/utils/tgutil"
|
||||||
|
"github.com/krau/SaveAny-Bot/core"
|
||||||
|
"github.com/krau/SaveAny-Bot/core/tasks/batchtfile"
|
||||||
|
tftask "github.com/krau/SaveAny-Bot/core/tasks/tfile"
|
||||||
|
"github.com/krau/SaveAny-Bot/database"
|
||||||
|
"github.com/krau/SaveAny-Bot/pkg/tfile"
|
||||||
|
"github.com/krau/SaveAny-Bot/storage"
|
||||||
|
"github.com/rs/xid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 创建一个 tfile.TGFileTask 并添加到任务队列中, 以编辑消息的方式反馈结果
|
||||||
|
func CreateAndAddTGFileTaskWithEdit(ctx *ext.Context, userID int64, stor storage.Storage, dirPath string, file tfile.TGFileMessage, trackMsgID int) error {
|
||||||
|
logger := log.FromContext(ctx)
|
||||||
|
user, err := database.GetUserByChatID(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("Failed to get user by chat ID: %s", err)
|
||||||
|
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
|
||||||
|
ID: trackMsgID,
|
||||||
|
Message: i18n.T(i18nk.BotMsgCommonErrorGetUserWithErrFailed, map[string]any{
|
||||||
|
"Error": err.Error(),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
if user.ApplyRule && user.Rules != nil {
|
||||||
|
matched, matchedStorageName, matchedDirPath := ruleutil.ApplyRule(ctx, user.Rules, ruleutil.NewInput(file))
|
||||||
|
if !matched {
|
||||||
|
goto startCreateTask
|
||||||
|
}
|
||||||
|
if matchedDirPath != "" {
|
||||||
|
dirPath = matchedDirPath.String()
|
||||||
|
}
|
||||||
|
if matchedStorageName.Usable() {
|
||||||
|
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: i18n.T(i18nk.BotMsgCommonErrorGetStorageFailed, map[string]any{
|
||||||
|
"Error": err.Error(),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
startCreateTask:
|
||||||
|
storagePath := stor.JoinStoragePath(path.Join(dirPath, file.Name()))
|
||||||
|
injectCtx := tgutil.ExtWithContext(ctx.Context, ctx)
|
||||||
|
taskid := xid.New().String()
|
||||||
|
task, err := tftask.NewTGFileTask(taskid, injectCtx, file, stor, storagePath,
|
||||||
|
tftask.NewProgressTrack(
|
||||||
|
trackMsgID,
|
||||||
|
userID))
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("create task failed: %s", err)
|
||||||
|
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
|
||||||
|
ID: trackMsgID,
|
||||||
|
Message: i18n.T(i18nk.BotMsgCommonErrorTaskCreateFailed, map[string]any{
|
||||||
|
"Error": 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: i18n.T(i18nk.BotMsgCommonErrorTaskAddFailed, map[string]any{
|
||||||
|
"Error": err.Error(),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
text, entities := msgelem.BuildTaskAddedEntities(ctx, file.Name(), core.GetLength(injectCtx))
|
||||||
|
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
|
||||||
|
ID: trackMsgID,
|
||||||
|
Message: text,
|
||||||
|
Entities: entities,
|
||||||
|
})
|
||||||
|
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建一个 batchtfile.BatchTGFileTask 并添加到任务队列中, 以编辑消息的方式反馈结果
|
||||||
|
func CreateAndAddBatchTGFileTaskWithEdit(ctx *ext.Context, userID int64, stor storage.Storage, dirPath string, files []tfile.TGFileMessage, trackMsgID int) error {
|
||||||
|
logger := log.FromContext(ctx)
|
||||||
|
user, err := database.GetUserByChatID(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("Failed to get user by chat ID: %s", err)
|
||||||
|
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
|
||||||
|
ID: trackMsgID,
|
||||||
|
Message: i18n.T(i18nk.BotMsgCommonErrorGetUserWithErrFailed, map[string]any{
|
||||||
|
"Error": err.Error(),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
|
||||||
|
useRule := user.ApplyRule && user.Rules != nil
|
||||||
|
|
||||||
|
applyRule := func(file tfile.TGFileMessage) (string, ruleutil.MatchedDirPath) {
|
||||||
|
if !useRule {
|
||||||
|
return stor.Name(), ruleutil.MatchedDirPath(dirPath)
|
||||||
|
}
|
||||||
|
matched, storName, dirP := ruleutil.ApplyRule(ctx, user.Rules, ruleutil.NewInput(file))
|
||||||
|
if !matched {
|
||||||
|
return stor.Name(), ruleutil.MatchedDirPath(dirPath)
|
||||||
|
}
|
||||||
|
storname := storName.String()
|
||||||
|
if !storName.Usable() {
|
||||||
|
storname = stor.Name()
|
||||||
|
}
|
||||||
|
return storname, dirP
|
||||||
|
}
|
||||||
|
|
||||||
|
elems := make([]batchtfile.TaskElement, 0, len(files))
|
||||||
|
type albumFile struct {
|
||||||
|
file tfile.TGFileMessage
|
||||||
|
storage storage.Storage
|
||||||
|
}
|
||||||
|
albumFiles := make(map[int64][]albumFile, 0)
|
||||||
|
for _, file := range files {
|
||||||
|
storName, dirPath := applyRule(file)
|
||||||
|
fileStor := stor
|
||||||
|
if storName != stor.Name() && storName != "" {
|
||||||
|
fileStor, err = storage.GetStorageByUserIDAndName(ctx, user.ChatID, storName)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("Failed to get storage by user ID and name: %s", err)
|
||||||
|
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
|
||||||
|
ID: trackMsgID,
|
||||||
|
Message: i18n.T(i18nk.BotMsgCommonErrorGetStorageFailed, map[string]any{
|
||||||
|
"Error": err.Error(),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !dirPath.NeedNewForAlbum() {
|
||||||
|
storPath := fileStor.JoinStoragePath(path.Join(dirPath.String(), file.Name()))
|
||||||
|
elem, err := batchtfile.NewTaskElement(fileStor, storPath, file)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("Failed to create task element: %s", err)
|
||||||
|
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
|
||||||
|
ID: trackMsgID,
|
||||||
|
Message: i18n.T(i18nk.BotMsgCommonErrorTaskCreateFailed, map[string]any{
|
||||||
|
"Error": err.Error(),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
elems = append(elems, *elem)
|
||||||
|
} else {
|
||||||
|
groupId, isGroup := file.Message().GetGroupedID()
|
||||||
|
if !isGroup || groupId == 0 {
|
||||||
|
logger.Warnf("File %s is not in a group, skipping album handling", file.Name())
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := albumFiles[groupId]; !ok {
|
||||||
|
albumFiles[groupId] = make([]albumFile, 0)
|
||||||
|
}
|
||||||
|
albumFiles[groupId] = append(albumFiles[groupId], albumFile{
|
||||||
|
file: file,
|
||||||
|
storage: fileStor,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, afiles := range albumFiles {
|
||||||
|
if len(afiles) <= 1 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// 对于需要新建目录的文件, 将第一个文件的文件名(去除扩展名)作为目录名
|
||||||
|
// 存储以第一个文件的存储为准
|
||||||
|
albumDir := strings.TrimSuffix(path.Base(afiles[0].file.Name()), path.Ext(afiles[0].file.Name()))
|
||||||
|
albumStor := afiles[0].storage
|
||||||
|
for _, af := range afiles {
|
||||||
|
afstorPath := af.storage.JoinStoragePath(path.Join(dirPath, albumDir, af.file.Name()))
|
||||||
|
elem, err := batchtfile.NewTaskElement(albumStor, afstorPath, af.file)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("Failed to create task element for album file: %s", err)
|
||||||
|
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
|
||||||
|
ID: trackMsgID,
|
||||||
|
Message: i18n.T(i18nk.BotMsgCommonErrorTaskCreateFailed, map[string]any{
|
||||||
|
"Error": err.Error(),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
elems = append(elems, *elem)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
injectCtx := tgutil.ExtWithContext(ctx.Context, ctx)
|
||||||
|
taskid := xid.New().String()
|
||||||
|
task := batchtfile.NewBatchTGFileTask(taskid, injectCtx, elems, batchtfile.NewProgressTracker(trackMsgID, userID), true)
|
||||||
|
if err := core.AddTask(injectCtx, task); err != nil {
|
||||||
|
logger.Errorf("Failed to add batch task: %s", err)
|
||||||
|
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
|
||||||
|
ID: trackMsgID,
|
||||||
|
Message: i18n.T(i18nk.BotMsgCommonErrorTaskAddFailed, map[string]any{
|
||||||
|
"Error": err.Error(),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
|
||||||
|
ID: trackMsgID,
|
||||||
|
Message: i18n.T(i18nk.BotMsgCommonInfoBatchTasksAdded, map[string]any{
|
||||||
|
"Count": len(files),
|
||||||
|
}),
|
||||||
|
ReplyMarkup: nil,
|
||||||
|
})
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
56
client/bot/handlers/utils/shortcut/tphtask.go
Normal file
56
client/bot/handlers/utils/shortcut/tphtask.go
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
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/i18n"
|
||||||
|
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
|
||||||
|
"github.com/krau/SaveAny-Bot/common/utils/tgutil"
|
||||||
|
"github.com/krau/SaveAny-Bot/common/utils/tphutil"
|
||||||
|
"github.com/krau/SaveAny-Bot/core"
|
||||||
|
tphtask "github.com/krau/SaveAny-Bot/core/tasks/telegraph"
|
||||||
|
"github.com/krau/SaveAny-Bot/pkg/telegraph"
|
||||||
|
"github.com/krau/SaveAny-Bot/storage"
|
||||||
|
"github.com/rs/xid"
|
||||||
|
)
|
||||||
|
|
||||||
|
func CreateAndAddtelegraphWithEdit(
|
||||||
|
ctx *ext.Context,
|
||||||
|
userID int64,
|
||||||
|
tphpage *telegraph.Page,
|
||||||
|
dirPath string, // unescaped ph path for file storage
|
||||||
|
pics []string,
|
||||||
|
stor storage.Storage,
|
||||||
|
trackMsgID int) error {
|
||||||
|
|
||||||
|
injectCtx := tgutil.ExtWithContext(ctx.Context, ctx)
|
||||||
|
task := tphtask.NewTask(xid.New().String(),
|
||||||
|
injectCtx,
|
||||||
|
tphpage.Path,
|
||||||
|
pics,
|
||||||
|
stor,
|
||||||
|
stor.JoinStoragePath(dirPath),
|
||||||
|
tphutil.DefaultClient(),
|
||||||
|
tphtask.NewProgress(trackMsgID, userID),
|
||||||
|
)
|
||||||
|
if err := core.AddTask(injectCtx, task); err != nil {
|
||||||
|
log.FromContext(ctx).Errorf("Failed to add task: %s", err)
|
||||||
|
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
|
||||||
|
ID: trackMsgID,
|
||||||
|
Message: i18n.T(i18nk.BotMsgCommonErrorTaskAddFailed, map[string]any{
|
||||||
|
"Error": 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
|
||||||
|
}
|
||||||
63
client/bot/handlers/utils/shortcut/ytdlp.go
Normal file
63
client/bot/handlers/utils/shortcut/ytdlp.go
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
package shortcut
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/celestix/gotgproto/dispatcher"
|
||||||
|
"github.com/celestix/gotgproto/ext"
|
||||||
|
"github.com/charmbracelet/log"
|
||||||
|
"github.com/gotd/td/tg"
|
||||||
|
"github.com/rs/xid"
|
||||||
|
|
||||||
|
"github.com/krau/SaveAny-Bot/common/i18n"
|
||||||
|
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
|
||||||
|
"github.com/krau/SaveAny-Bot/common/utils/tgutil"
|
||||||
|
"github.com/krau/SaveAny-Bot/core"
|
||||||
|
"github.com/krau/SaveAny-Bot/core/tasks/ytdlp"
|
||||||
|
"github.com/krau/SaveAny-Bot/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
func CreateAndAddYtdlpTaskWithEdit(ctx *ext.Context, stor storage.Storage, dirPath string, urls []string, flags []string, msgID int, userID int64) error {
|
||||||
|
logger := log.FromContext(ctx)
|
||||||
|
injectCtx := tgutil.ExtWithContext(ctx.Context, ctx)
|
||||||
|
|
||||||
|
// Validate URLs
|
||||||
|
if len(urls) == 0 {
|
||||||
|
logger.Error("URLs list is empty")
|
||||||
|
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
|
||||||
|
ID: msgID,
|
||||||
|
Message: i18n.T(i18nk.BotMsgYtdlpErrorNoValidUrls, nil),
|
||||||
|
})
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Infof("Creating yt-dlp task for %d URL(s) with %d flag(s)", len(urls), len(flags))
|
||||||
|
|
||||||
|
// Create yt-dlp task
|
||||||
|
task := ytdlp.NewTask(
|
||||||
|
xid.New().String(),
|
||||||
|
injectCtx,
|
||||||
|
urls,
|
||||||
|
flags,
|
||||||
|
stor,
|
||||||
|
stor.JoinStoragePath(dirPath),
|
||||||
|
ytdlp.NewProgress(msgID, userID),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Add task to queue
|
||||||
|
if err := core.AddTask(injectCtx, task); err != nil {
|
||||||
|
logger.Errorf("Failed to add yt-dlp task: %s", err)
|
||||||
|
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
|
||||||
|
ID: msgID,
|
||||||
|
Message: i18n.T(i18nk.BotMsgCommonErrorTaskAddFailed, map[string]any{
|
||||||
|
"Error": err.Error(),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
|
||||||
|
ID: msgID,
|
||||||
|
Message: i18n.T(i18nk.BotMsgCommonInfoTaskAdded, nil),
|
||||||
|
})
|
||||||
|
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
421
client/bot/handlers/watch.go
Normal file
421
client/bot/handlers/watch.go
Normal file
@@ -0,0 +1,421 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"path"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"text/template"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/celestix/gotgproto/dispatcher"
|
||||||
|
"github.com/celestix/gotgproto/ext"
|
||||||
|
"github.com/charmbracelet/log"
|
||||||
|
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/mediautil"
|
||||||
|
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/ruleutil"
|
||||||
|
userclient "github.com/krau/SaveAny-Bot/client/user"
|
||||||
|
"github.com/krau/SaveAny-Bot/common/i18n"
|
||||||
|
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
|
||||||
|
"github.com/krau/SaveAny-Bot/common/utils/tgutil"
|
||||||
|
"github.com/krau/SaveAny-Bot/config"
|
||||||
|
"github.com/krau/SaveAny-Bot/core"
|
||||||
|
coretfile "github.com/krau/SaveAny-Bot/core/tasks/tfile"
|
||||||
|
"github.com/krau/SaveAny-Bot/database"
|
||||||
|
"github.com/krau/SaveAny-Bot/pkg/enums/fnamest"
|
||||||
|
"github.com/krau/SaveAny-Bot/pkg/tfile"
|
||||||
|
"github.com/krau/SaveAny-Bot/storage"
|
||||||
|
"github.com/rs/xid"
|
||||||
|
)
|
||||||
|
|
||||||
|
func handleWatchCmd(ctx *ext.Context, update *ext.Update) error {
|
||||||
|
logger := log.FromContext(ctx)
|
||||||
|
args := strings.Split(update.EffectiveMessage.Text, " ")
|
||||||
|
if len(args) < 2 {
|
||||||
|
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgWatchHelpText)), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
userChatID := update.GetUserChat().GetID()
|
||||||
|
user, err := database.GetUserByChatID(ctx, userChatID)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("Failed to get user: %s", err)
|
||||||
|
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgCommonErrorGetUserFailed)), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
if user.DefaultStorage == "" {
|
||||||
|
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgCommonErrorDefaultStorageNotSet)), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
chatArg := args[1]
|
||||||
|
chatID, err := tgutil.ParseChatID(ctx, chatArg)
|
||||||
|
if err != nil {
|
||||||
|
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgCommonErrorInvalidIdOrUsername, map[string]any{"Error": err.Error()})), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
watching, err := user.WatchingChat(ctx, chatID)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("Failed to check if user is watching chat %d: %s", chatID, err)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
if watching {
|
||||||
|
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgWatchInfoAlreadyWatchingChat)), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
filter := ""
|
||||||
|
if len(args) > 2 {
|
||||||
|
filterArg := strings.Join(args[2:], " ")
|
||||||
|
filterType := strings.Split(filterArg, ":")[0]
|
||||||
|
filterData := strings.Split(filterArg, ":")[1]
|
||||||
|
if filterType == "" || filterData == "" {
|
||||||
|
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgWatchErrorFilterFormatInvalid)), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
switch filterType {
|
||||||
|
case "msgre":
|
||||||
|
_, err := regexp.Compile(filterData)
|
||||||
|
if err != nil {
|
||||||
|
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgCommonErrorInvalidRegex, map[string]any{"Error": err.Error()})), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
filter = filterType + ":" + filterData
|
||||||
|
default:
|
||||||
|
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgWatchErrorFilterTypeUnsupported)), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := user.WatchChat(ctx, database.WatchChat{
|
||||||
|
UserID: user.ID,
|
||||||
|
ChatID: chatID,
|
||||||
|
Filter: filter,
|
||||||
|
}); err != nil {
|
||||||
|
logger.Errorf("Failed to watch chat %d: %s", chatID, err)
|
||||||
|
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgWatchErrorWatchChatFailed, map[string]any{"Error": err.Error()})), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgWatchInfoWatchChatStarted, map[string]any{"Chat": chatArg})), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleLswatchCmd(ctx *ext.Context, update *ext.Update) error {
|
||||||
|
logger := log.FromContext(ctx)
|
||||||
|
userChatID := update.GetUserChat().GetID()
|
||||||
|
user, err := database.GetUserByChatID(ctx, userChatID)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("Failed to get user: %s", err)
|
||||||
|
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgCommonErrorGetUserFailed)), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
chats := user.WatchChats
|
||||||
|
if len(chats) == 0 {
|
||||||
|
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgWatchInfoWatchListEmpty)), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString(i18n.T(i18nk.BotMsgWatchInfoWatchListHeader))
|
||||||
|
for _, chat := range chats {
|
||||||
|
sb.WriteString("- ")
|
||||||
|
sb.WriteString(fmt.Sprintf("%d", chat.ChatID))
|
||||||
|
if chat.Filter != "" {
|
||||||
|
sb.WriteString(i18n.T(i18nk.BotMsgWatchInfoWatchListFilterPrefix))
|
||||||
|
sb.WriteString(chat.Filter)
|
||||||
|
sb.WriteString(")")
|
||||||
|
}
|
||||||
|
sb.WriteString("\n")
|
||||||
|
}
|
||||||
|
ctx.Reply(update, ext.ReplyTextString(sb.String()), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleUnwatchCmd(ctx *ext.Context, update *ext.Update) error {
|
||||||
|
logger := log.FromContext(ctx)
|
||||||
|
args := strings.Split(update.EffectiveMessage.Text, " ")
|
||||||
|
if len(args) < 2 {
|
||||||
|
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgWatchErrorUnwatchNoChatProvided)), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
userChatID := update.GetUserChat().GetID()
|
||||||
|
user, err := database.GetUserByChatID(ctx, userChatID)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("Failed to get user: %s", err)
|
||||||
|
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgCommonErrorGetUserFailed)), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
chatArg := args[1]
|
||||||
|
chatID, err := tgutil.ParseChatID(ctx, chatArg)
|
||||||
|
if err != nil {
|
||||||
|
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgCommonErrorInvalidIdOrUsername, map[string]any{"Error": err.Error()})), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
if err := user.UnwatchChat(ctx, chatID); err != nil {
|
||||||
|
logger.Errorf("Failed to unwatch chat %d: %s", chatID, err)
|
||||||
|
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgWatchErrorUnwatchChatFailed, map[string]any{"Error": err.Error()})), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgWatchInfoWatchChatStopped, map[string]any{"Chat": chatArg})), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
|
||||||
|
type watchMediaGroupHandler struct {
|
||||||
|
groups map[int64]map[uint][]tfile.TGFileMessage // chatID -> userID -> files
|
||||||
|
timers map[int64]map[uint]*time.Timer
|
||||||
|
mu sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
var watchMediaGroupMgr = &watchMediaGroupHandler{
|
||||||
|
groups: make(map[int64]map[uint][]tfile.TGFileMessage),
|
||||||
|
timers: make(map[int64]map[uint]*time.Timer),
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *watchMediaGroupHandler) addFile(chatID int64, userID uint, file tfile.TGFileMessage, timeout time.Duration, callback func([]tfile.TGFileMessage)) {
|
||||||
|
w.mu.Lock()
|
||||||
|
defer w.mu.Unlock()
|
||||||
|
|
||||||
|
if w.groups[chatID] == nil {
|
||||||
|
w.groups[chatID] = make(map[uint][]tfile.TGFileMessage)
|
||||||
|
}
|
||||||
|
if w.timers[chatID] == nil {
|
||||||
|
w.timers[chatID] = make(map[uint]*time.Timer)
|
||||||
|
}
|
||||||
|
|
||||||
|
if timer, exists := w.timers[chatID][userID]; exists {
|
||||||
|
timer.Stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
w.groups[chatID][userID] = append(w.groups[chatID][userID], file)
|
||||||
|
|
||||||
|
w.timers[chatID][userID] = time.AfterFunc(timeout, func() {
|
||||||
|
w.mu.Lock()
|
||||||
|
files := w.groups[chatID][userID]
|
||||||
|
delete(w.groups[chatID], userID)
|
||||||
|
delete(w.timers[chatID], userID)
|
||||||
|
if len(w.groups[chatID]) == 0 {
|
||||||
|
delete(w.groups, chatID)
|
||||||
|
}
|
||||||
|
if len(w.timers[chatID]) == 0 {
|
||||||
|
delete(w.timers, chatID)
|
||||||
|
}
|
||||||
|
w.mu.Unlock()
|
||||||
|
|
||||||
|
if len(files) > 0 {
|
||||||
|
callback(files)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func listenMediaMessageEvent(ch chan userclient.MediaMessageEvent) {
|
||||||
|
if userclient.GetCtx() == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
logger := log.FromContext(userclient.GetCtx())
|
||||||
|
for event := range ch {
|
||||||
|
logger.Debug("Received media message event", "chat_id", event.ChatID, "file_name", event.File.Name())
|
||||||
|
ctx := event.Ctx
|
||||||
|
file := event.File
|
||||||
|
chats, err := database.GetWatchChatsByChatID(ctx, event.ChatID)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("Failed to get watch chats for chat ID %d: %v", event.ChatID, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
msgText := event.File.Message().GetMessage()
|
||||||
|
for _, chat := range chats {
|
||||||
|
if chat.Filter != "" {
|
||||||
|
filter := strings.Split(chat.Filter, ":")
|
||||||
|
if len(filter) != 2 {
|
||||||
|
logger.Warnf("Invalid filter format in chat %d, skipping", chat.ChatID)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
filterType := filter[0]
|
||||||
|
filterData := filter[1]
|
||||||
|
switch filterType {
|
||||||
|
case "msgre": // [TODO] enums for filter types
|
||||||
|
if ok, err := regexp.MatchString(filterData, msgText); err != nil {
|
||||||
|
continue
|
||||||
|
} else if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
logger.Warnf("Unsupported filter type %s in chat %d, skipping", filterType, chat.ChatID)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
user, err := database.GetUserByID(ctx, chat.UserID)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("Failed to get user by ID %d: %v", chat.UserID, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if user.DefaultStorage == "" {
|
||||||
|
logger.Warnf("User %d has no default storage set, skipping media message handling", chat.UserID)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
stor, err := storage.GetStorageByUserIDAndName(ctx, user.ChatID, user.DefaultStorage)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("Failed to get storage by user ID %d and name %s: %v", user.ChatID, user.DefaultStorage, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch user.FilenameStrategy {
|
||||||
|
case fnamest.Message.String():
|
||||||
|
file.SetName(tgutil.GenFileNameFromMessage(*file.Message()))
|
||||||
|
case fnamest.Template.String():
|
||||||
|
if user.FilenameTemplate == "" {
|
||||||
|
logger.Warnf("Empty filename template for user %d, using default filename", user.ChatID)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
message := file.Message()
|
||||||
|
tmpl, err := template.New("filename").Parse(user.FilenameTemplate)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("Failed to parse filename template for user %d: %s", user.ChatID, err)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
data := mediautil.BuildFilenameTemplateData(message)
|
||||||
|
var sb strings.Builder
|
||||||
|
err = tmpl.Execute(&sb, data)
|
||||||
|
if err != nil {
|
||||||
|
log.FromContext(ctx).Errorf("failed to execute filename template: %s", err)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
file.SetName(sb.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this is a media group and if rules specify NEW-FOR-ALBUM
|
||||||
|
groupID, isGroup := file.Message().GetGroupedID()
|
||||||
|
needAlbumHandling := false
|
||||||
|
if isGroup && groupID != 0 && user.ApplyRule && user.Rules != nil {
|
||||||
|
_, _, matchedDirPath := ruleutil.ApplyRule(ctx, user.Rules, ruleutil.NewInput(file))
|
||||||
|
needAlbumHandling = matchedDirPath.NeedNewForAlbum()
|
||||||
|
}
|
||||||
|
|
||||||
|
if needAlbumHandling {
|
||||||
|
// For media groups with NEW-FOR-ALBUM rule, collect all files of the same group
|
||||||
|
watchMediaGroupMgr.addFile(event.ChatID, user.ID, file, time.Duration(config.C().Telegram.MediaGroupTimeout)*time.Second, func(files []tfile.TGFileMessage) {
|
||||||
|
processWatchMediaGroup(ctx, user, stor, "", files)
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process single file or media group without album folder creation
|
||||||
|
var dirPath string
|
||||||
|
if user.ApplyRule && user.Rules != nil {
|
||||||
|
matched, matchedStorageName, matchedDirPath := ruleutil.ApplyRule(ctx, user.Rules, ruleutil.NewInput(file))
|
||||||
|
if !matched {
|
||||||
|
goto startCreateTask
|
||||||
|
}
|
||||||
|
dirPath = matchedDirPath.String()
|
||||||
|
if matchedStorageName.Usable() {
|
||||||
|
stor, err = storage.GetStorageByUserIDAndName(ctx, user.ChatID, matchedStorageName.String())
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("Failed to get storage by user ID and name: %s", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
startCreateTask:
|
||||||
|
storagePath := stor.JoinStoragePath(path.Join(dirPath, file.Name()))
|
||||||
|
injectCtx := tgutil.ExtWithContext(ctx.Context, ctx)
|
||||||
|
taskid := xid.New().String()
|
||||||
|
task, err := coretfile.NewTGFileTask(taskid, injectCtx, file, stor, storagePath, nil)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("create task failed: %s", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := core.AddTask(injectCtx, task); err != nil {
|
||||||
|
logger.Errorf("add task failed: %s", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
logger.Infof("Added media message task for user %d in chat %d: %s", chat.UserID, event.ChatID, file.Name())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func processWatchMediaGroup(ctx *ext.Context, user *database.User, stor storage.Storage, dirPath string, files []tfile.TGFileMessage) {
|
||||||
|
logger := log.FromContext(ctx)
|
||||||
|
if len(files) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
useRule := user.ApplyRule && user.Rules != nil
|
||||||
|
|
||||||
|
applyRule := func(file tfile.TGFileMessage) (string, ruleutil.MatchedDirPath) {
|
||||||
|
if !useRule {
|
||||||
|
return stor.Name(), ruleutil.MatchedDirPath(dirPath)
|
||||||
|
}
|
||||||
|
matched, storName, dirP := ruleutil.ApplyRule(ctx, user.Rules, ruleutil.NewInput(file))
|
||||||
|
if !matched {
|
||||||
|
return stor.Name(), ruleutil.MatchedDirPath(dirPath)
|
||||||
|
}
|
||||||
|
storname := storName.String()
|
||||||
|
if !storName.Usable() {
|
||||||
|
storname = stor.Name()
|
||||||
|
}
|
||||||
|
return storname, dirP
|
||||||
|
}
|
||||||
|
|
||||||
|
type albumFile struct {
|
||||||
|
file tfile.TGFileMessage
|
||||||
|
storage storage.Storage
|
||||||
|
}
|
||||||
|
albumFiles := make(map[int64][]albumFile)
|
||||||
|
|
||||||
|
// Collect files by group ID
|
||||||
|
for _, file := range files {
|
||||||
|
storName, ruleDirPath := applyRule(file)
|
||||||
|
fileStor := stor
|
||||||
|
if storName != stor.Name() && storName != "" {
|
||||||
|
var err error
|
||||||
|
fileStor, err = storage.GetStorageByUserIDAndName(ctx, user.ChatID, storName)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("Failed to get storage by user ID and name: %s", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
groupId, isGroup := file.Message().GetGroupedID()
|
||||||
|
if !isGroup || groupId == 0 {
|
||||||
|
logger.Warnf("File %s is not in a group, skipping", file.Name())
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if !ruleDirPath.NeedNewForAlbum() {
|
||||||
|
logger.Warnf("File %s does not need album folder, skipping", file.Name())
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, ok := albumFiles[groupId]; !ok {
|
||||||
|
albumFiles[groupId] = make([]albumFile, 0)
|
||||||
|
}
|
||||||
|
albumFiles[groupId] = append(albumFiles[groupId], albumFile{
|
||||||
|
file: file,
|
||||||
|
storage: fileStor,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process album files with folder creation
|
||||||
|
injectCtx := tgutil.ExtWithContext(ctx.Context, ctx)
|
||||||
|
totalTasks := 0
|
||||||
|
for groupID, afiles := range albumFiles {
|
||||||
|
if len(afiles) <= 1 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use first file's name (without extension) as album folder name
|
||||||
|
albumDir := strings.TrimSuffix(path.Base(afiles[0].file.Name()), path.Ext(afiles[0].file.Name()))
|
||||||
|
albumStor := afiles[0].storage
|
||||||
|
|
||||||
|
logger.Infof("Creating album folder for group %d: %s with %d files", groupID, albumDir, len(afiles))
|
||||||
|
|
||||||
|
for _, af := range afiles {
|
||||||
|
afstorPath := af.storage.JoinStoragePath(path.Join(dirPath, albumDir, af.file.Name()))
|
||||||
|
taskid := xid.New().String()
|
||||||
|
task, err := coretfile.NewTGFileTask(taskid, injectCtx, af.file, albumStor, afstorPath, nil)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("create task failed for album file: %s", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := core.AddTask(injectCtx, task); err != nil {
|
||||||
|
logger.Errorf("add task failed: %s", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
totalTasks++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
logger.Infof("Added %d watch media tasks for user %d", totalTasks, user.ChatID)
|
||||||
|
}
|
||||||
92
client/bot/handlers/ytdlp.go
Normal file
92
client/bot/handlers/ytdlp.go
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/url"
|
||||||
|
"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/common/i18n"
|
||||||
|
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
|
||||||
|
"github.com/krau/SaveAny-Bot/pkg/enums/tasktype"
|
||||||
|
"github.com/krau/SaveAny-Bot/pkg/tcbdata"
|
||||||
|
"github.com/krau/SaveAny-Bot/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
func handleYtdlpCmd(ctx *ext.Context, update *ext.Update) error {
|
||||||
|
logger := log.FromContext(ctx)
|
||||||
|
args := strings.Split(update.EffectiveMessage.Text, " ")
|
||||||
|
if len(args) < 2 {
|
||||||
|
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgYtdlpUsage)), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
|
||||||
|
// Separate URLs and flags from arguments
|
||||||
|
var urls []string
|
||||||
|
var flags []string
|
||||||
|
|
||||||
|
for i := 1; i < len(args); i++ {
|
||||||
|
arg := strings.TrimSpace(args[i])
|
||||||
|
if arg == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if it's a flag (starts with - or --)
|
||||||
|
if strings.HasPrefix(arg, "-") {
|
||||||
|
flags = append(flags, arg)
|
||||||
|
// Check if the next argument might be a value for this flag
|
||||||
|
// Don't consume it if it starts with - or looks like a URL with scheme
|
||||||
|
if i+1 < len(args) {
|
||||||
|
nextArg := strings.TrimSpace(args[i+1])
|
||||||
|
if nextArg != "" && !strings.HasPrefix(nextArg, "-") {
|
||||||
|
// Check if it's clearly a URL (has ://)
|
||||||
|
// This handles common video URLs (http://, https://)
|
||||||
|
// For other yt-dlp inputs, users should ensure proper formatting
|
||||||
|
if strings.Contains(nextArg, "://") {
|
||||||
|
// It's a URL, don't consume it as a flag value
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Otherwise, treat it as a flag value
|
||||||
|
flags = append(flags, nextArg)
|
||||||
|
i++ // Skip the next argument as it's been consumed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Try to parse as URL
|
||||||
|
u, err := url.Parse(arg)
|
||||||
|
if err != nil || u.Scheme == "" || u.Host == "" {
|
||||||
|
logger.Warnf("Invalid URL: %s", arg)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
urls = append(urls, arg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(urls) == 0 {
|
||||||
|
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgYtdlpErrorNoValidUrls)), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Debugf("Preparing yt-dlp download for %d URL(s) with %d flag(s)", len(urls), len(flags))
|
||||||
|
|
||||||
|
// Build storage selection keyboard
|
||||||
|
markup, err := msgelem.BuildAddSelectStorageKeyboard(storage.GetUserStorages(ctx, update.GetUserChat().GetID()), tcbdata.Add{
|
||||||
|
TaskType: tasktype.TaskTypeYtdlp,
|
||||||
|
YtdlpURLs: urls,
|
||||||
|
YtdlpFlags: flags,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgYtdlpInfoUrlsSelectStorage, map[string]any{
|
||||||
|
"Count": len(urls),
|
||||||
|
})), &ext.ReplyOpts{
|
||||||
|
Markup: markup,
|
||||||
|
})
|
||||||
|
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
129
client/bot/handlers/ytdlp_test.go
Normal file
129
client/bot/handlers/ytdlp_test.go
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestYtdlpArgumentParsing tests the URL and flag separation logic
|
||||||
|
func TestYtdlpArgumentParsing(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
expectedURLs []string
|
||||||
|
expectedFlags []string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "Single URL without flags",
|
||||||
|
input: "/ytdlp https://example.com/video",
|
||||||
|
expectedURLs: []string{"https://example.com/video"},
|
||||||
|
expectedFlags: []string{},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Multiple URLs without flags",
|
||||||
|
input: "/ytdlp https://example.com/v1 https://example.com/v2",
|
||||||
|
expectedURLs: []string{"https://example.com/v1", "https://example.com/v2"},
|
||||||
|
expectedFlags: []string{},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "URL with format flag",
|
||||||
|
input: "/ytdlp --format best https://example.com/video",
|
||||||
|
expectedURLs: []string{"https://example.com/video"},
|
||||||
|
expectedFlags: []string{"--format", "best"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "URL with extract-audio flag",
|
||||||
|
input: "/ytdlp --extract-audio --audio-format mp3 https://example.com/video",
|
||||||
|
expectedURLs: []string{"https://example.com/video"},
|
||||||
|
expectedFlags: []string{"--extract-audio", "--audio-format", "mp3"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Multiple URLs with flags",
|
||||||
|
input: "/ytdlp --format best https://example.com/v1 https://example.com/v2",
|
||||||
|
expectedURLs: []string{"https://example.com/v1", "https://example.com/v2"},
|
||||||
|
expectedFlags: []string{"--format", "best"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Flags mixed with URLs",
|
||||||
|
input: "/ytdlp https://example.com/v1 --format best https://example.com/v2",
|
||||||
|
expectedURLs: []string{"https://example.com/v1", "https://example.com/v2"},
|
||||||
|
expectedFlags: []string{"--format", "best"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Short flag",
|
||||||
|
input: "/ytdlp -f best https://example.com/video",
|
||||||
|
expectedURLs: []string{"https://example.com/video"},
|
||||||
|
expectedFlags: []string{"-f", "best"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Boolean flag",
|
||||||
|
input: "/ytdlp --extract-audio https://example.com/video",
|
||||||
|
expectedURLs: []string{"https://example.com/video"},
|
||||||
|
expectedFlags: []string{"--extract-audio"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
args := strings.Split(tt.input, " ")
|
||||||
|
|
||||||
|
// Simulate the parsing logic from handleYtdlpCmd
|
||||||
|
var urls []string
|
||||||
|
var flags []string
|
||||||
|
|
||||||
|
for i := 1; i < len(args); i++ {
|
||||||
|
arg := strings.TrimSpace(args[i])
|
||||||
|
if arg == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if it's a flag (starts with - or --)
|
||||||
|
if strings.HasPrefix(arg, "-") {
|
||||||
|
flags = append(flags, arg)
|
||||||
|
// Check if the next argument might be a value for this flag
|
||||||
|
if i+1 < len(args) {
|
||||||
|
nextArg := strings.TrimSpace(args[i+1])
|
||||||
|
if nextArg != "" && !strings.HasPrefix(nextArg, "-") {
|
||||||
|
// Check if it's clearly a URL (has ://)
|
||||||
|
if strings.Contains(nextArg, "://") {
|
||||||
|
// It's a URL, don't consume it as a flag value
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Otherwise, treat it as a flag value
|
||||||
|
flags = append(flags, nextArg)
|
||||||
|
i++ // Skip the next argument as it's been consumed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Try to parse as URL
|
||||||
|
u, err := url.Parse(arg)
|
||||||
|
if err != nil || u.Scheme == "" || u.Host == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
urls = append(urls, arg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify URLs
|
||||||
|
if len(urls) != len(tt.expectedURLs) {
|
||||||
|
t.Errorf("Expected %d URLs, got %d", len(tt.expectedURLs), len(urls))
|
||||||
|
}
|
||||||
|
for i, expectedURL := range tt.expectedURLs {
|
||||||
|
if i >= len(urls) || urls[i] != expectedURL {
|
||||||
|
t.Errorf("Expected URL[%d] to be '%s', got '%s'", i, expectedURL, urls[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify flags
|
||||||
|
if len(flags) != len(tt.expectedFlags) {
|
||||||
|
t.Errorf("Expected %d flags, got %d", len(tt.expectedFlags), len(flags))
|
||||||
|
}
|
||||||
|
for i, expectedFlag := range tt.expectedFlags {
|
||||||
|
if i >= len(flags) || flags[i] != expectedFlag {
|
||||||
|
t.Errorf("Expected flag[%d] to be '%s', got '%s'", i, expectedFlag, flags[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
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.C().Telegram.RpcRetry),
|
||||||
|
floodwait.NewSimpleWaiter(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newBackoff(timeout time.Duration) backoff.BackOff {
|
||||||
|
b := backoff.NewExponentialBackOff()
|
||||||
|
b.Multiplier = 1.1
|
||||||
|
b.MaxElapsedTime = timeout
|
||||||
|
b.MaxInterval = 10 * time.Second
|
||||||
|
return b
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package bot
|
package middleware
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"time"
|
"time"
|
||||||
@@ -9,8 +9,8 @@ import (
|
|||||||
"golang.org/x/time/rate"
|
"golang.org/x/time/rate"
|
||||||
)
|
)
|
||||||
|
|
||||||
func FloodWaitMiddleware() []telegram.Middleware {
|
func NewFloodWaitMiddlewares(maxRetries uint) []telegram.Middleware {
|
||||||
waiter := floodwait.NewSimpleWaiter().WithMaxRetries(5)
|
waiter := floodwait.NewSimpleWaiter().WithMaxRetries(maxRetries)
|
||||||
ratelimiter := ratelimit.New(rate.Every(time.Millisecond*100), 5)
|
ratelimiter := ratelimit.New(rate.Every(time.Millisecond*100), 5)
|
||||||
return []telegram.Middleware{
|
return []telegram.Middleware{
|
||||||
waiter,
|
waiter,
|
||||||
61
client/middleware/recovery/recovery.go
Normal file
61
client/middleware/recovery/recovery.go
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
package recovery
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/cenkalti/backoff/v4"
|
||||||
|
"github.com/charmbracelet/log"
|
||||||
|
"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 fmt.Errorf("recovery: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
57
client/user/auth_terminal.go
Normal file
57
client/user/auth_terminal.go
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
package user
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/celestix/gotgproto"
|
||||||
|
"golang.org/x/term"
|
||||||
|
)
|
||||||
|
|
||||||
|
type terminalAuthConversator struct{}
|
||||||
|
|
||||||
|
func readLine(prompt string) (string, error) {
|
||||||
|
fmt.Print(prompt)
|
||||||
|
reader := bufio.NewReader(os.Stdin)
|
||||||
|
text, err := reader.ReadString('\n')
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(text), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *terminalAuthConversator) AskPhoneNumber() (string, error) {
|
||||||
|
fmt.Println("Your Phone Number (e.g. +44 123456):")
|
||||||
|
return readLine("> ")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *terminalAuthConversator) AskCode() (string, error) {
|
||||||
|
fmt.Println("Your Code (e.g. 123456):")
|
||||||
|
return readLine("> ")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *terminalAuthConversator) AskPassword() (string, error) {
|
||||||
|
fmt.Println("Your 2FA Password:")
|
||||||
|
fmt.Print("> ")
|
||||||
|
bytePwd, err := term.ReadPassword(int(os.Stdin.Fd()))
|
||||||
|
fmt.Println()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.TrimSpace(string(bytePwd)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *terminalAuthConversator) AuthStatus(authStatus gotgproto.AuthStatus) {
|
||||||
|
switch authStatus.Event {
|
||||||
|
case gotgproto.AuthStatusPhoneRetrial:
|
||||||
|
fmt.Printf("The phone number is incorrect. Attempts left: %d\n", authStatus.AttemptsLeft)
|
||||||
|
case gotgproto.AuthStatusPasswordRetrial:
|
||||||
|
fmt.Printf("The 2FA password is incorrect. Attempts left: %d\n", authStatus.AttemptsLeft)
|
||||||
|
case gotgproto.AuthStatusPhoneCodeRetrial:
|
||||||
|
fmt.Printf("The OTP code is incorrect. Attempts left: %d\n", authStatus.AttemptsLeft)
|
||||||
|
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/dispatcher/handlers"
|
||||||
|
"github.com/celestix/gotgproto/dispatcher/handlers/filters"
|
||||||
|
"github.com/celestix/gotgproto/ext"
|
||||||
|
"github.com/celestix/gotgproto/sessionMaker"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/log"
|
||||||
|
"github.com/gotd/td/tg"
|
||||||
|
"github.com/krau/SaveAny-Bot/client/middleware"
|
||||||
|
"github.com/krau/SaveAny-Bot/common/utils/tgutil"
|
||||||
|
"github.com/krau/SaveAny-Bot/config"
|
||||||
|
"github.com/krau/SaveAny-Bot/database"
|
||||||
|
)
|
||||||
|
|
||||||
|
var uc *gotgproto.Client
|
||||||
|
var ectx *ext.Context
|
||||||
|
|
||||||
|
func GetCtx() *ext.Context {
|
||||||
|
if ectx != nil {
|
||||||
|
return ectx
|
||||||
|
}
|
||||||
|
if uc == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
ectx = uc.CreateContext()
|
||||||
|
return ectx
|
||||||
|
}
|
||||||
|
|
||||||
|
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() {
|
||||||
|
resolver, err := tgutil.NewConfigProxyResolver()
|
||||||
|
if err != nil {
|
||||||
|
res <- struct {
|
||||||
|
client *gotgproto.Client
|
||||||
|
err error
|
||||||
|
}{nil, err}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tclient, err := gotgproto.NewClient(
|
||||||
|
config.C().Telegram.AppID,
|
||||||
|
config.C().Telegram.AppHash,
|
||||||
|
gotgproto.ClientTypePhone(""),
|
||||||
|
&gotgproto.ClientOpts{
|
||||||
|
Session: sessionMaker.SqlSession(database.GetDialect(config.C().Telegram.Userbot.Session)),
|
||||||
|
AuthConversator: &terminalAuthConversator{},
|
||||||
|
Context: ctx,
|
||||||
|
DisableCopyright: true,
|
||||||
|
Resolver: resolver,
|
||||||
|
MaxRetries: config.C().Telegram.RpcRetry,
|
||||||
|
AutoFetchReply: true,
|
||||||
|
Middlewares: middleware.NewDefaultMiddlewares(ctx, 5*time.Minute),
|
||||||
|
ErrorHandler: func(ctx *ext.Context, u *ext.Update, s string) error {
|
||||||
|
log.FromContext(ctx).Errorf("Unhandled error: %s", s)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
res <- struct {
|
||||||
|
client *gotgproto.Client
|
||||||
|
err error
|
||||||
|
}{nil, err}
|
||||||
|
}
|
||||||
|
res <- struct {
|
||||||
|
client *gotgproto.Client
|
||||||
|
err error
|
||||||
|
}(struct {
|
||||||
|
client *gotgproto.Client
|
||||||
|
err error
|
||||||
|
}{tclient, nil})
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil, ctx.Err()
|
||||||
|
case r := <-res:
|
||||||
|
if r.err != nil {
|
||||||
|
return nil, r.err
|
||||||
|
}
|
||||||
|
uc = r.client
|
||||||
|
uc.Dispatcher.AddHandler(handlers.NewMessage(filters.Message.Media, func(ctx *ext.Context, u *ext.Update) error {
|
||||||
|
switch u.UpdateClass.(type) {
|
||||||
|
case *tg.UpdateEditChannelMessage, *tg.UpdateEditMessage, *tg.UpdateDeleteChannelMessages, *tg.UpdateDeleteMessages:
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
chatId := u.EffectiveChat().GetID()
|
||||||
|
watchChats, err := database.GetWatchChatsByChatID(ctx, chatId)
|
||||||
|
if err != nil || len(watchChats) == 0 {
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
return dispatcher.ContinueGroups
|
||||||
|
}))
|
||||||
|
uc.Dispatcher.AddHandler(handlers.NewMessage(filters.Message.Media, handleMediaMessage))
|
||||||
|
log.FromContext(ctx).Infof("User client logged in successfully: %s", uc.Self.FirstName+" "+uc.Self.LastName)
|
||||||
|
return uc, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
100
client/user/watch.go
Normal file
100
client/user/watch.go
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
package user
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/celestix/gotgproto/dispatcher"
|
||||||
|
"github.com/celestix/gotgproto/ext"
|
||||||
|
"github.com/gotd/td/tg"
|
||||||
|
"github.com/krau/SaveAny-Bot/common/utils/tgutil"
|
||||||
|
"github.com/krau/SaveAny-Bot/pkg/tfile"
|
||||||
|
)
|
||||||
|
|
||||||
|
type MediaMessageEvent struct {
|
||||||
|
Ctx *ext.Context
|
||||||
|
ChatID int64 // from witch the media message was sent
|
||||||
|
MessageID int
|
||||||
|
File tfile.TGFileMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
type messageKey struct {
|
||||||
|
ChatID int64
|
||||||
|
MessageID int
|
||||||
|
}
|
||||||
|
|
||||||
|
type MediaMessageHandler struct {
|
||||||
|
events map[messageKey]MediaMessageEvent
|
||||||
|
timers map[messageKey]*time.Timer
|
||||||
|
mu sync.Mutex
|
||||||
|
debounce time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
mediaMessageCh = make(chan MediaMessageEvent, 100)
|
||||||
|
mediaMessageHandler = &MediaMessageHandler{
|
||||||
|
events: make(map[messageKey]MediaMessageEvent),
|
||||||
|
timers: make(map[messageKey]*time.Timer),
|
||||||
|
debounce: 5 * time.Second,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
func GetMediaMessageCh() chan MediaMessageEvent {
|
||||||
|
return mediaMessageCh
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendMediaMessageEvent(event MediaMessageEvent) {
|
||||||
|
key := messageKey{ChatID: event.ChatID, MessageID: event.MessageID}
|
||||||
|
|
||||||
|
mediaMessageHandler.mu.Lock()
|
||||||
|
defer mediaMessageHandler.mu.Unlock()
|
||||||
|
|
||||||
|
if timer, exists := mediaMessageHandler.timers[key]; exists {
|
||||||
|
timer.Stop()
|
||||||
|
} else {
|
||||||
|
mediaMessageHandler.events[key] = event
|
||||||
|
}
|
||||||
|
|
||||||
|
mediaMessageHandler.timers[key] = time.AfterFunc(mediaMessageHandler.debounce, func() {
|
||||||
|
mediaMessageHandler.mu.Lock()
|
||||||
|
event := mediaMessageHandler.events[key]
|
||||||
|
delete(mediaMessageHandler.events, key)
|
||||||
|
delete(mediaMessageHandler.timers, key)
|
||||||
|
mediaMessageHandler.mu.Unlock()
|
||||||
|
|
||||||
|
mediaMessageCh <- event
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleMediaMessage(ctx *ext.Context, update *ext.Update) error {
|
||||||
|
message := update.EffectiveMessage
|
||||||
|
media, ok := message.GetMedia()
|
||||||
|
if !ok || media == nil {
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
support := func() bool {
|
||||||
|
switch media.(type) {
|
||||||
|
case *tg.MessageMediaDocument, *tg.MessageMediaPhoto:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
if !support {
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
file, err := tfile.FromMediaMessage(media, ctx.Raw, message.Message, tfile.WithNameIfEmpty(
|
||||||
|
tgutil.GenFileNameFromMessage(*message.Message),
|
||||||
|
))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
chatId := update.EffectiveChat().GetID()
|
||||||
|
sendMediaMessageEvent(MediaMessageEvent{
|
||||||
|
Ctx: ctx,
|
||||||
|
ChatID: chatId,
|
||||||
|
MessageID: message.ID,
|
||||||
|
File: file,
|
||||||
|
})
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
115
cmd/geni18n/main.go
Normal file
115
cmd/geni18n/main.go
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
// cmd/geni18n/main.go
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"go/format"
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/goccy/go-yaml"
|
||||||
|
)
|
||||||
|
|
||||||
|
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{})
|
||||||
|
|
||||||
|
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(), ".yaml") || strings.HasSuffix(d.Name(), ".yml")) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var content map[string]any
|
||||||
|
if err := yaml.Unmarshal(data, &content); err != nil {
|
||||||
|
return fmt.Errorf("failed to parse yaml %s: %w", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
collectKeys(content, "", keys)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
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)
|
||||||
|
|
||||||
|
// Generate code to buffer
|
||||||
|
var buf bytes.Buffer
|
||||||
|
w := bufio.NewWriter(&buf)
|
||||||
|
fmt.Fprintf(w, "// Code generated by cmd/geni18n. DO NOT EDIT.\n")
|
||||||
|
fmt.Fprintf(w, "package %s\n\n", *pkg)
|
||||||
|
fmt.Fprintf(w, "type Key string\n\n")
|
||||||
|
fmt.Fprintf(w, "const (\n")
|
||||||
|
for _, key := range list {
|
||||||
|
name := toPascal(key)
|
||||||
|
fmt.Fprintf(w, "\t%s Key = %q\n", name, key)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, ")\n")
|
||||||
|
w.Flush()
|
||||||
|
|
||||||
|
// Format the generated code
|
||||||
|
formatted, err := format.Source(buf.Bytes())
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Warning: failed to format generated code: %v\n", err)
|
||||||
|
formatted = buf.Bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write to output file
|
||||||
|
if err := os.WriteFile(*out, formatted, 0644); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error writing output file: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectKeys(node map[string]any, prefix string, keys map[string]struct{}) {
|
||||||
|
for k, v := range node {
|
||||||
|
fullKey := k
|
||||||
|
if prefix != "" {
|
||||||
|
fullKey = prefix + "." + k
|
||||||
|
}
|
||||||
|
switch val := v.(type) {
|
||||||
|
case map[string]any:
|
||||||
|
collectKeys(val, fullKey, keys)
|
||||||
|
default:
|
||||||
|
keys[fullKey] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 转 PascalCase
|
||||||
|
func toPascal(key string) string {
|
||||||
|
parts := strings.Split(key, ".")
|
||||||
|
for i, p := range parts {
|
||||||
|
subs := strings.Split(p, "_")
|
||||||
|
for j, s := range subs {
|
||||||
|
if len(s) > 0 {
|
||||||
|
subs[j] = strings.ToUpper(s[:1]) + s[1:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
parts[i] = strings.Join(subs, "")
|
||||||
|
}
|
||||||
|
return strings.Join(parts, "")
|
||||||
|
}
|
||||||
12
cmd/root.go
12
cmd/root.go
@@ -1,8 +1,11 @@
|
|||||||
package cmd
|
package cmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/krau/SaveAny-Bot/cmd/upload"
|
||||||
|
"github.com/krau/SaveAny-Bot/config"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -12,8 +15,13 @@ var rootCmd = &cobra.Command{
|
|||||||
Run: Run,
|
Run: Run,
|
||||||
}
|
}
|
||||||
|
|
||||||
func Execute() {
|
func init() {
|
||||||
if err := rootCmd.Execute(); err != nil {
|
config.RegisterFlags(rootCmd)
|
||||||
|
upload.Register(rootCmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
func Execute(ctx context.Context) {
|
||||||
|
if err := rootCmd.ExecuteContext(ctx); err != nil {
|
||||||
fmt.Println(err)
|
fmt.Println(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
106
cmd/run.go
106
cmd/run.go
@@ -1,51 +1,107 @@
|
|||||||
package cmd
|
package cmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"syscall"
|
"time"
|
||||||
|
|
||||||
"github.com/krau/SaveAny-Bot/bootstrap"
|
"slices"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/log"
|
||||||
|
"github.com/krau/SaveAny-Bot/client/bot"
|
||||||
|
userclient "github.com/krau/SaveAny-Bot/client/user"
|
||||||
|
"github.com/krau/SaveAny-Bot/common/cache"
|
||||||
|
"github.com/krau/SaveAny-Bot/common/i18n"
|
||||||
|
"github.com/krau/SaveAny-Bot/common/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/logger"
|
"github.com/krau/SaveAny-Bot/database"
|
||||||
|
"github.com/krau/SaveAny-Bot/parsers"
|
||||||
|
"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) {
|
||||||
bootstrap.InitAll()
|
ctx, cancel := context.WithCancel(cmd.Context())
|
||||||
core.Run()
|
logger := log.NewWithOptions(os.Stdout, log.Options{
|
||||||
|
Level: log.DebugLevel,
|
||||||
|
ReportTimestamp: true,
|
||||||
|
TimeFormat: time.TimeOnly,
|
||||||
|
ReportCaller: true,
|
||||||
|
})
|
||||||
|
ctx = log.WithContext(ctx, logger)
|
||||||
|
|
||||||
quit := make(chan os.Signal, 1)
|
exitChan, err := initAll(ctx, cmd)
|
||||||
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
if err != nil {
|
||||||
sig := <-quit
|
logger.Fatal("Init failed", "error", err)
|
||||||
logger.L.Info(sig, ", exitting...")
|
}
|
||||||
defer logger.L.Info("Bye!")
|
go func() {
|
||||||
if config.Cfg.NoCleanCache {
|
<-exitChan
|
||||||
|
cancel()
|
||||||
|
}()
|
||||||
|
|
||||||
|
core.Run(ctx)
|
||||||
|
|
||||||
|
<-ctx.Done()
|
||||||
|
logger.Info("Exiting...")
|
||||||
|
defer logger.Info("Exit complete")
|
||||||
|
cleanCache()
|
||||||
|
}
|
||||||
|
|
||||||
|
func initAll(ctx context.Context, cmd *cobra.Command) (<-chan struct{}, error) {
|
||||||
|
configFile := config.GetConfigFile(cmd)
|
||||||
|
if err := config.Init(ctx, configFile); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to load config: %w", err)
|
||||||
|
}
|
||||||
|
cache.Init()
|
||||||
|
logger := log.FromContext(ctx)
|
||||||
|
i18n.Init(config.C().Lang)
|
||||||
|
logger.Info("Initializing...")
|
||||||
|
database.Init(ctx)
|
||||||
|
storage.LoadStorages(ctx)
|
||||||
|
if config.C().Parser.PluginEnable {
|
||||||
|
for _, dir := range config.C().Parser.PluginDirs {
|
||||||
|
if err := parsers.LoadPlugins(ctx, dir); err != nil {
|
||||||
|
logger.Error("Failed to load parser plugins", "dir", dir, "error", err)
|
||||||
|
} else {
|
||||||
|
logger.Debug("Loaded parser plugins from directory", "dir", dir)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if config.C().Telegram.Userbot.Enable {
|
||||||
|
_, err := userclient.Login(ctx)
|
||||||
|
if err != nil {
|
||||||
|
logger.Fatal("User login failed", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return bot.Init(ctx), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func cleanCache() {
|
||||||
|
if config.C().NoCleanCache {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if config.Cfg.Temp.BasePath != "" {
|
if config.C().Temp.BasePath != "" && !config.C().Stream {
|
||||||
for _, path := range []string{"/", ".", "\\", ".."} {
|
if slices.Contains([]string{"/", ".", "\\", ".."}, filepath.Clean(config.C().Temp.BasePath)) {
|
||||||
if filepath.Clean(config.Cfg.Temp.BasePath) == path {
|
log.Error("Invalid cache directory", "path", config.C().Temp.BasePath)
|
||||||
logger.L.Error("Invalid cache dir: ", config.Cfg.Temp.BasePath)
|
return
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
currentDir, err := os.Getwd()
|
currentDir, err := os.Getwd()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.L.Error("Failed to get current dir: ", err)
|
log.Error("Failed to get working directory", "error", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
cachePath := filepath.Join(currentDir, config.Cfg.Temp.BasePath)
|
cachePath := filepath.Join(currentDir, config.C().Temp.BasePath)
|
||||||
cachePath, err = filepath.Abs(cachePath)
|
cachePath, err = filepath.Abs(cachePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.L.Error("Failed to get absolute path: ", err)
|
log.Error("Failed to get absolute cache path", "error", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
logger.L.Info("Cleaning cache dir: ", cachePath)
|
log.Info("Cleaning cache directory", "path", cachePath)
|
||||||
if err := os.RemoveAll(cachePath); err != nil {
|
if err := fsutil.RemoveAllInDir(cachePath); err != nil {
|
||||||
logger.L.Error("Failed to clean cache dir: ", err)
|
log.Error("Failed to clean cache directory", "error", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
130
cmd/upload/cmd.go
Normal file
130
cmd/upload/cmd.go
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
package upload
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/log"
|
||||||
|
"github.com/krau/SaveAny-Bot/client/bot"
|
||||||
|
"github.com/krau/SaveAny-Bot/common/cache"
|
||||||
|
"github.com/krau/SaveAny-Bot/common/i18n"
|
||||||
|
"github.com/krau/SaveAny-Bot/common/utils/ioutil"
|
||||||
|
"github.com/krau/SaveAny-Bot/common/utils/tgutil"
|
||||||
|
"github.com/krau/SaveAny-Bot/config"
|
||||||
|
"github.com/krau/SaveAny-Bot/database"
|
||||||
|
"github.com/krau/SaveAny-Bot/pkg/enums/ctxkey"
|
||||||
|
stortype "github.com/krau/SaveAny-Bot/pkg/enums/storage"
|
||||||
|
"github.com/krau/SaveAny-Bot/storage"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
var uploadCmd = &cobra.Command{
|
||||||
|
Use: "upload",
|
||||||
|
Short: "upload local files to storage",
|
||||||
|
RunE: Upload,
|
||||||
|
}
|
||||||
|
|
||||||
|
func Register(root *cobra.Command) {
|
||||||
|
uploadCmd.Flags().StringP("file", "f", "", "file path to upload")
|
||||||
|
uploadCmd.MarkFlagRequired("file")
|
||||||
|
uploadCmd.Flags().StringP("storage", "s", "", "storage name to upload to")
|
||||||
|
uploadCmd.MarkFlagRequired("storage")
|
||||||
|
uploadCmd.Flags().StringP("dir", "d", "", "storage dir to upload to, default is the base_path of the storage")
|
||||||
|
uploadCmd.Flags().Bool("no-progress", false, "disable progress bar")
|
||||||
|
root.AddCommand(uploadCmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
func Upload(cmd *cobra.Command, args []string) error {
|
||||||
|
storname, err := cmd.Flags().GetString("storage")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fp, err := cmd.Flags().GetString("file")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
dirPath, err := cmd.Flags().GetString("dir")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
noProgress, err := cmd.Flags().GetBool("no-progress")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := cmd.Context()
|
||||||
|
log := log.FromContext(ctx)
|
||||||
|
configFile := config.GetConfigFile(cmd)
|
||||||
|
if err := config.Init(ctx, configFile); err != nil {
|
||||||
|
return fmt.Errorf("failed to load config: %w", err)
|
||||||
|
}
|
||||||
|
i18n.Init(config.C().Lang)
|
||||||
|
cache.Init()
|
||||||
|
database.Init(ctx)
|
||||||
|
|
||||||
|
stor, err := storage.GetStorageByName(ctx, storname)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal("Failed to get storage", "error", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch stor.Type() {
|
||||||
|
case stortype.Telegram:
|
||||||
|
bot.Init(ctx)
|
||||||
|
default:
|
||||||
|
// placeholder for other storage types that may need special initialization
|
||||||
|
}
|
||||||
|
|
||||||
|
file, err := os.Open(filepath.Clean(fp))
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal("Failed to open file", "error", err)
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
fileInfo, err := file.Stat()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal("Failed to get file info", "error", err)
|
||||||
|
}
|
||||||
|
fileName := fileInfo.Name()
|
||||||
|
fileSize := fileInfo.Size()
|
||||||
|
|
||||||
|
uploadPath := stor.JoinStoragePath(path.Join(dirPath, fileName))
|
||||||
|
|
||||||
|
ctx = context.WithValue(ctx, ctxkey.ContentLength, fileSize)
|
||||||
|
ctx = tgutil.ExtWithContext(ctx, bot.ExtContext())
|
||||||
|
|
||||||
|
// Create progress reader and UI
|
||||||
|
var reader io.Reader
|
||||||
|
var progressUI *UploadProgress
|
||||||
|
log.Info("Uploading file...", "file", fp, "to", storname, "as", uploadPath)
|
||||||
|
|
||||||
|
if !noProgress && fileSize > 0 {
|
||||||
|
progressUI = NewUploadProgress(ctx, fileName, fileSize)
|
||||||
|
progressUI.Start()
|
||||||
|
|
||||||
|
reader = ioutil.NewProgressReader(file, fileSize, func(read int64, total int64) {
|
||||||
|
if total > 0 {
|
||||||
|
progressUI.UpdateProgress(float64(read) / float64(total))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
reader = file
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := stor.Save(ctx, reader, uploadPath); err != nil {
|
||||||
|
if progressUI != nil {
|
||||||
|
progressUI.SetError(err)
|
||||||
|
progressUI.Wait()
|
||||||
|
}
|
||||||
|
log.Fatal("Failed to upload file", "error", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if progressUI != nil {
|
||||||
|
progressUI.Done()
|
||||||
|
progressUI.Wait()
|
||||||
|
}
|
||||||
|
log.Info("File uploaded successfully")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
35
cmd/upload/progress_stub.go
Normal file
35
cmd/upload/progress_stub.go
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
//go:build no_bubbletea
|
||||||
|
|
||||||
|
package upload
|
||||||
|
|
||||||
|
import "context"
|
||||||
|
|
||||||
|
type uploadModel struct {
|
||||||
|
}
|
||||||
|
|
||||||
|
// UploadProgress manages the progress UI for uploads
|
||||||
|
type UploadProgress struct {
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewUploadProgress creates a new upload progress tracker
|
||||||
|
func NewUploadProgress(ctx context.Context, fileName string, fileSize int64) *UploadProgress {
|
||||||
|
return &UploadProgress{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start starts the progress UI in a goroutine and returns immediately
|
||||||
|
func (up *UploadProgress) Start() {}
|
||||||
|
|
||||||
|
// UpdateProgress updates the progress bar with a new percentage (0.0 - 1.0)
|
||||||
|
func (up *UploadProgress) UpdateProgress(percent float64) {}
|
||||||
|
|
||||||
|
// SetError sets an error and quits the progress UI
|
||||||
|
func (up *UploadProgress) SetError(err error) {}
|
||||||
|
|
||||||
|
// Done signals that the upload is complete
|
||||||
|
func (up *UploadProgress) Done() {}
|
||||||
|
|
||||||
|
// Wait waits for the progress UI to finish
|
||||||
|
func (up *UploadProgress) Wait() {}
|
||||||
|
|
||||||
|
// Quit quits the progress UI
|
||||||
|
func (up *UploadProgress) Quit() {}
|
||||||
178
cmd/upload/progress_tea.go
Normal file
178
cmd/upload/progress_tea.go
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
//go:build !no_bubbletea
|
||||||
|
|
||||||
|
package upload
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/bubbles/progress"
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
"github.com/dustin/go-humanize"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
helpStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#626262"))
|
||||||
|
)
|
||||||
|
|
||||||
|
// progressMsg is sent to update the progress bar
|
||||||
|
type progressMsg float64
|
||||||
|
|
||||||
|
// progressErrMsg is sent when an error occurs
|
||||||
|
type progressErrMsg struct{ err error }
|
||||||
|
|
||||||
|
// progressDoneMsg is sent when the upload is complete
|
||||||
|
type progressDoneMsg struct{}
|
||||||
|
|
||||||
|
// uploadModel is the bubbletea model for the upload progress UI
|
||||||
|
type uploadModel struct {
|
||||||
|
progress progress.Model
|
||||||
|
fileName string
|
||||||
|
fileSize int64
|
||||||
|
bytesRead int64
|
||||||
|
err error
|
||||||
|
done bool
|
||||||
|
quitting bool
|
||||||
|
width int
|
||||||
|
}
|
||||||
|
|
||||||
|
func newUploadModel(fileName string, fileSize int64) uploadModel {
|
||||||
|
p := progress.New(
|
||||||
|
progress.WithDefaultGradient(),
|
||||||
|
progress.WithWidth(50),
|
||||||
|
)
|
||||||
|
return uploadModel{
|
||||||
|
progress: p,
|
||||||
|
fileName: fileName,
|
||||||
|
fileSize: fileSize,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m uploadModel) Init() tea.Cmd {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m uploadModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
|
switch msg := msg.(type) {
|
||||||
|
case tea.WindowSizeMsg:
|
||||||
|
m.width = msg.Width
|
||||||
|
m.progress.Width = min(msg.Width-10, 80)
|
||||||
|
return m, nil
|
||||||
|
|
||||||
|
case progressMsg:
|
||||||
|
var cmds []tea.Cmd
|
||||||
|
percent := float64(msg)
|
||||||
|
m.bytesRead = int64(percent * float64(m.fileSize))
|
||||||
|
|
||||||
|
cmds = append(cmds, m.progress.SetPercent(percent))
|
||||||
|
return m, tea.Batch(cmds...)
|
||||||
|
|
||||||
|
case progressErrMsg:
|
||||||
|
m.err = msg.err
|
||||||
|
return m, tea.Quit
|
||||||
|
|
||||||
|
case progressDoneMsg:
|
||||||
|
m.done = true
|
||||||
|
m.progress.SetPercent(1.0)
|
||||||
|
return m, tea.Quit
|
||||||
|
|
||||||
|
case progress.FrameMsg:
|
||||||
|
// Don't process frame messages if we're done or quitting
|
||||||
|
if m.done || m.quitting {
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
progressModel, cmd := m.progress.Update(msg)
|
||||||
|
m.progress = progressModel.(progress.Model)
|
||||||
|
return m, cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m uploadModel) View() string {
|
||||||
|
if m.err != nil {
|
||||||
|
return fmt.Sprintf("\n ❌ Error: %s\n\n", m.err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString("\n")
|
||||||
|
|
||||||
|
// File info
|
||||||
|
sb.WriteString(fmt.Sprintf(" 📁 %s\n", m.fileName))
|
||||||
|
sb.WriteString(fmt.Sprintf(" 📊 %s / %s\n\n",
|
||||||
|
humanize.Bytes(uint64(m.bytesRead)),
|
||||||
|
humanize.Bytes(uint64(m.fileSize)),
|
||||||
|
))
|
||||||
|
|
||||||
|
// Progress bar
|
||||||
|
sb.WriteString(" ")
|
||||||
|
sb.WriteString(m.progress.View())
|
||||||
|
sb.WriteString("\n\n")
|
||||||
|
|
||||||
|
if m.done {
|
||||||
|
sb.WriteString(" √ Upload complete!\n\n")
|
||||||
|
} else {
|
||||||
|
sb.WriteString(helpStyle.Render(" Press Ctrl+C to cancel"))
|
||||||
|
sb.WriteString("\n\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// UploadProgress manages the progress UI for uploads
|
||||||
|
type UploadProgress struct {
|
||||||
|
program *tea.Program
|
||||||
|
ctx context.Context
|
||||||
|
cancel context.CancelFunc
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewUploadProgress creates a new upload progress tracker
|
||||||
|
func NewUploadProgress(ctx context.Context, fileName string, fileSize int64) *UploadProgress {
|
||||||
|
model := newUploadModel(fileName, fileSize)
|
||||||
|
ctx, cancel := context.WithCancel(ctx)
|
||||||
|
p := tea.NewProgram(
|
||||||
|
model,
|
||||||
|
tea.WithoutSignalHandler(),
|
||||||
|
tea.WithContext(ctx),
|
||||||
|
tea.WithInput(nil), // Disable keyboard input, rely on context cancellation
|
||||||
|
)
|
||||||
|
return &UploadProgress{
|
||||||
|
program: p,
|
||||||
|
ctx: ctx,
|
||||||
|
cancel: cancel,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start starts the progress UI in a goroutine and returns immediately
|
||||||
|
func (up *UploadProgress) Start() {
|
||||||
|
go func() {
|
||||||
|
up.program.Run()
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateProgress updates the progress bar with a new percentage (0.0 - 1.0)
|
||||||
|
func (up *UploadProgress) UpdateProgress(percent float64) {
|
||||||
|
up.program.Send(progressMsg(percent))
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetError sets an error and quits the progress UI
|
||||||
|
func (up *UploadProgress) SetError(err error) {
|
||||||
|
up.program.Send(progressErrMsg{err: err})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Done signals that the upload is complete
|
||||||
|
func (up *UploadProgress) Done() {
|
||||||
|
up.program.Send(progressDoneMsg{})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait waits for the progress UI to finish
|
||||||
|
func (up *UploadProgress) Wait() {
|
||||||
|
up.program.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Quit quits the progress UI
|
||||||
|
func (up *UploadProgress) Quit() {
|
||||||
|
up.program.Quit()
|
||||||
|
}
|
||||||
@@ -4,8 +4,8 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
|
||||||
"github.com/krau/SaveAny-Bot/common"
|
"github.com/krau/SaveAny-Bot/config"
|
||||||
"github.com/rhysd/go-github-selfupdate/selfupdate"
|
"github.com/unvgo/ghselfupdate"
|
||||||
|
|
||||||
"github.com/blang/semver"
|
"github.com/blang/semver"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
@@ -16,7 +16,7 @@ var VersionCmd = &cobra.Command{
|
|||||||
Aliases: []string{"v"},
|
Aliases: []string{"v"},
|
||||||
Short: "Print the version number of saveany-bot",
|
Short: "Print the version number of saveany-bot",
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
fmt.Printf("saveany-bot version: %s %s/%s\nBuildTime: %s, Commit: %s\n", common.Version, runtime.GOOS, runtime.GOARCH, common.BuildTime, common.GitCommit)
|
fmt.Printf("saveany-bot version: %s %s/%s\nBuildTime: %s, Commit: %s\n", config.Version, runtime.GOOS, runtime.GOARCH, config.BuildTime, config.GitCommit)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,18 +25,33 @@ var upgradeCmd = &cobra.Command{
|
|||||||
Aliases: []string{"up"},
|
Aliases: []string{"up"},
|
||||||
Short: "Upgrade saveany-bot to the latest version",
|
Short: "Upgrade saveany-bot to the latest version",
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
v := semver.MustParse(common.Version)
|
v := semver.MustParse(config.Version)
|
||||||
latest, err := selfupdate.UpdateSelf(v, "krau/SaveAny-Bot")
|
latest, found, err := ghselfupdate.DetectLatest(config.GitRepo)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Println("Binary update failed:", err)
|
fmt.Println("Error occurred while detecting latest version:", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if latest.Version.Equals(v) {
|
if !found {
|
||||||
fmt.Println("Current binary is the latest version", common.Version)
|
fmt.Println("No releases found")
|
||||||
} else {
|
return
|
||||||
fmt.Println("Successfully updated to version", latest.Version)
|
|
||||||
fmt.Println("Release note:\n", latest.ReleaseNotes)
|
|
||||||
}
|
}
|
||||||
|
if latest.Version.Major != v.Major {
|
||||||
|
fmt.Printf("Major version upgrade detected: %s -> %s. Please manually download the latest version and check the migration guide.\n", v, latest.Version)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if latest.Version.Equals(v) || latest.Version.LT(v) {
|
||||||
|
fmt.Println("Current binary is the latest version", config.Version)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Printf("Updating to version %s...\n", latest.Version)
|
||||||
|
latest, err = ghselfupdate.UpdateSelf(v, config.GitRepo)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println("Update failed:", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Println("Successfully updated to version", latest.Version)
|
||||||
|
fmt.Println("Release note:\n", latest.ReleaseNotes)
|
||||||
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,60 +0,0 @@
|
|||||||
package common
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"encoding/gob"
|
|
||||||
"sync"
|
|
||||||
|
|
||||||
"github.com/coocood/freecache"
|
|
||||||
"github.com/gotd/td/tg"
|
|
||||||
"github.com/krau/SaveAny-Bot/types"
|
|
||||||
)
|
|
||||||
|
|
||||||
type CommonCache struct {
|
|
||||||
cache *freecache.Cache
|
|
||||||
mu sync.RWMutex
|
|
||||||
}
|
|
||||||
|
|
||||||
var Cache *CommonCache
|
|
||||||
|
|
||||||
func initCache() {
|
|
||||||
gob.Register(types.File{})
|
|
||||||
gob.Register(tg.InputDocumentFileLocation{})
|
|
||||||
gob.Register(tg.InputPhotoFileLocation{})
|
|
||||||
Cache = &CommonCache{cache: freecache.NewCache(10 * 1024 * 1024)}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *CommonCache) Get(key string, value *types.File) error {
|
|
||||||
c.mu.RLock()
|
|
||||||
defer c.mu.RUnlock()
|
|
||||||
data, err := Cache.cache.Get([]byte(key))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
dec := gob.NewDecoder(bytes.NewReader(data))
|
|
||||||
err = dec.Decode(&value)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *CommonCache) Set(key string, value *types.File, expireSeconds int) error {
|
|
||||||
c.mu.Lock()
|
|
||||||
defer c.mu.Unlock()
|
|
||||||
var buf bytes.Buffer
|
|
||||||
enc := gob.NewEncoder(&buf)
|
|
||||||
err := enc.Encode(value)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
Cache.cache.Set([]byte(key), buf.Bytes(), expireSeconds)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *CommonCache) Delete(key string) error {
|
|
||||||
c.mu.Lock()
|
|
||||||
defer c.mu.Unlock()
|
|
||||||
Cache.cache.Del([]byte(key))
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
53
common/cache/ristretto.go
vendored
Normal file
53
common/cache/ristretto.go
vendored
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
package cache
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/log"
|
||||||
|
"github.com/dgraph-io/ristretto/v2"
|
||||||
|
"github.com/krau/SaveAny-Bot/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
var cache *ristretto.Cache[string, any]
|
||||||
|
|
||||||
|
func Init() {
|
||||||
|
if cache != nil {
|
||||||
|
panic("cache already initialized")
|
||||||
|
}
|
||||||
|
c, err := ristretto.NewCache(&ristretto.Config[string, any]{
|
||||||
|
NumCounters: config.C().Cache.NumCounters,
|
||||||
|
MaxCost: config.C().Cache.MaxCost,
|
||||||
|
BufferItems: 64,
|
||||||
|
OnReject: func(item *ristretto.Item[any]) {
|
||||||
|
log.Warnf("Cache item rejected: key=%d, value=%v", item.Key, item.Value)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("failed to create ristretto cache: %v", err)
|
||||||
|
}
|
||||||
|
cache = c
|
||||||
|
}
|
||||||
|
|
||||||
|
func Set(key string, value any) error {
|
||||||
|
ok := cache.SetWithTTL(key, value, 0, time.Duration(config.C().Cache.TTL)*time.Second)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("failed to set value in cache")
|
||||||
|
}
|
||||||
|
cache.Wait()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func Get[T any](key string) (T, bool) {
|
||||||
|
v, ok := cache.Get(key)
|
||||||
|
if !ok {
|
||||||
|
var zero T
|
||||||
|
return zero, false
|
||||||
|
}
|
||||||
|
vT, ok := v.(T)
|
||||||
|
if !ok {
|
||||||
|
var zero T
|
||||||
|
return zero, false
|
||||||
|
}
|
||||||
|
return vT, true
|
||||||
|
}
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
package common
|
|
||||||
|
|
||||||
func Init() {
|
|
||||||
initCache()
|
|
||||||
}
|
|
||||||
61
common/i18n/i18n.go
Normal file
61
common/i18n/i18n.go
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
// [TODO] complete the i18n support
|
||||||
|
|
||||||
|
package i18n
|
||||||
|
|
||||||
|
import (
|
||||||
|
"embed"
|
||||||
|
|
||||||
|
"maps"
|
||||||
|
|
||||||
|
"github.com/goccy/go-yaml"
|
||||||
|
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
|
||||||
|
"github.com/nicksnyder/go-i18n/v2/i18n"
|
||||||
|
"golang.org/x/text/language"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed locale/*
|
||||||
|
var localesFS embed.FS
|
||||||
|
|
||||||
|
var (
|
||||||
|
bundle *i18n.Bundle
|
||||||
|
localizer *i18n.Localizer
|
||||||
|
)
|
||||||
|
|
||||||
|
func Init(lang string) {
|
||||||
|
bundle = i18n.NewBundle(language.SimplifiedChinese)
|
||||||
|
bundle.RegisterUnmarshalFunc("yaml", yaml.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 i18nk.Key, templateData ...map[string]any) string {
|
||||||
|
if localizer == nil || bundle == nil {
|
||||||
|
Init("zh-Hans")
|
||||||
|
}
|
||||||
|
templateDataMap := make(map[string]any)
|
||||||
|
for _, data := range templateData {
|
||||||
|
maps.Copy(templateDataMap, data)
|
||||||
|
}
|
||||||
|
msg, err := localizer.Localize(&i18n.LocalizeConfig{
|
||||||
|
MessageID: string(key),
|
||||||
|
TemplateData: templateDataMap,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return string(key)
|
||||||
|
}
|
||||||
|
return msg
|
||||||
|
}
|
||||||
289
common/i18n/i18nk/keys.go
Normal file
289
common/i18n/i18nk/keys.go
Normal file
@@ -0,0 +1,289 @@
|
|||||||
|
// Code generated by cmd/geni18n. DO NOT EDIT.
|
||||||
|
package i18nk
|
||||||
|
|
||||||
|
type Key string
|
||||||
|
|
||||||
|
const (
|
||||||
|
BotMsgAria2ErrorAddingAria2Download Key = "bot.msg.aria2.error_adding_aria2_download"
|
||||||
|
BotMsgAria2ErrorAria2ClientInitFailed Key = "bot.msg.aria2.error_aria2_client_init_failed"
|
||||||
|
BotMsgAria2ErrorAria2NotEnabled Key = "bot.msg.aria2.error_aria2_not_enabled"
|
||||||
|
BotMsgAria2InfoAddingAria2Download Key = "bot.msg.aria2.info_adding_aria2_download"
|
||||||
|
BotMsgAria2InfoAria2DownloadAdded Key = "bot.msg.aria2.info_aria2_download_added"
|
||||||
|
BotMsgAria2InfoSelectStorage Key = "bot.msg.aria2.info_select_storage"
|
||||||
|
BotMsgCancelErrorCancelFailed Key = "bot.msg.cancel.error_cancel_failed"
|
||||||
|
BotMsgCancelInfoCancelRequested Key = "bot.msg.cancel.info_cancel_requested"
|
||||||
|
BotMsgCancelInfoCancellingTask Key = "bot.msg.cancel.info_cancelling_task"
|
||||||
|
BotMsgCancelUsage Key = "bot.msg.cancel.usage"
|
||||||
|
BotMsgCmdAria2dl Key = "bot.msg.cmd.aria2dl"
|
||||||
|
BotMsgCmdCancel Key = "bot.msg.cmd.cancel"
|
||||||
|
BotMsgCmdConfig Key = "bot.msg.cmd.config"
|
||||||
|
BotMsgCmdDir Key = "bot.msg.cmd.dir"
|
||||||
|
BotMsgCmdDl Key = "bot.msg.cmd.dl"
|
||||||
|
BotMsgCmdFnametmpl Key = "bot.msg.cmd.fnametmpl"
|
||||||
|
BotMsgCmdHelp Key = "bot.msg.cmd.help"
|
||||||
|
BotMsgCmdImport Key = "bot.msg.cmd.import"
|
||||||
|
BotMsgCmdLswatch Key = "bot.msg.cmd.lswatch"
|
||||||
|
BotMsgCmdParser Key = "bot.msg.cmd.parser"
|
||||||
|
BotMsgCmdRule Key = "bot.msg.cmd.rule"
|
||||||
|
BotMsgCmdSave Key = "bot.msg.cmd.save"
|
||||||
|
BotMsgCmdSilent Key = "bot.msg.cmd.silent"
|
||||||
|
BotMsgCmdStart Key = "bot.msg.cmd.start"
|
||||||
|
BotMsgCmdStorage Key = "bot.msg.cmd.storage"
|
||||||
|
BotMsgCmdSyncpeers Key = "bot.msg.cmd.syncpeers"
|
||||||
|
BotMsgCmdTask Key = "bot.msg.cmd.task"
|
||||||
|
BotMsgCmdUnwatch Key = "bot.msg.cmd.unwatch"
|
||||||
|
BotMsgCmdUpdate Key = "bot.msg.cmd.update"
|
||||||
|
BotMsgCmdWatch Key = "bot.msg.cmd.watch"
|
||||||
|
BotMsgCmdYtdlp Key = "bot.msg.cmd.ytdlp"
|
||||||
|
BotMsgCommonCancelButtonText Key = "bot.msg.common.cancel_button_text"
|
||||||
|
BotMsgCommonErrorBuildDirSelectKeyboardFailed Key = "bot.msg.common.error_build_dir_select_keyboard_failed"
|
||||||
|
BotMsgCommonErrorBuildStorageSelectKeyboardFailed Key = "bot.msg.common.error_build_storage_select_keyboard_failed"
|
||||||
|
BotMsgCommonErrorBuildStorageSelectMessageFailed Key = "bot.msg.common.error_build_storage_select_message_failed"
|
||||||
|
BotMsgCommonErrorDataExpired Key = "bot.msg.common.error_data_expired"
|
||||||
|
BotMsgCommonErrorDefaultStorageNotSet Key = "bot.msg.common.error_default_storage_not_set"
|
||||||
|
BotMsgCommonErrorGetDirFailed Key = "bot.msg.common.error_get_dir_failed"
|
||||||
|
BotMsgCommonErrorGetFileFailed Key = "bot.msg.common.error_get_file_failed"
|
||||||
|
BotMsgCommonErrorGetMessagesFailed Key = "bot.msg.common.error_get_messages_failed"
|
||||||
|
BotMsgCommonErrorGetStorageFailed Key = "bot.msg.common.error_get_storage_failed"
|
||||||
|
BotMsgCommonErrorGetTelegraphPageFailed Key = "bot.msg.common.error_get_telegraph_page_failed"
|
||||||
|
BotMsgCommonErrorGetUserFailed Key = "bot.msg.common.error_get_user_failed"
|
||||||
|
BotMsgCommonErrorGetUserInfoFailed Key = "bot.msg.common.error_get_user_info_failed"
|
||||||
|
BotMsgCommonErrorGetUserWithErrFailed Key = "bot.msg.common.error_get_user_with_err_failed"
|
||||||
|
BotMsgCommonErrorInvalidIdOrUsername Key = "bot.msg.common.error_invalid_id_or_username"
|
||||||
|
BotMsgCommonErrorInvalidMsgIdRange Key = "bot.msg.common.error_invalid_msg_id_range"
|
||||||
|
BotMsgCommonErrorInvalidRegex Key = "bot.msg.common.error_invalid_regex"
|
||||||
|
BotMsgCommonErrorNoAvailableStorage Key = "bot.msg.common.error_no_available_storage"
|
||||||
|
BotMsgCommonErrorNoImagesInTelegraphPage Key = "bot.msg.common.error_no_images_in_telegraph_page"
|
||||||
|
BotMsgCommonErrorNoMessagesInRange Key = "bot.msg.common.error_no_messages_in_range"
|
||||||
|
BotMsgCommonErrorNoPermission Key = "bot.msg.common.error_no_permission"
|
||||||
|
BotMsgCommonErrorNoSavableFilesFound Key = "bot.msg.common.error_no_savable_files_found"
|
||||||
|
BotMsgCommonErrorNoSavableMessagesInRange Key = "bot.msg.common.error_no_savable_messages_in_range"
|
||||||
|
BotMsgCommonErrorParseTelegraphPathFailed Key = "bot.msg.common.error_parse_telegraph_path_failed"
|
||||||
|
BotMsgCommonErrorTaskAddFailed Key = "bot.msg.common.error_task_add_failed"
|
||||||
|
BotMsgCommonErrorTaskCreateFailed Key = "bot.msg.common.error_task_create_failed"
|
||||||
|
BotMsgCommonErrorUpdateUserInfoFailed Key = "bot.msg.common.error_update_user_info_failed"
|
||||||
|
BotMsgCommonInfoBatchTasksAdded Key = "bot.msg.common.info_batch_tasks_added"
|
||||||
|
BotMsgCommonInfoDefaultStorageSet Key = "bot.msg.common.info_default_storage_set"
|
||||||
|
BotMsgCommonInfoDefaultStorageWithDirSet Key = "bot.msg.common.info_default_storage_with_dir_set"
|
||||||
|
BotMsgCommonInfoFetchingFileInfo Key = "bot.msg.common.info_fetching_file_info"
|
||||||
|
BotMsgCommonInfoFetchingMessages Key = "bot.msg.common.info_fetching_messages"
|
||||||
|
BotMsgCommonInfoFetchingTelegraphPage Key = "bot.msg.common.info_fetching_telegraph_page"
|
||||||
|
BotMsgCommonInfoFoundFilesSelectStorage Key = "bot.msg.common.info_found_files_select_storage"
|
||||||
|
BotMsgCommonInfoSilentModeOff Key = "bot.msg.common.info_silent_mode_off"
|
||||||
|
BotMsgCommonInfoSilentModeOn Key = "bot.msg.common.info_silent_mode_on"
|
||||||
|
BotMsgCommonInfoTaskAdded Key = "bot.msg.common.info_task_added"
|
||||||
|
BotMsgCommonPromptSelectDefaultDir Key = "bot.msg.common.prompt_select_default_dir"
|
||||||
|
BotMsgCommonPromptSelectDefaultStorage Key = "bot.msg.common.prompt_select_default_storage"
|
||||||
|
BotMsgCommonPromptSelectDir Key = "bot.msg.common.prompt_select_dir"
|
||||||
|
BotMsgConfigButtonFilenameStrategy Key = "bot.msg.config.button_filename_strategy"
|
||||||
|
BotMsgConfigErrorInvalidCallbackData Key = "bot.msg.config.error_invalid_callback_data"
|
||||||
|
BotMsgConfigErrorInvalidTemplate Key = "bot.msg.config.error_invalid_template"
|
||||||
|
BotMsgConfigFnametmplHelp Key = "bot.msg.config.fnametmpl_help"
|
||||||
|
BotMsgConfigInfoCurrentTemplatePrefix Key = "bot.msg.config.info_current_template_prefix"
|
||||||
|
BotMsgConfigInfoFilenameStrategySet Key = "bot.msg.config.info_filename_strategy_set"
|
||||||
|
BotMsgConfigInfoTemplateUpdated Key = "bot.msg.config.info_template_updated"
|
||||||
|
BotMsgConfigPromptSelectFilenameStrategy Key = "bot.msg.config.prompt_select_filename_strategy"
|
||||||
|
BotMsgConfigPromptSelectOption Key = "bot.msg.config.prompt_select_option"
|
||||||
|
BotMsgDirButtonDefault Key = "bot.msg.dir.button_default"
|
||||||
|
BotMsgDirErrorCreateDirFailed Key = "bot.msg.dir.error_create_dir_failed"
|
||||||
|
BotMsgDirErrorDeleteDirFailed Key = "bot.msg.dir.error_delete_dir_failed"
|
||||||
|
BotMsgDirErrorGetUserDirsFailed Key = "bot.msg.dir.error_get_user_dirs_failed"
|
||||||
|
BotMsgDirErrorGetUserFailed Key = "bot.msg.dir.error_get_user_failed"
|
||||||
|
BotMsgDirErrorInvalidDirId Key = "bot.msg.dir.error_invalid_dir_id"
|
||||||
|
BotMsgDirErrorUnknownOperation Key = "bot.msg.dir.error_unknown_operation"
|
||||||
|
BotMsgDirHelpAddExampleCmd Key = "bot.msg.dir.help_add_example_cmd"
|
||||||
|
BotMsgDirHelpAddExamplePrefix Key = "bot.msg.dir.help_add_example_prefix"
|
||||||
|
BotMsgDirHelpAddSuffix Key = "bot.msg.dir.help_add_suffix"
|
||||||
|
BotMsgDirHelpAvailableOps Key = "bot.msg.dir.help_available_ops"
|
||||||
|
BotMsgDirHelpDelExampleCmd Key = "bot.msg.dir.help_del_example_cmd"
|
||||||
|
BotMsgDirHelpDelExamplePrefix Key = "bot.msg.dir.help_del_example_prefix"
|
||||||
|
BotMsgDirHelpDelSuffix Key = "bot.msg.dir.help_del_suffix"
|
||||||
|
BotMsgDirHelpExistingDirsPrefix Key = "bot.msg.dir.help_existing_dirs_prefix"
|
||||||
|
BotMsgDirHelpUsage Key = "bot.msg.dir.help_usage"
|
||||||
|
BotMsgDirInfoCreateDirSuccess Key = "bot.msg.dir.info_create_dir_success"
|
||||||
|
BotMsgDirInfoDeleteDirSuccess Key = "bot.msg.dir.info_delete_dir_success"
|
||||||
|
BotMsgDlErrorNoValidLinks Key = "bot.msg.dl.error_no_valid_links"
|
||||||
|
BotMsgDlInfoFilesSelectStorage Key = "bot.msg.dl.info_files_select_storage"
|
||||||
|
BotMsgDlUsage Key = "bot.msg.dl.usage"
|
||||||
|
BotMsgHelpTextFmt Key = "bot.msg.help_text_fmt"
|
||||||
|
BotMsgImportErrorAddTaskFailed Key = "bot.msg.import.error_add_task_failed"
|
||||||
|
BotMsgImportErrorInvalidChatId Key = "bot.msg.import.error_invalid_chat_id"
|
||||||
|
BotMsgImportErrorInvalidRegex Key = "bot.msg.import.error_invalid_regex"
|
||||||
|
BotMsgImportErrorListFilesFailed Key = "bot.msg.import.error_list_files_failed"
|
||||||
|
BotMsgImportErrorNoFilesToImport Key = "bot.msg.import.error_no_files_to_import"
|
||||||
|
BotMsgImportErrorNoTargetChatId Key = "bot.msg.import.error_no_target_chat_id"
|
||||||
|
BotMsgImportErrorNoTelegramStorage Key = "bot.msg.import.error_no_telegram_storage"
|
||||||
|
BotMsgImportErrorStorageNotFound Key = "bot.msg.import.error_storage_not_found"
|
||||||
|
BotMsgImportErrorStorageNotListable Key = "bot.msg.import.error_storage_not_listable"
|
||||||
|
BotMsgImportErrorStorageNotReadable Key = "bot.msg.import.error_storage_not_readable"
|
||||||
|
BotMsgImportInfoFetchingFiles Key = "bot.msg.import.info_fetching_files"
|
||||||
|
BotMsgImportInfoTaskAdded Key = "bot.msg.import.info_task_added"
|
||||||
|
BotMsgImportStartStats Key = "bot.msg.import.start_stats"
|
||||||
|
BotMsgImportUsage Key = "bot.msg.import.usage"
|
||||||
|
BotMsgMediaGroupErrorBuildStorageSelectKeyboardFailed Key = "bot.msg.media_group.error_build_storage_select_keyboard_failed"
|
||||||
|
BotMsgMediaGroupInfoGroupFoundFilesSelectStorage Key = "bot.msg.media_group.info_group_found_files_select_storage"
|
||||||
|
BotMsgMediaGroupInfoSavingFiles Key = "bot.msg.media_group.info_saving_files"
|
||||||
|
BotMsgParseErrorBuildParsedTextEntityFailed Key = "bot.msg.parse.error_build_parsed_text_entity_failed"
|
||||||
|
BotMsgParseErrorBuildStorageSelectKeyboardFailed Key = "bot.msg.parse.error_build_storage_select_keyboard_failed"
|
||||||
|
BotMsgParseErrorParseTextFailed Key = "bot.msg.parse.error_parse_text_failed"
|
||||||
|
BotMsgParseInfoAuthorPrefix Key = "bot.msg.parse.info_author_prefix"
|
||||||
|
BotMsgParseInfoDescriptionPrefix Key = "bot.msg.parse.info_description_prefix"
|
||||||
|
BotMsgParseInfoFileCountPrefix Key = "bot.msg.parse.info_file_count_prefix"
|
||||||
|
BotMsgParseInfoLinkPrefix Key = "bot.msg.parse.info_link_prefix"
|
||||||
|
BotMsgParseInfoParsing Key = "bot.msg.parse.info_parsing"
|
||||||
|
BotMsgParseInfoPromptSelectStorage Key = "bot.msg.parse.info_prompt_select_storage"
|
||||||
|
BotMsgParseInfoTotalSizePrefix Key = "bot.msg.parse.info_total_size_prefix"
|
||||||
|
BotMsgParserErrorDownloadFileFailed Key = "bot.msg.parser.error_download_file_failed"
|
||||||
|
BotMsgParserErrorFileTooLarge Key = "bot.msg.parser.error_file_too_large"
|
||||||
|
BotMsgParserErrorGetFilenameFailed Key = "bot.msg.parser.error_get_filename_failed"
|
||||||
|
BotMsgParserErrorInstallPluginFailed Key = "bot.msg.parser.error_install_plugin_failed"
|
||||||
|
BotMsgParserErrorNoValidFileInReply Key = "bot.msg.parser.error_no_valid_file_in_reply"
|
||||||
|
BotMsgParserErrorOnlyJsSupported Key = "bot.msg.parser.error_only_js_supported"
|
||||||
|
BotMsgParserErrorWrongFileType Key = "bot.msg.parser.error_wrong_file_type"
|
||||||
|
BotMsgParserHelpText Key = "bot.msg.parser.help_text"
|
||||||
|
BotMsgParserInfoInstallPluginSuccess Key = "bot.msg.parser.info_install_plugin_success"
|
||||||
|
BotMsgParserPluginNotEnabled Key = "bot.msg.parser.plugin_not_enabled"
|
||||||
|
BotMsgParserPromptReplyWithParserFile Key = "bot.msg.parser.prompt_reply_with_parser_file"
|
||||||
|
BotMsgProgressAria2Done Key = "bot.msg.progress.aria2_done"
|
||||||
|
BotMsgProgressAria2Downloading Key = "bot.msg.progress.aria2_downloading"
|
||||||
|
BotMsgProgressAria2Start Key = "bot.msg.progress.aria2_start"
|
||||||
|
BotMsgProgressAvgSpeedPrefix Key = "bot.msg.progress.avg_speed_prefix"
|
||||||
|
BotMsgProgressBatchDonePrefix Key = "bot.msg.progress.batch_done_prefix"
|
||||||
|
BotMsgProgressBatchProcessingPrefix Key = "bot.msg.progress.batch_processing_prefix"
|
||||||
|
BotMsgProgressBatchStartPrefix Key = "bot.msg.progress.batch_start_prefix"
|
||||||
|
BotMsgProgressCurrentProgressPrefix Key = "bot.msg.progress.current_progress_prefix"
|
||||||
|
BotMsgProgressCurrentSpeedPrefix Key = "bot.msg.progress.current_speed_prefix"
|
||||||
|
BotMsgProgressDirectDonePrefix Key = "bot.msg.progress.direct_done_prefix"
|
||||||
|
BotMsgProgressDirectStart Key = "bot.msg.progress.direct_start"
|
||||||
|
BotMsgProgressDownloadDonePrefix Key = "bot.msg.progress.download_done_prefix"
|
||||||
|
BotMsgProgressDownloadFailedPrefix Key = "bot.msg.progress.download_failed_prefix"
|
||||||
|
BotMsgProgressDownloadedPrefix Key = "bot.msg.progress.downloaded_prefix"
|
||||||
|
BotMsgProgressDownloadingPrefix Key = "bot.msg.progress.downloading_prefix"
|
||||||
|
BotMsgProgressErrorPrefix Key = "bot.msg.progress.error_prefix"
|
||||||
|
BotMsgProgressFileNamePrefix Key = "bot.msg.progress.file_name_prefix"
|
||||||
|
BotMsgProgressFileProcessingPrefix Key = "bot.msg.progress.file_processing_prefix"
|
||||||
|
BotMsgProgressFileSizePrefix Key = "bot.msg.progress.file_size_prefix"
|
||||||
|
BotMsgProgressFileStartPrefix Key = "bot.msg.progress.file_start_prefix"
|
||||||
|
BotMsgProgressImportAvgSpeedPrefix Key = "bot.msg.progress.import_avg_speed_prefix"
|
||||||
|
BotMsgProgressImportElapsedTimePrefix Key = "bot.msg.progress.import_elapsed_time_prefix"
|
||||||
|
BotMsgProgressImportFailedFilesPrefix Key = "bot.msg.progress.import_failed_files_prefix"
|
||||||
|
BotMsgProgressImportFailedPrefix Key = "bot.msg.progress.import_failed_prefix"
|
||||||
|
BotMsgProgressImportProcessingMore Key = "bot.msg.progress.import_processing_more"
|
||||||
|
BotMsgProgressImportProcessingPrefix Key = "bot.msg.progress.import_processing_prefix"
|
||||||
|
BotMsgProgressImportProgressPrefix Key = "bot.msg.progress.import_progress_prefix"
|
||||||
|
BotMsgProgressImportRemainingTimePrefix Key = "bot.msg.progress.import_remaining_time_prefix"
|
||||||
|
BotMsgProgressImportSpeedPrefix Key = "bot.msg.progress.import_speed_prefix"
|
||||||
|
BotMsgProgressImportStartPrefix Key = "bot.msg.progress.import_start_prefix"
|
||||||
|
BotMsgProgressImportSuccessPrefix Key = "bot.msg.progress.import_success_prefix"
|
||||||
|
BotMsgProgressImportTotalFilesPrefix Key = "bot.msg.progress.import_total_files_prefix"
|
||||||
|
BotMsgProgressImportTotalSizePrefix Key = "bot.msg.progress.import_total_size_prefix"
|
||||||
|
BotMsgProgressImportUploadedPrefix Key = "bot.msg.progress.import_uploaded_prefix"
|
||||||
|
BotMsgProgressParsedDonePrefix Key = "bot.msg.progress.parsed_done_prefix"
|
||||||
|
BotMsgProgressParsedStartPrefix Key = "bot.msg.progress.parsed_start_prefix"
|
||||||
|
BotMsgProgressProcessingListPrefix Key = "bot.msg.progress.processing_list_prefix"
|
||||||
|
BotMsgProgressProcessingNone Key = "bot.msg.progress.processing_none"
|
||||||
|
BotMsgProgressSavePathPrefix Key = "bot.msg.progress.save_path_prefix"
|
||||||
|
BotMsgProgressTaskCanceled Key = "bot.msg.progress.task_canceled"
|
||||||
|
BotMsgProgressTaskCanceledWithId Key = "bot.msg.progress.task_canceled_with_id"
|
||||||
|
BotMsgProgressTaskFailedWithError Key = "bot.msg.progress.task_failed_with_error"
|
||||||
|
BotMsgProgressTelegraphDonePrefix Key = "bot.msg.progress.telegraph_done_prefix"
|
||||||
|
BotMsgProgressTelegraphProgressPrefix Key = "bot.msg.progress.telegraph_progress_prefix"
|
||||||
|
BotMsgProgressTelegraphStartPrefix Key = "bot.msg.progress.telegraph_start_prefix"
|
||||||
|
BotMsgProgressTotalSizePrefix Key = "bot.msg.progress.total_size_prefix"
|
||||||
|
BotMsgProgressYtdlpDone Key = "bot.msg.progress.ytdlp_done"
|
||||||
|
BotMsgProgressYtdlpDownloading Key = "bot.msg.progress.ytdlp_downloading"
|
||||||
|
BotMsgProgressYtdlpStart Key = "bot.msg.progress.ytdlp_start"
|
||||||
|
BotMsgRuleErrorCreateRuleFailed Key = "bot.msg.rule.error_create_rule_failed"
|
||||||
|
BotMsgRuleErrorDeleteRuleFailed Key = "bot.msg.rule.error_delete_rule_failed"
|
||||||
|
BotMsgRuleErrorGetUserRulesFailed Key = "bot.msg.rule.error_get_user_rules_failed"
|
||||||
|
BotMsgRuleErrorInvalidRuleId Key = "bot.msg.rule.error_invalid_rule_id"
|
||||||
|
BotMsgRuleErrorInvalidRuleType Key = "bot.msg.rule.error_invalid_rule_type"
|
||||||
|
BotMsgRuleErrorUpdateUserFailed Key = "bot.msg.rule.error_update_user_failed"
|
||||||
|
BotMsgRuleHelpAddSuffix Key = "bot.msg.rule.help_add_suffix"
|
||||||
|
BotMsgRuleHelpAvailableOps Key = "bot.msg.rule.help_available_ops"
|
||||||
|
BotMsgRuleHelpCurrentModeDisabled Key = "bot.msg.rule.help_current_mode_disabled"
|
||||||
|
BotMsgRuleHelpCurrentModeEnabled Key = "bot.msg.rule.help_current_mode_enabled"
|
||||||
|
BotMsgRuleHelpDelSuffix Key = "bot.msg.rule.help_del_suffix"
|
||||||
|
BotMsgRuleHelpExistingRulesPrefix Key = "bot.msg.rule.help_existing_rules_prefix"
|
||||||
|
BotMsgRuleHelpSwitchSuffix Key = "bot.msg.rule.help_switch_suffix"
|
||||||
|
BotMsgRuleHelpUsage Key = "bot.msg.rule.help_usage"
|
||||||
|
BotMsgRuleInfoCreateRuleSuccess Key = "bot.msg.rule.info_create_rule_success"
|
||||||
|
BotMsgRuleInfoDeleteRuleSuccess Key = "bot.msg.rule.info_delete_rule_success"
|
||||||
|
BotMsgRuleInfoRuleModeDisabled Key = "bot.msg.rule.info_rule_mode_disabled"
|
||||||
|
BotMsgRuleInfoRuleModeEnabled Key = "bot.msg.rule.info_rule_mode_enabled"
|
||||||
|
BotMsgRulePromptProvideRuleId Key = "bot.msg.rule.prompt_provide_rule_id"
|
||||||
|
BotMsgSaveErrorInvalidIdOrUsername Key = "bot.msg.save.error_invalid_id_or_username"
|
||||||
|
BotMsgSaveHelpText Key = "bot.msg.save_help_text"
|
||||||
|
BotMsgStorageInfoFilenamePrefix Key = "bot.msg.storage.info_filename_prefix"
|
||||||
|
BotMsgStorageInfoPromptSelectStorage Key = "bot.msg.storage.info_prompt_select_storage"
|
||||||
|
BotMsgSyncpeersDone Key = "bot.msg.syncpeers.done"
|
||||||
|
BotMsgSyncpeersFailed Key = "bot.msg.syncpeers.failed"
|
||||||
|
BotMsgSyncpeersStart Key = "bot.msg.syncpeers.start"
|
||||||
|
BotMsgSyncpeersSuccess Key = "bot.msg.syncpeers.success"
|
||||||
|
BotMsgTasksCancelFailed Key = "bot.msg.tasks.cancel_failed"
|
||||||
|
BotMsgTasksCancelRequestedPrefix Key = "bot.msg.tasks.cancel_requested_prefix"
|
||||||
|
BotMsgTasksFieldCreated Key = "bot.msg.tasks.field_created"
|
||||||
|
BotMsgTasksFieldId Key = "bot.msg.tasks.field_id"
|
||||||
|
BotMsgTasksFieldStatus Key = "bot.msg.tasks.field_status"
|
||||||
|
BotMsgTasksFieldTitle Key = "bot.msg.tasks.field_title"
|
||||||
|
BotMsgTasksInfoAddedToQueueFull Key = "bot.msg.tasks.info_added_to_queue_full"
|
||||||
|
BotMsgTasksInfoAddedToQueuePrefix Key = "bot.msg.tasks.info_added_to_queue_prefix"
|
||||||
|
BotMsgTasksInfoFilenamePrefix Key = "bot.msg.tasks.info_filename_prefix"
|
||||||
|
BotMsgTasksInfoQueueLengthPrefix Key = "bot.msg.tasks.info_queue_length_prefix"
|
||||||
|
BotMsgTasksQueuedEmpty Key = "bot.msg.tasks.queued_empty"
|
||||||
|
BotMsgTasksQueuedTitle Key = "bot.msg.tasks.queued_title"
|
||||||
|
BotMsgTasksRunningEmpty Key = "bot.msg.tasks.running_empty"
|
||||||
|
BotMsgTasksRunningTitle Key = "bot.msg.tasks.running_title"
|
||||||
|
BotMsgTasksStatusCancelRequested Key = "bot.msg.tasks.status_cancel_requested"
|
||||||
|
BotMsgTasksStatusQueued Key = "bot.msg.tasks.status_queued"
|
||||||
|
BotMsgTasksStatusRunning Key = "bot.msg.tasks.status_running"
|
||||||
|
BotMsgTasksTotalPrefix Key = "bot.msg.tasks.total_prefix"
|
||||||
|
BotMsgTasksTruncatedNote Key = "bot.msg.tasks.truncated_note"
|
||||||
|
BotMsgTasksUsage Key = "bot.msg.tasks.usage"
|
||||||
|
BotMsgTasksUsageCancel Key = "bot.msg.tasks.usage_cancel"
|
||||||
|
BotMsgTelegraphErrorBuildStorageSelectKeyboardFailed Key = "bot.msg.telegraph.error_build_storage_select_keyboard_failed"
|
||||||
|
BotMsgTelegraphInfoPicCountPrefix Key = "bot.msg.telegraph.info_pic_count_prefix"
|
||||||
|
BotMsgTelegraphInfoPromptSelectStorage Key = "bot.msg.telegraph.info_prompt_select_storage"
|
||||||
|
BotMsgTelegraphInfoTitlePrefix Key = "bot.msg.telegraph.info_title_prefix"
|
||||||
|
BotMsgUpdateButtonUpgrade Key = "bot.msg.update.button_upgrade"
|
||||||
|
BotMsgUpdateErrorCheckLatestFailed Key = "bot.msg.update.error_check_latest_failed"
|
||||||
|
BotMsgUpdateErrorNoReleaseFound Key = "bot.msg.update.error_no_release_found"
|
||||||
|
BotMsgUpdateErrorUpgradeFailed Key = "bot.msg.update.error_upgrade_failed"
|
||||||
|
BotMsgUpdateErrorVersionVarInvalid Key = "bot.msg.update.error_version_var_invalid"
|
||||||
|
BotMsgUpdateInfoAlreadyLatest Key = "bot.msg.update.info_already_latest"
|
||||||
|
BotMsgUpdateInfoMajorUpgradeRequired Key = "bot.msg.update.info_major_upgrade_required"
|
||||||
|
BotMsgUpdateInfoNewVersionInDocker Key = "bot.msg.update.info_new_version_in_docker"
|
||||||
|
BotMsgUpdateInfoNewVersionPromptUpgrade Key = "bot.msg.update.info_new_version_prompt_upgrade"
|
||||||
|
BotMsgUpdateInfoUpgradeSuccess Key = "bot.msg.update.info_upgrade_success"
|
||||||
|
BotMsgUpdateInfoUpgradingWithVersion Key = "bot.msg.update.info_upgrading_with_version"
|
||||||
|
BotMsgWatchErrorFilterFormatInvalid Key = "bot.msg.watch.error_filter_format_invalid"
|
||||||
|
BotMsgWatchErrorFilterTypeUnsupported Key = "bot.msg.watch.error_filter_type_unsupported"
|
||||||
|
BotMsgWatchErrorUnwatchChatFailed Key = "bot.msg.watch.error_unwatch_chat_failed"
|
||||||
|
BotMsgWatchErrorUnwatchNoChatProvided Key = "bot.msg.watch.error_unwatch_no_chat_provided"
|
||||||
|
BotMsgWatchErrorWatchChatFailed Key = "bot.msg.watch.error_watch_chat_failed"
|
||||||
|
BotMsgWatchInfoAlreadyWatchingChat Key = "bot.msg.watch.info_already_watching_chat"
|
||||||
|
BotMsgWatchInfoWatchChatStarted Key = "bot.msg.watch.info_watch_chat_started"
|
||||||
|
BotMsgWatchInfoWatchChatStopped Key = "bot.msg.watch.info_watch_chat_stopped"
|
||||||
|
BotMsgWatchInfoWatchListEmpty Key = "bot.msg.watch.info_watch_list_empty"
|
||||||
|
BotMsgWatchInfoWatchListFilterPrefix Key = "bot.msg.watch.info_watch_list_filter_prefix"
|
||||||
|
BotMsgWatchInfoWatchListHeader Key = "bot.msg.watch.info_watch_list_header"
|
||||||
|
BotMsgWatchHelpText Key = "bot.msg.watch_help_text"
|
||||||
|
BotMsgYtdlpErrorDownloadFailed Key = "bot.msg.ytdlp.error_download_failed"
|
||||||
|
BotMsgYtdlpErrorNoValidUrls Key = "bot.msg.ytdlp.error_no_valid_urls"
|
||||||
|
BotMsgYtdlpInfoDownloading Key = "bot.msg.ytdlp.info_downloading"
|
||||||
|
BotMsgYtdlpInfoUrlsSelectStorage Key = "bot.msg.ytdlp.info_urls_select_storage"
|
||||||
|
BotMsgYtdlpUsage Key = "bot.msg.ytdlp.usage"
|
||||||
|
ConfigErrDuplicateStorageName Key = "config.err.duplicate_storage_name"
|
||||||
|
ConfigErrInvalidCacheDir Key = "config.err.invalid_cache_dir"
|
||||||
|
ErrCleanCacheFailed Key = "err.clean_cache_failed"
|
||||||
|
ErrGetCacheAbsPathFailed Key = "err.get_cache_abs_path_failed"
|
||||||
|
ErrGetWorkdirFailed Key = "err.get_workdir_failed"
|
||||||
|
LifetimeBye Key = "lifetime.bye"
|
||||||
|
LifetimeCleaningCache Key = "lifetime.cleaning_cache"
|
||||||
|
LifetimeExiting Key = "lifetime.exiting"
|
||||||
|
LifetimeInitfailed Key = "lifetime.initfailed"
|
||||||
|
LifetimeIniting Key = "lifetime.initing"
|
||||||
|
LifetimeUserLoginFailed Key = "lifetime.user_login_failed"
|
||||||
|
ParserPluginLoadFailed Key = "parser.plugin.load_failed"
|
||||||
|
ParserPluginLoadedDir Key = "parser.plugin.loaded_dir"
|
||||||
|
)
|
||||||
385
common/i18n/locale/en.yaml
Normal file
385
common/i18n/locale/en.yaml
Normal file
@@ -0,0 +1,385 @@
|
|||||||
|
lifetime:
|
||||||
|
initing: Starting up
|
||||||
|
initfailed: Initialization failed
|
||||||
|
exiting: Shutting down
|
||||||
|
user_login_failed: "User login failed: {{.Error}}"
|
||||||
|
cleaning_cache: "Cleaning cache {{.Path}}"
|
||||||
|
bye: Exited
|
||||||
|
config:
|
||||||
|
err:
|
||||||
|
invalid_cache_dir: "Invalid cache directory: {{.Path}}, please check the config file"
|
||||||
|
duplicate_storage_name: "Storage name '{{.Name}}' is duplicated, please check the config file"
|
||||||
|
err:
|
||||||
|
get_workdir_failed: "Failed to get working directory: {{.Error}}"
|
||||||
|
get_cache_abs_path_failed: "Failed to get absolute cache path: {{.Error}}"
|
||||||
|
clean_cache_failed: "Failed to clean cache: {{.Error}}"
|
||||||
|
parser:
|
||||||
|
plugin:
|
||||||
|
load_failed: Failed to load parser plugins
|
||||||
|
loaded_dir: Parser plugins loaded
|
||||||
|
bot:
|
||||||
|
msg:
|
||||||
|
help_text_fmt: |
|
||||||
|
Save Any Bot - Save your Telegram files
|
||||||
|
Version: %s , Commit: %s
|
||||||
|
|
||||||
|
Commands:
|
||||||
|
/start - Start using the bot
|
||||||
|
/help - Show help
|
||||||
|
/silent - Toggle silent mode
|
||||||
|
/storage - Set default storage
|
||||||
|
/save [custom filename] - Save file
|
||||||
|
/import <storage_name> <dir_path> [channel_id] [filter] - Import files from storage to Telegram
|
||||||
|
/dir - Manage storage directories
|
||||||
|
/rule - Manage rules
|
||||||
|
/config - Modify configuration
|
||||||
|
/fnametmpl - Set custom filename template
|
||||||
|
/parser - Manage parser plugins
|
||||||
|
/task - Manage task queue
|
||||||
|
/watch - Watch chats and auto save (UserBot)
|
||||||
|
/unwatch - Stop watching chats (UserBot)
|
||||||
|
/lswatch - List watched chats (UserBot)
|
||||||
|
/syncpeers - Sync peer chats (UserBot)
|
||||||
|
/update - Check and upgrade to latest version
|
||||||
|
|
||||||
|
Usage guide: https://sabot.unv.app/usage
|
||||||
|
cmd:
|
||||||
|
start: "Start using"
|
||||||
|
silent: "Toggle silent mode"
|
||||||
|
storage: "Set default storage"
|
||||||
|
dir: "Manage storage directories"
|
||||||
|
rule: "Manage auto-save rules"
|
||||||
|
save: "Save files"
|
||||||
|
dl: "Download files from given links"
|
||||||
|
aria2dl: "Download files using Aria2"
|
||||||
|
ytdlp: "Download video/audio using yt-dlp"
|
||||||
|
import: "Import files from storage to Telegram"
|
||||||
|
task: "Manage task queue"
|
||||||
|
cancel: "Cancel task"
|
||||||
|
watch: "Watch chats (UserBot)"
|
||||||
|
unwatch: "Stop watching chats (UserBot)"
|
||||||
|
lswatch: "List watched chats (UserBot)"
|
||||||
|
config: "Modify configuration"
|
||||||
|
fnametmpl: "Set filename template"
|
||||||
|
help: "Show help"
|
||||||
|
parser: "Manage parsers"
|
||||||
|
update: "Check for updates"
|
||||||
|
syncpeers: "Sync peer chats (UserBot)"
|
||||||
|
save_help_text: |
|
||||||
|
Usage:
|
||||||
|
|
||||||
|
1. Reply to the file you want to save with this command, optional filename parameter.
|
||||||
|
Example:
|
||||||
|
/save custom_file_name.mp4
|
||||||
|
|
||||||
|
2. After setting default storage, send /save <channel_id/username> <message_id_range> to batch save files. Rules will be applied; if no rule matches, default storage will be used.
|
||||||
|
Example:
|
||||||
|
/save @acherkrau 114-514
|
||||||
|
watch_help_text: |
|
||||||
|
Use /watch to watch messages in a chat and automatically save them to the default storage, following storage rules.
|
||||||
|
|
||||||
|
Syntax:
|
||||||
|
/watch <chat_id> [filter]
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
- <chat_id>: Chat ID or username
|
||||||
|
- [filter]: Optional, format is filter_type:expression , see docs for all supported filters
|
||||||
|
|
||||||
|
Example:
|
||||||
|
/watch -1002229835658 msgre:.*plana.*
|
||||||
|
|
||||||
|
This will watch chat with ID -1002229835658 and save all media messages containing "plana".
|
||||||
|
common:
|
||||||
|
cancel_button_text: "Cancel"
|
||||||
|
error_invalid_regex: "Invalid regex: {{.Error}}"
|
||||||
|
error_invalid_msg_id_range: "Invalid message ID range: {{.Error}}"
|
||||||
|
error_invalid_id_or_username: "Invalid ID or username: {{.Error}}"
|
||||||
|
error_get_messages_failed: "Failed to get messages: {{.Error}}"
|
||||||
|
info_fetching_messages: "Fetching messages..."
|
||||||
|
error_no_messages_in_range: "No messages found in the specified range"
|
||||||
|
error_no_savable_messages_in_range: "No savable messages found in the specified range"
|
||||||
|
error_build_storage_select_message_failed: "Failed to build storage selection message: {{.Error}}"
|
||||||
|
error_build_storage_select_keyboard_failed: "Failed to build storage selection keyboard: {{.Error}}"
|
||||||
|
info_found_files_select_storage: "Found {{.Count}} files, please select storage"
|
||||||
|
error_get_user_failed: "Failed to get user"
|
||||||
|
error_get_user_with_err_failed: "Failed to get user: {{.Error}}"
|
||||||
|
error_default_storage_not_set: "Please set a default storage first with /storage"
|
||||||
|
error_no_available_storage: "No available storage"
|
||||||
|
error_get_storage_failed: "Failed to get storage: {{.Error}}"
|
||||||
|
prompt_select_default_storage: "Please select a storage to set as default"
|
||||||
|
error_data_expired: "Data has expired or is invalid"
|
||||||
|
error_task_add_failed: "Failed to add task: {{.Error}}"
|
||||||
|
info_task_added: "Task added"
|
||||||
|
info_batch_tasks_added: "Batch tasks added, total {{.Count}} files"
|
||||||
|
error_task_create_failed: "Failed to create task: {{.Error}}"
|
||||||
|
error_get_dir_failed: "Failed to get directory: {{.Error}}"
|
||||||
|
prompt_select_dir: "Please select a directory to store to"
|
||||||
|
prompt_select_default_dir: "Please select a default directory to save to"
|
||||||
|
info_default_storage_set: "Default storage set to: {{.Name}}"
|
||||||
|
info_default_storage_with_dir_set: "Default storage set to: {{.Name}}:/{{.Dir}}"
|
||||||
|
error_get_user_info_failed: "Failed to get user info: {{.Error}}"
|
||||||
|
error_update_user_info_failed: "Failed to update user info: {{.Error}}"
|
||||||
|
info_silent_mode_on: "Silent mode enabled"
|
||||||
|
info_silent_mode_off: "Silent mode disabled"
|
||||||
|
error_get_file_failed: "Failed to get file: {{.Error}}"
|
||||||
|
info_fetching_file_info: "Fetching file info..."
|
||||||
|
error_no_savable_files_found: "No savable files found"
|
||||||
|
error_parse_telegraph_path_failed: "Failed to parse telegraph path: {{.Error}}"
|
||||||
|
info_fetching_telegraph_page: "Fetching telegraph page..."
|
||||||
|
error_get_telegraph_page_failed: "Failed to get telegraph page: {{.Error}}"
|
||||||
|
error_no_images_in_telegraph_page: "No images found in telegraph page"
|
||||||
|
error_build_dir_select_keyboard_failed: "Failed to build directory selection keyboard: {{.Error}}"
|
||||||
|
error_no_permission: |
|
||||||
|
You are not in the whitelist and cannot use this bot.
|
||||||
|
You can deploy your own instance: https://github.com/krau/SaveAny-Bot
|
||||||
|
save:
|
||||||
|
error_invalid_id_or_username: "Invalid ID or username: {{.Error}}"
|
||||||
|
watch:
|
||||||
|
error_filter_format_invalid: "Invalid filter format, please use <type>:<expression>"
|
||||||
|
error_filter_type_unsupported: "Unsupported filter type, please see the docs"
|
||||||
|
error_watch_chat_failed: "Failed to watch chat: {{.Error}}"
|
||||||
|
info_watch_chat_started: "Started watching chat: {{.Chat}}"
|
||||||
|
info_already_watching_chat: "Already watching this chat"
|
||||||
|
info_watch_list_empty: "No chats are being watched currently"
|
||||||
|
info_watch_list_header: "Currently watched chats:\n"
|
||||||
|
info_watch_list_filter_prefix: " (filter: "
|
||||||
|
error_unwatch_no_chat_provided: "Please provide a chat ID or username to unwatch"
|
||||||
|
error_unwatch_chat_failed: "Failed to unwatch chat: {{.Error}}"
|
||||||
|
info_watch_chat_stopped: "Stopped watching chat: {{.Chat}}"
|
||||||
|
tasks:
|
||||||
|
usage_cancel: "Usage: /tasks cancel <task_id>"
|
||||||
|
usage: "Usage: /tasks [running|queued|cancel <task_id>]"
|
||||||
|
cancel_failed: "Failed to cancel task: {{.Error}}"
|
||||||
|
cancel_requested_prefix: "Cancel requested for task: "
|
||||||
|
running_empty: "No running tasks"
|
||||||
|
running_title: "Currently running tasks:"
|
||||||
|
total_prefix: "Total: {{.Count}}\n"
|
||||||
|
field_id: "ID: "
|
||||||
|
field_title: "Title: "
|
||||||
|
field_created: "Created at: "
|
||||||
|
field_status: "Status: "
|
||||||
|
status_running: "Running"
|
||||||
|
status_queued: "Queued"
|
||||||
|
status_cancel_requested: "Cancel requested"
|
||||||
|
queued_empty: "No queued tasks"
|
||||||
|
queued_title: "Currently queued tasks:"
|
||||||
|
truncated_note: "...\nShowing first 10 tasks, total {{.Count}} tasks"
|
||||||
|
info_added_to_queue_full: "Added to task queue\nFilename: {{.Filename}}\nCurrent queued tasks: {{.QueueLength}}"
|
||||||
|
info_added_to_queue_prefix: "Added to task queue\n"
|
||||||
|
info_filename_prefix: "Filename: "
|
||||||
|
info_queue_length_prefix: "\nCurrent queued tasks: "
|
||||||
|
rule:
|
||||||
|
error_get_user_rules_failed: "Failed to get user rules"
|
||||||
|
error_update_user_failed: "Failed to update user"
|
||||||
|
info_rule_mode_enabled: "Rule mode enabled"
|
||||||
|
info_rule_mode_disabled: "Rule mode disabled"
|
||||||
|
error_invalid_rule_type: "Invalid rule type: {{.Type}}\nAvailable: {{.Available}}"
|
||||||
|
error_create_rule_failed: "Failed to create rule"
|
||||||
|
info_create_rule_success: "Rule created successfully"
|
||||||
|
prompt_provide_rule_id: "Please provide rule ID"
|
||||||
|
error_invalid_rule_id: "Invalid rule ID"
|
||||||
|
error_delete_rule_failed: "Failed to delete rule"
|
||||||
|
info_delete_rule_success: "Rule deleted successfully"
|
||||||
|
help_usage: "Usage: /rule <op> <args...>"
|
||||||
|
help_current_mode_enabled: "\nRule mode is currently enabled"
|
||||||
|
help_current_mode_disabled: "\nRule mode is currently disabled"
|
||||||
|
help_available_ops: "\n\nAvailable operations:\n"
|
||||||
|
help_switch_suffix: " - Toggle rule mode\n"
|
||||||
|
help_add_suffix: " <type> <data> <storage_name> <path> - Add rule\n"
|
||||||
|
help_del_suffix: " <rule_id> - Delete rule\n"
|
||||||
|
help_existing_rules_prefix: "\nCurrent rules:\n"
|
||||||
|
dir:
|
||||||
|
error_get_user_dirs_failed: "Failed to get user directories"
|
||||||
|
error_get_user_failed: "Failed to get user"
|
||||||
|
error_create_dir_failed: "Failed to create directory"
|
||||||
|
info_create_dir_success: "Directory created successfully"
|
||||||
|
error_invalid_dir_id: "Invalid directory ID"
|
||||||
|
error_delete_dir_failed: "Failed to delete directory"
|
||||||
|
info_delete_dir_success: "Directory deleted successfully"
|
||||||
|
error_unknown_operation: "Unknown operation"
|
||||||
|
help_usage: "Usage: /dir <op> <args...>"
|
||||||
|
help_available_ops: "\n\nAvailable operations:\n"
|
||||||
|
help_add_suffix: " <storage_name> <path> - Add path\n"
|
||||||
|
help_del_suffix: " <path_id> - Delete path\n"
|
||||||
|
help_add_example_prefix: "\nAdd path example:\n"
|
||||||
|
help_add_example_cmd: "/dir add local1 path/to/dir"
|
||||||
|
help_del_example_prefix: "\n\nDelete path example:\n"
|
||||||
|
help_del_example_cmd: "/dir del 3"
|
||||||
|
help_existing_dirs_prefix: "\n\nCurrent paths:\n"
|
||||||
|
button_default: "Default"
|
||||||
|
parser:
|
||||||
|
help_text: |
|
||||||
|
Usage:
|
||||||
|
|
||||||
|
/parser install <reply a file> - Install parser
|
||||||
|
plugin_not_enabled: "Parser plugin feature is not enabled"
|
||||||
|
prompt_reply_with_parser_file: "Please reply with a message containing the parser file"
|
||||||
|
error_no_valid_file_in_reply: "The replied message does not contain a valid file"
|
||||||
|
error_wrong_file_type: "Wrong file type"
|
||||||
|
error_file_too_large: "File too large"
|
||||||
|
error_get_filename_failed: "Failed to get filename"
|
||||||
|
error_only_js_supported: "Only .js files are supported as parsers"
|
||||||
|
error_download_file_failed: "Failed to download file: {{.Error}}"
|
||||||
|
error_install_plugin_failed: "Failed to install plugin: {{.Error}}"
|
||||||
|
info_install_plugin_success: "Plugin installed: {{.Name}}"
|
||||||
|
parse:
|
||||||
|
info_parsing: "Parsing..."
|
||||||
|
error_parse_text_failed: "Failed to parse text: {{.Error}}"
|
||||||
|
error_build_storage_select_keyboard_failed: "Failed to build storage selection keyboard: {{.Error}}"
|
||||||
|
error_build_parsed_text_entity_failed: "Failed to build parsed text entity: {{.Error}}"
|
||||||
|
info_link_prefix: "\nLink: "
|
||||||
|
info_author_prefix: "\nAuthor: "
|
||||||
|
info_description_prefix: "\nDescription: "
|
||||||
|
info_file_count_prefix: "\nFile count: "
|
||||||
|
info_total_size_prefix: "\nEstimated total size: "
|
||||||
|
info_prompt_select_storage: "\nPlease select storage"
|
||||||
|
telegraph:
|
||||||
|
error_build_storage_select_keyboard_failed: "Failed to build storage selection keyboard: {{.Error}}"
|
||||||
|
info_title_prefix: "Title: "
|
||||||
|
info_pic_count_prefix: "\nImage count: "
|
||||||
|
info_prompt_select_storage: "\nPlease select storage"
|
||||||
|
update:
|
||||||
|
error_version_var_invalid: "Currently in development version or version info injection failed: {{.Error}}"
|
||||||
|
error_check_latest_failed: "Failed to check latest version: {{.Error}}"
|
||||||
|
error_no_release_found: "No release found"
|
||||||
|
info_major_upgrade_required: "Major upgrade detected: {{.Current}} -> {{.Latest}} , please download the latest version from GitHub and check the migration guide"
|
||||||
|
info_already_latest: "Already on latest version: {{.Version}}"
|
||||||
|
info_new_version_in_docker: |-
|
||||||
|
New version found: {{.Latest}}
|
||||||
|
Current version: {{.Current}}
|
||||||
|
Published at: {{.PublishedAt}}
|
||||||
|
Since you are using Docker, please update it on your deployment platform
|
||||||
|
info_new_version_prompt_upgrade: |-
|
||||||
|
New version found: {{.Latest}}
|
||||||
|
Current version: {{.Current}}
|
||||||
|
|
||||||
|
File size: {{.SizeMB}} MB
|
||||||
|
Download URL: {{.URL}}
|
||||||
|
Published at: {{.PublishedAt}}
|
||||||
|
|
||||||
|
Upgrading will restart the bot. Proceed?
|
||||||
|
info_upgrading_with_version: "Upgrading, current version: {{.Current}}"
|
||||||
|
error_upgrade_failed: "Upgrade failed: {{.Error}}"
|
||||||
|
info_upgrade_success: "Upgraded to version {{.Version}}\nIf the bot did not restart automatically, please start it manually"
|
||||||
|
button_upgrade: "Upgrade"
|
||||||
|
config:
|
||||||
|
prompt_select_option: "Please select an option to configure"
|
||||||
|
button_filename_strategy: "Filename strategy"
|
||||||
|
error_invalid_callback_data: "Invalid callback data"
|
||||||
|
error_invalid_template: "Invalid template, please check syntax\n{{.Error}}"
|
||||||
|
info_filename_strategy_set: "Filename strategy set to: {{.Strategy}}"
|
||||||
|
prompt_select_filename_strategy: "Please select filename strategy, current strategy: {{.Strategy}}"
|
||||||
|
fnametmpl_help: |-
|
||||||
|
Use this command to set filename template, for example:
|
||||||
|
/fnametmpl Image_{{"{{.msgid}}"}}_{{"{{.msgdate}}"}}.jpg
|
||||||
|
|
||||||
|
Available variables:
|
||||||
|
- {{"{{.msgid}}"}}: Message ID
|
||||||
|
- {{"{{.msgtags}}"}}: Tags in the message, joined with underscore
|
||||||
|
- {{"{{.msggen}}"}}: Generated filename from the message
|
||||||
|
- {{"{{.msgdate}}"}}: Message date, format YYYY-MM-DD_HH-MM-SS
|
||||||
|
- {{"{{.msgraw}}"}}: Raw message text (unprocessed)
|
||||||
|
- {{"{{.origname}}"}}: Original media filename (if any)
|
||||||
|
- {{"{{.chatid}}"}}: Chat ID of the message
|
||||||
|
|
||||||
|
Template only takes effect when filename strategy is set to 'Custom template'.
|
||||||
|
If template parsing fails, it will fall back to default filename.
|
||||||
|
info_template_updated: "Filename template updated"
|
||||||
|
info_current_template_prefix: "Current template: {{.Template}}"
|
||||||
|
dl:
|
||||||
|
usage: "Usage: /dl <url1> <url2> ..."
|
||||||
|
error_no_valid_links: "No valid links to download"
|
||||||
|
info_files_select_storage: "Total {{.Count}} files, please select storage"
|
||||||
|
ytdlp:
|
||||||
|
usage: "Usage: /ytdlp [OPTIONS] <URL1> [URL2] ...\nExamples:\n /ytdlp https://example.com/video\n /ytdlp --format best https://example.com/video\n /ytdlp --extract-audio --audio-format mp3 https://example.com/video"
|
||||||
|
error_no_valid_urls: "No valid URLs"
|
||||||
|
info_urls_select_storage: "Found {{.Count}} links, please select storage"
|
||||||
|
info_downloading: "Downloading via yt-dlp..."
|
||||||
|
error_download_failed: "yt-dlp download failed: {{.Error}}"
|
||||||
|
import:
|
||||||
|
usage: "Usage: /import <storage_name> <dir_path> [target_chat_id] [filter]\n\nExamples:\n/import local1 /downloads\n/import MyAlist /media/photos -1001234567890\n/import MyLocal /backup \".*[.]mp4$\""
|
||||||
|
error_storage_not_found: "Storage '{{.StorageName}}' not found or access denied: {{.Error}}"
|
||||||
|
error_storage_not_listable: "Storage '{{.StorageName}}' does not support listing files"
|
||||||
|
error_storage_not_readable: "Storage '{{.StorageName}}' does not support reading files"
|
||||||
|
error_no_telegram_storage: "No Telegram storage found: {{.Error}}"
|
||||||
|
info_fetching_files: "Fetching file list..."
|
||||||
|
error_list_files_failed: "Failed to list files: {{.Error}}"
|
||||||
|
error_invalid_regex: "Invalid regular expression: {{.Error}}"
|
||||||
|
error_no_files_to_import: "No files to import in directory"
|
||||||
|
error_invalid_chat_id: "Invalid Chat ID: {{.Error}}"
|
||||||
|
error_no_target_chat_id: "No target channel ID specified and Telegram storage has no default chat_id configured"
|
||||||
|
error_add_task_failed: "Failed to add task: {{.Error}}"
|
||||||
|
info_task_added: "Added {{.Count}} files to import queue\nTotal size: {{.SizeMB}} MB\nTask ID: {{.TaskID}}"
|
||||||
|
cancel:
|
||||||
|
usage: "Usage: /cancel <task_id>"
|
||||||
|
error_cancel_failed: "Failed to cancel task: {{.Error}}"
|
||||||
|
info_cancel_requested: "Cancel requested for task: {{.TaskID}}"
|
||||||
|
info_cancelling_task: "Cancelling task..."
|
||||||
|
media_group:
|
||||||
|
info_saving_files: "Saving files..."
|
||||||
|
error_build_storage_select_keyboard_failed: "Failed to build storage selection keyboard: {{.Error}}"
|
||||||
|
info_group_found_files_select_storage: "Total {{.Count}} files, please select storage"
|
||||||
|
storage:
|
||||||
|
info_filename_prefix: "Filename: "
|
||||||
|
info_prompt_select_storage: "\nPlease select storage"
|
||||||
|
progress:
|
||||||
|
batch_start_prefix: "Starting batch download task\nTotal size: "
|
||||||
|
batch_processing_prefix: "Processing batch download task\nTotal size: "
|
||||||
|
downloading_prefix: "Downloading\nTotal size: "
|
||||||
|
processing_list_prefix: "\nProcessing:\n"
|
||||||
|
processing_none: " - None"
|
||||||
|
avg_speed_prefix: "\nAverage speed: "
|
||||||
|
current_progress_prefix: "\nCurrent progress: "
|
||||||
|
task_canceled: "Task canceled"
|
||||||
|
task_canceled_with_id: "Processing canceled: {{.TaskID}}"
|
||||||
|
task_failed_with_error: "Processing failed: {{.Error}}"
|
||||||
|
batch_done_prefix: "Completed\nFile count: "
|
||||||
|
direct_done_prefix: "Completed, file count: "
|
||||||
|
parsed_start_prefix: "Starting download from {{.Site}}\nTotal size: "
|
||||||
|
parsed_done_prefix: "Completed, resource count: "
|
||||||
|
telegraph_start_prefix: "Starting Telegraph download\nImage count: "
|
||||||
|
telegraph_progress_prefix: "Downloading\nCurrent progress: "
|
||||||
|
telegraph_done_prefix: "Completed\nImage count: "
|
||||||
|
file_start_prefix: "Starting download\nFilename: "
|
||||||
|
file_processing_prefix: "Processing download task\nFilename: "
|
||||||
|
download_failed_prefix: "Download failed\nFilename: "
|
||||||
|
download_done_prefix: "Download completed\nFilename: "
|
||||||
|
file_size_prefix: "\nFile size: "
|
||||||
|
save_path_prefix: "\nSave path: "
|
||||||
|
total_size_prefix: "\nTotal size: "
|
||||||
|
direct_start: "Starting download, total size: {{.SizeMB}} MB ({{.Count}} files)"
|
||||||
|
file_name_prefix: "Filename: "
|
||||||
|
error_prefix: "\nError: "
|
||||||
|
aria2_start: "Waiting for Aria2 to complete download (GID: {{.GID}})..."
|
||||||
|
aria2_downloading: "Aria2 downloading (GID: {{.GID}})\n"
|
||||||
|
aria2_done: "Aria2 download completed and transferred (GID: {{.GID}})\n"
|
||||||
|
ytdlp_start: "Starting yt-dlp download ({{.Count}} links)..."
|
||||||
|
ytdlp_downloading: "yt-dlp downloading ({{.Count}} links)\n"
|
||||||
|
ytdlp_done: "yt-dlp download completed and transferred ({{.Count}} files)\n"
|
||||||
|
downloaded_prefix: "\nDownloaded: "
|
||||||
|
current_speed_prefix: "\nCurrent speed: "
|
||||||
|
import_start_prefix: "Importing: "
|
||||||
|
import_progress_prefix: "Import progress: "
|
||||||
|
import_uploaded_prefix: "\nUploaded: "
|
||||||
|
import_speed_prefix: "\nSpeed: "
|
||||||
|
import_remaining_time_prefix: "\nRemaining time: "
|
||||||
|
import_processing_prefix: "\nProcessing:\n"
|
||||||
|
import_processing_more: "...and {{.Count}} more files\n"
|
||||||
|
import_failed_prefix: "Import failed\n"
|
||||||
|
import_success_prefix: "Import completed\n"
|
||||||
|
import_total_files_prefix: "\nTotal files: "
|
||||||
|
import_total_size_prefix: "\nTotal size: "
|
||||||
|
import_elapsed_time_prefix: "\nElapsed time: "
|
||||||
|
import_avg_speed_prefix: "\nAverage speed: "
|
||||||
|
import_failed_files_prefix: "\nFailed files: "
|
||||||
|
syncpeers:
|
||||||
|
start: "Starting to sync peers..."
|
||||||
|
done: "Peer sync completed, total {{.Count}} chats synced"
|
||||||
|
failed: "Peer sync failed: {{.Error}}"
|
||||||
|
aria2:
|
||||||
|
error_aria2_not_enabled: "Aria2 feature is not enabled in the configuration"
|
||||||
|
error_aria2_client_init_failed: "Aria2 client initialization failed: {{.Error}}"
|
||||||
|
info_adding_aria2_download: "Adding Aria2 download task..."
|
||||||
|
error_adding_aria2_download: "Failed to add Aria2 download task: {{.Error}}"
|
||||||
|
info_aria2_download_added: "Aria2 download task added, GID: {{.GID}}"
|
||||||
|
info_select_storage: "Please select storage, the task will be added to Aria2 download queue after selection"
|
||||||
392
common/i18n/locale/zh-Hans.yaml
Normal file
392
common/i18n/locale/zh-Hans.yaml
Normal file
@@ -0,0 +1,392 @@
|
|||||||
|
lifetime:
|
||||||
|
initing: 正在启动
|
||||||
|
initfailed: 初始化失败
|
||||||
|
exiting: 正在退出
|
||||||
|
user_login_failed: "用户登录失败: {{.Error}}"
|
||||||
|
cleaning_cache: "正在清理缓存 {{.Path}}"
|
||||||
|
bye: 已退出
|
||||||
|
config:
|
||||||
|
err:
|
||||||
|
invalid_cache_dir: "无效的缓存目录: {{.Path}},请检查配置文件"
|
||||||
|
duplicate_storage_name: "存储名称 '{{.Name}}' 重复,请检查配置文件"
|
||||||
|
err:
|
||||||
|
get_workdir_failed: "获取工作目录失败: {{.Error}}"
|
||||||
|
get_cache_abs_path_failed: "获取缓存绝对路径失败: {{.Error}}"
|
||||||
|
clean_cache_failed: "清理缓存失败: {{.Error}}"
|
||||||
|
parser:
|
||||||
|
plugin:
|
||||||
|
load_failed: 加载解析器插件失败
|
||||||
|
loaded_dir: 解析器插件已加载
|
||||||
|
bot:
|
||||||
|
msg:
|
||||||
|
help_text_fmt: |
|
||||||
|
Save Any Bot - 转存你的 Telegram 文件
|
||||||
|
版本: %s , 提交: %s
|
||||||
|
|
||||||
|
命令:
|
||||||
|
/start - 开始使用
|
||||||
|
/help - 显示帮助
|
||||||
|
/silent - 开关静默模式
|
||||||
|
/storage - 设置默认存储位置
|
||||||
|
/save [自定义文件名] - 保存文件
|
||||||
|
/dl <链接1> <链接2> ... - 下载给定链接的文件
|
||||||
|
/import <存储名> <目录路径> [频道ID] [过滤器] - 从存储端导入文件到 Telegram
|
||||||
|
/dir - 管理存储目录
|
||||||
|
/rule - 管理规则
|
||||||
|
/config - 修改配置
|
||||||
|
/fnametmpl - 设置文件自定义命名模板
|
||||||
|
/parser - 管理解析器插件
|
||||||
|
/task - 管理任务队列
|
||||||
|
/watch - 监听聊天并自动保存 (UserBot)
|
||||||
|
/unwatch - 取消监听聊天 (UserBot)
|
||||||
|
/lswatch - 列出正在监听的聊天 (UserBot)
|
||||||
|
/syncpeers - 同步对话列表 (UserBot)
|
||||||
|
/update - 检查更新并升级
|
||||||
|
|
||||||
|
使用帮助: https://sabot.unv.app/usage
|
||||||
|
cmd:
|
||||||
|
start: "开始使用"
|
||||||
|
silent: "切换静默模式"
|
||||||
|
storage: "设置默认存储端"
|
||||||
|
dir: "管理存储文件夹"
|
||||||
|
rule: "管理自动存储规则"
|
||||||
|
save: "保存文件"
|
||||||
|
dl: "下载给定链接的文件"
|
||||||
|
aria2dl: "使用 Aria2 下载给定链接的文件"
|
||||||
|
ytdlp: "使用 yt-dlp 下载视频/音频"
|
||||||
|
import: "从存储端导入文件到 Telegram"
|
||||||
|
task: "管理任务队列"
|
||||||
|
cancel: "取消任务"
|
||||||
|
watch: "监听聊天(UserBot)"
|
||||||
|
unwatch: "取消监听聊天(UserBot)"
|
||||||
|
lswatch: "列出监听的聊天(UserBot)"
|
||||||
|
syncpeers: "同步对话列表(UserBot)"
|
||||||
|
config: "修改配置"
|
||||||
|
fnametmpl: "设置文件命名模板"
|
||||||
|
help: "显示帮助"
|
||||||
|
parser: "管理解析器"
|
||||||
|
update: "检查更新"
|
||||||
|
save_help_text: |
|
||||||
|
使用方法:
|
||||||
|
|
||||||
|
1. 使用该命令回复要保存的文件, 可选文件名参数.
|
||||||
|
示例:
|
||||||
|
/save custom_file_name.mp4
|
||||||
|
|
||||||
|
2. 设置默认存储后, 发送 /save <频道ID/用户名> <消息ID范围> 来批量保存文件. 遵从存储规则, 若未匹配到任何规则则使用默认存储.
|
||||||
|
示例:
|
||||||
|
/save @acherkrau 114-514
|
||||||
|
watch_help_text: |
|
||||||
|
使用 /watch 命令监听一个聊天的消息, 并自动保存到默认存储中, 遵从存储规则.
|
||||||
|
|
||||||
|
命令语法:
|
||||||
|
/watch <chat_id> [filter]
|
||||||
|
|
||||||
|
参数:
|
||||||
|
- <chat_id>: 聊天的 ID 或用户名
|
||||||
|
- [filter]: 可选, 格式为 过滤器类型:表达式 , 所有支持类型的过滤器请查看文档
|
||||||
|
|
||||||
|
命令示例:
|
||||||
|
/watch -1002229835658 msgre:.*plana.*
|
||||||
|
|
||||||
|
这将监听 ID 为 -1002229835658 的聊天, 并转存所有包含 "plana" 的媒体消息
|
||||||
|
common:
|
||||||
|
cancel_button_text: "取消任务"
|
||||||
|
error_invalid_regex: "无效的正则表达式: {{.Error}}"
|
||||||
|
error_invalid_msg_id_range: "无效的消息ID范围: {{.Error}}"
|
||||||
|
error_invalid_id_or_username: "无效的ID或用户名: {{.Error}}"
|
||||||
|
error_get_messages_failed: "获取消息失败: {{.Error}}"
|
||||||
|
info_fetching_messages: "正在获取消息..."
|
||||||
|
error_no_messages_in_range: "没有找到指定范围内的消息"
|
||||||
|
error_no_savable_messages_in_range: "没有找到指定范围内的可保存消息"
|
||||||
|
error_build_storage_select_message_failed: "构建存储选择消息失败: {{.Error}}"
|
||||||
|
error_build_storage_select_keyboard_failed: "构建存储选择键盘失败: {{.Error}}"
|
||||||
|
info_found_files_select_storage: "找到 {{.Count}} 个文件, 请选择存储位置"
|
||||||
|
error_get_user_failed: "获取用户失败"
|
||||||
|
error_get_user_with_err_failed: "获取用户失败: {{.Error}}"
|
||||||
|
error_default_storage_not_set: "请先设置默认存储, 使用 /storage 命令"
|
||||||
|
error_no_available_storage: "无可用的存储"
|
||||||
|
error_get_storage_failed: "获取存储失败: {{.Error}}"
|
||||||
|
prompt_select_default_storage: "请选择要设为默认的存储位置"
|
||||||
|
error_data_expired: "数据已过期或无效"
|
||||||
|
error_task_add_failed: "任务添加失败: {{.Error}}"
|
||||||
|
info_task_added: "任务已添加"
|
||||||
|
info_batch_tasks_added: "已添加批量任务, 共 {{.Count}} 个文件"
|
||||||
|
error_task_create_failed: "任务创建失败: {{.Error}}"
|
||||||
|
error_get_dir_failed: "获取目录失败: {{.Error}}"
|
||||||
|
prompt_select_dir: "请选择要存储到的目录"
|
||||||
|
prompt_select_default_dir: "请选择要保存到的默认文件夹"
|
||||||
|
info_default_storage_set: "已将默认存储位置设为: {{.Name}}"
|
||||||
|
info_default_storage_with_dir_set: "已将默认存储位置设为: {{.Name}}:/{{.Dir}}"
|
||||||
|
error_get_user_info_failed: "获取用户信息失败: {{.Error}}"
|
||||||
|
error_update_user_info_failed: "更新用户信息失败: {{.Error}}"
|
||||||
|
info_silent_mode_on: "已开启静默模式"
|
||||||
|
info_silent_mode_off: "已关闭静默模式"
|
||||||
|
error_get_file_failed: "获取文件失败: {{.Error}}"
|
||||||
|
info_fetching_file_info: "正在获取文件信息..."
|
||||||
|
error_no_savable_files_found: "没有找到可保存的文件"
|
||||||
|
error_parse_telegraph_path_failed: "解析 telegraph 路径失败: {{.Error}}"
|
||||||
|
info_fetching_telegraph_page: "正在获取 telegraph 页面..."
|
||||||
|
error_get_telegraph_page_failed: "获取 telegraph 页面失败: {{.Error}}"
|
||||||
|
error_no_images_in_telegraph_page: "在 telegraph 页面中未找到图片"
|
||||||
|
error_build_dir_select_keyboard_failed: "构建目录选择键盘失败: {{.Error}}"
|
||||||
|
error_no_permission: |
|
||||||
|
您不在白名单中, 无法使用此 Bot.
|
||||||
|
您可以部署自己的实例: https://github.com/krau/SaveAny-Bot
|
||||||
|
save:
|
||||||
|
error_invalid_id_or_username: "无效的ID或用户名: {{.Error}}"
|
||||||
|
watch:
|
||||||
|
error_filter_format_invalid: "过滤器格式错误, 请使用 <过滤器类型>:<表达式>"
|
||||||
|
error_filter_type_unsupported: "不支持的过滤器类型, 请参阅文档"
|
||||||
|
error_watch_chat_failed: "监听聊天失败: {{.Error}}"
|
||||||
|
info_watch_chat_started: "已开始监听聊天: {{.Chat}}"
|
||||||
|
info_already_watching_chat: "已经在监听此聊天"
|
||||||
|
info_watch_list_empty: "当前没有监听任何聊天"
|
||||||
|
info_watch_list_header: "当前监听的聊天:\n"
|
||||||
|
info_watch_list_filter_prefix: " (过滤器: "
|
||||||
|
error_unwatch_no_chat_provided: "请提供要取消监听的聊天ID或用户名"
|
||||||
|
error_unwatch_chat_failed: "取消监听聊天失败: {{.Error}}"
|
||||||
|
info_watch_chat_stopped: "已取消监听聊天: {{.Chat}}"
|
||||||
|
tasks:
|
||||||
|
usage_cancel: "用法: /tasks cancel <task_id>"
|
||||||
|
usage: "用法: /tasks [running|queued|cancel <task_id>]"
|
||||||
|
cancel_failed: "取消任务失败: {{.Error}}"
|
||||||
|
cancel_requested_prefix: "已请求取消任务: "
|
||||||
|
running_empty: "当前没有正在运行的任务"
|
||||||
|
running_title: "当前正在运行的任务:"
|
||||||
|
total_prefix: "总数: {{.Count}}\n"
|
||||||
|
field_id: "ID: "
|
||||||
|
field_title: "名称: "
|
||||||
|
field_created: "创建时间: "
|
||||||
|
field_status: "状态: "
|
||||||
|
status_running: "运行中"
|
||||||
|
status_queued: "排队中"
|
||||||
|
status_cancel_requested: "已请求取消"
|
||||||
|
queued_empty: "当前没有排队中的任务"
|
||||||
|
queued_title: "当前排队中的任务:"
|
||||||
|
truncated_note: "...\n只显示前 10 个任务, 共 {{.Count}} 个任务"
|
||||||
|
info_added_to_queue_full: "已添加到任务队列\n文件名: {{.Filename}}\n当前排队任务数: {{.QueueLength}}"
|
||||||
|
info_added_to_queue_prefix: "已添加到任务队列\n"
|
||||||
|
info_filename_prefix: "文件名: "
|
||||||
|
info_queue_length_prefix: "\n当前排队任务数: "
|
||||||
|
rule:
|
||||||
|
error_get_user_rules_failed: "获取用户规则失败"
|
||||||
|
error_update_user_failed: "更新用户失败"
|
||||||
|
info_rule_mode_enabled: "已启用规则模式"
|
||||||
|
info_rule_mode_disabled: "已禁用规则模式"
|
||||||
|
error_invalid_rule_type: "无效的规则类型: {{.Type}}\n可用: {{.Available}}"
|
||||||
|
error_create_rule_failed: "创建规则失败"
|
||||||
|
info_create_rule_success: "创建规则成功"
|
||||||
|
prompt_provide_rule_id: "请提供规则ID"
|
||||||
|
error_invalid_rule_id: "无效的规则ID"
|
||||||
|
error_delete_rule_failed: "删除规则失败"
|
||||||
|
info_delete_rule_success: "删除规则成功"
|
||||||
|
help_usage: "使用方法: /rule <操作> <参数...>"
|
||||||
|
help_current_mode_enabled: "\n当前已启用规则模式"
|
||||||
|
help_current_mode_disabled: "\n当前已禁用规则模式"
|
||||||
|
help_available_ops: "\n\n可用操作:\n"
|
||||||
|
help_switch_suffix: " - 开关规则模式\n"
|
||||||
|
help_add_suffix: " <类型> <数据> <存储名> <路径> - 添加规则\n"
|
||||||
|
help_del_suffix: " <规则ID> - 删除规则\n"
|
||||||
|
help_existing_rules_prefix: "\n当前已添加的规则:\n"
|
||||||
|
dir:
|
||||||
|
error_get_user_dirs_failed: "获取用户文件夹失败"
|
||||||
|
error_get_user_failed: "获取用户失败"
|
||||||
|
error_create_dir_failed: "创建文件夹失败"
|
||||||
|
info_create_dir_success: "文件夹添加成功"
|
||||||
|
error_invalid_dir_id: "文件夹ID无效"
|
||||||
|
error_delete_dir_failed: "删除文件夹失败"
|
||||||
|
info_delete_dir_success: "文件夹删除成功"
|
||||||
|
error_unknown_operation: "未知操作"
|
||||||
|
help_usage: "使用方法: /dir <操作> <参数...>"
|
||||||
|
help_available_ops: "\n\n可用操作:\n"
|
||||||
|
help_add_suffix: " <存储名> <路径> - 添加路径\n"
|
||||||
|
help_del_suffix: " <路径ID> - 删除路径\n"
|
||||||
|
help_add_example_prefix: "\n添加路径示例:\n"
|
||||||
|
help_add_example_cmd: "/dir add local1 path/to/dir"
|
||||||
|
help_del_example_prefix: "\n\n删除路径示例:\n"
|
||||||
|
help_del_example_cmd: "/dir del 3"
|
||||||
|
help_existing_dirs_prefix: "\n\n当前已添加的路径:\n"
|
||||||
|
button_default: "默认"
|
||||||
|
parser:
|
||||||
|
help_text: |
|
||||||
|
用法:
|
||||||
|
|
||||||
|
/parser install <回复一个文件> - 安装解析器
|
||||||
|
plugin_not_enabled: "解析器插件功能未启用"
|
||||||
|
prompt_reply_with_parser_file: "请回复一个包含解析器文件的消息"
|
||||||
|
error_no_valid_file_in_reply: "回复的消息不包含有效的文件"
|
||||||
|
error_wrong_file_type: "错误的文件类型"
|
||||||
|
error_file_too_large: "文件过大"
|
||||||
|
error_get_filename_failed: "无法获取文件名"
|
||||||
|
error_only_js_supported: "仅支持 .js 文件作为解析器"
|
||||||
|
error_download_file_failed: "文件下载失败: {{.Error}}"
|
||||||
|
error_install_plugin_failed: "插件安装失败: {{.Error}}"
|
||||||
|
info_install_plugin_success: "插件安装成功: {{.Name}}"
|
||||||
|
parse:
|
||||||
|
info_parsing: "正在解析..."
|
||||||
|
error_parse_text_failed: "Failed to parse text: {{.Error}}"
|
||||||
|
error_build_storage_select_keyboard_failed: "Failed to build storage selection keyboard: {{.Error}}"
|
||||||
|
error_build_parsed_text_entity_failed: "Failed to build parsed text entity: {{.Error}}"
|
||||||
|
info_link_prefix: "\n链接: "
|
||||||
|
info_author_prefix: "\n作者: "
|
||||||
|
info_description_prefix: "\n描述: "
|
||||||
|
info_file_count_prefix: "\n文件数量: "
|
||||||
|
info_total_size_prefix: "\n预计总大小: "
|
||||||
|
info_prompt_select_storage: "\n请选择存储位置"
|
||||||
|
telegraph:
|
||||||
|
error_build_storage_select_keyboard_failed: "构建存储选择键盘失败: {{.Error}}"
|
||||||
|
info_title_prefix: "标题: "
|
||||||
|
info_pic_count_prefix: "\n图片数量: "
|
||||||
|
info_prompt_select_storage: "\n请选择存储位置"
|
||||||
|
update:
|
||||||
|
error_version_var_invalid: "当前处于开发版本或版本信息注入失败: {{.Error}}"
|
||||||
|
error_check_latest_failed: "检测最新版本失败: {{.Error}}"
|
||||||
|
error_no_release_found: "没有找到版本信息"
|
||||||
|
info_major_upgrade_required: "检测到大版本更新: {{.Current}} -> {{.Latest}} , 请前往 GitHub 手动下载最新版本并查看迁移指南"
|
||||||
|
info_already_latest: "当前已经是最新版本: {{.Version}}"
|
||||||
|
info_new_version_in_docker: |-
|
||||||
|
发现新版本: {{.Latest}}
|
||||||
|
当前版本: {{.Current}}
|
||||||
|
发布时间: {{.PublishedAt}}
|
||||||
|
由于您正在使用 Docker 部署, 请自行在部署平台上执行更新命令
|
||||||
|
info_new_version_prompt_upgrade: |-
|
||||||
|
发现新版本: {{.Latest}}
|
||||||
|
当前版本: {{.Current}}
|
||||||
|
|
||||||
|
文件大小: {{.SizeMB}} MB
|
||||||
|
下载链接: {{.URL}}
|
||||||
|
发布时间: {{.PublishedAt}}
|
||||||
|
|
||||||
|
升级将重启 Bot , 是否升级?
|
||||||
|
info_upgrading_with_version: "正在升级中, 当前版本: {{.Current}}"
|
||||||
|
error_upgrade_failed: "升级失败: {{.Error}}"
|
||||||
|
info_upgrade_success: "已升级至版本 {{.Version}}\n若 Bot 未自动重启请手动启动"
|
||||||
|
button_upgrade: "升级"
|
||||||
|
config:
|
||||||
|
prompt_select_option: "请选择要配置的选项"
|
||||||
|
button_filename_strategy: "文件名策略"
|
||||||
|
error_invalid_callback_data: "无效的回调数据"
|
||||||
|
error_invalid_template: "无效的模板, 请检查语法\n{{.Error}}"
|
||||||
|
info_filename_strategy_set: "已将文件名策略设置为: {{.Strategy}}"
|
||||||
|
prompt_select_filename_strategy: "请选择文件名策略, 当前策略: {{.Strategy}}"
|
||||||
|
fnametmpl_help: |-
|
||||||
|
使用该命令设置文件名模板, 示例:
|
||||||
|
/fnametmpl 图片_{{"{{.msgid}}"}}_{{"{{.msgdate}}"}}.jpg
|
||||||
|
|
||||||
|
可用变量:
|
||||||
|
- {{"{{.msgid}}"}}: 消息ID
|
||||||
|
- {{"{{.msgtags}}"}}: 消息中的标签, 将以下划线分隔输出
|
||||||
|
- {{"{{.msggen}}"}}: 根据消息生成的文件名
|
||||||
|
- {{"{{.msgdate}}"}}: 消息日期, 格式 YYYY-MM-DD_HH-MM-SS
|
||||||
|
- {{"{{.msgraw}}"}}: 消息的原始文本内容 (不经任何处理)
|
||||||
|
- {{"{{.origname}}"}}: 媒体的原始文件名 (如果有)
|
||||||
|
- {{"{{.chatid}}"}}: 消息的聊天ID
|
||||||
|
|
||||||
|
模板仅在文件名策略设置为 '自定义模板' 时生效,
|
||||||
|
且模板解析错误时会回退到默认文件名
|
||||||
|
info_template_updated: "已更新文件名模板"
|
||||||
|
info_current_template_prefix: "当前模板: {{.Template}}"
|
||||||
|
dl:
|
||||||
|
usage: "用法: /dl <链接1> <链接2> ..."
|
||||||
|
error_no_valid_links: "没有有效的链接可供下载"
|
||||||
|
info_files_select_storage: "共 {{.Count}} 个文件, 请选择存储位置"
|
||||||
|
ytdlp:
|
||||||
|
usage: "用法: /ytdlp [选项] <URL1> [URL2] ...\n示例:\n /ytdlp https://example.com/video\n /ytdlp --format best https://example.com/video\n /ytdlp --extract-audio --audio-format mp3 https://example.com/video"
|
||||||
|
error_no_valid_urls: "没有有效的 URL"
|
||||||
|
info_urls_select_storage: "共 {{.Count}} 个链接, 请选择存储位置"
|
||||||
|
info_downloading: "正在通过 yt-dlp 下载..."
|
||||||
|
error_download_failed: "yt-dlp 下载失败: {{.Error}}"
|
||||||
|
import:
|
||||||
|
usage: |
|
||||||
|
用法: /import <storage_name> <dir_path> [target_chat_id] [filter]
|
||||||
|
示例:
|
||||||
|
/import 本机1 /downloads
|
||||||
|
/import MyAlist /media/photos -1001234567890
|
||||||
|
/import MyLocal /backup ".*\.mp4$"
|
||||||
|
error_storage_not_found: "存储端 '{{.StorageName}}' 不存在或您无权访问: {{.Error}}"
|
||||||
|
error_storage_not_listable: "存储端 '{{.StorageName}}' 不支持列举文件功能"
|
||||||
|
error_storage_not_readable: "存储端 '{{.StorageName}}' 不支持读取文件功能"
|
||||||
|
error_no_telegram_storage: "未找到可用的 Telegram 存储: {{.Error}}"
|
||||||
|
info_fetching_files: "正在获取文件列表..."
|
||||||
|
error_list_files_failed: "获取文件列表失败: {{.Error}}"
|
||||||
|
error_invalid_regex: "正则表达式无效: {{.Error}}"
|
||||||
|
error_no_files_to_import: "目录中没有可导入的文件"
|
||||||
|
error_invalid_chat_id: "无效的 Chat ID: {{.Error}}"
|
||||||
|
error_no_target_chat_id: "未指定目标频道 ID,且 Telegram 存储未配置默认 chat_id"
|
||||||
|
error_add_task_failed: "添加任务失败: {{.Error}}"
|
||||||
|
info_task_added: "已添加 {{.Count}} 个文件到导入队列\n总大小: {{.SizeMB}} MB\n任务 ID: {{.TaskID}}"
|
||||||
|
start_stats: "总文件数: {{.Count}}\n总大小: {{.SizeMB}} MB"
|
||||||
|
cancel:
|
||||||
|
usage: "用法: /cancel <task_id>"
|
||||||
|
error_cancel_failed: "取消任务失败: {{.Error}}"
|
||||||
|
info_cancel_requested: "已请求取消任务: {{.TaskID}}"
|
||||||
|
info_cancelling_task: "正在取消任务..."
|
||||||
|
media_group:
|
||||||
|
info_saving_files: "正在保存文件..."
|
||||||
|
error_build_storage_select_keyboard_failed: "构建存储选择键盘失败: {{.Error}}"
|
||||||
|
info_group_found_files_select_storage: "共 {{.Count}} 个文件, 请选择存储位置"
|
||||||
|
storage:
|
||||||
|
info_filename_prefix: "文件名: "
|
||||||
|
info_prompt_select_storage: "\n请选择存储位置"
|
||||||
|
progress:
|
||||||
|
batch_start_prefix: "开始执行批量下载任务\n总大小: "
|
||||||
|
batch_processing_prefix: "正在处理批量下载任务\n总大小: "
|
||||||
|
downloading_prefix: "正在下载\n总大小: "
|
||||||
|
processing_list_prefix: "\n正在处理:\n"
|
||||||
|
processing_none: " - 无"
|
||||||
|
avg_speed_prefix: "\n平均速度: "
|
||||||
|
current_progress_prefix: "\n当前进度: "
|
||||||
|
task_canceled: "任务已取消"
|
||||||
|
task_canceled_with_id: "处理已取消: {{.TaskID}}"
|
||||||
|
task_failed_with_error: "处理失败: {{.Error}}"
|
||||||
|
batch_done_prefix: "处理完成\n文件数: "
|
||||||
|
direct_done_prefix: "处理完成, 文件数量: "
|
||||||
|
parsed_start_prefix: "开始下载 {{.Site}} 的资源\n总大小: "
|
||||||
|
parsed_done_prefix: "处理完成, 资源数量: "
|
||||||
|
telegraph_start_prefix: "开始下载Telegraph\n图片数量: "
|
||||||
|
telegraph_progress_prefix: "正在下载\n当前进度: "
|
||||||
|
telegraph_done_prefix: "处理完成\n图片数量: "
|
||||||
|
file_start_prefix: "开始下载\n文件名: "
|
||||||
|
file_processing_prefix: "正在处理下载任务\n文件名: "
|
||||||
|
download_failed_prefix: "下载失败\n文件名: "
|
||||||
|
download_done_prefix: "下载完成\n文件名: "
|
||||||
|
file_size_prefix: "\n文件大小: "
|
||||||
|
save_path_prefix: "\n保存路径: "
|
||||||
|
total_size_prefix: "\n总大小: "
|
||||||
|
direct_start: "开始下载, 总大小: {{.SizeMB}} MB ({{.Count}} 个文件)"
|
||||||
|
file_name_prefix: "文件名: "
|
||||||
|
error_prefix: "\n错误: "
|
||||||
|
aria2_start: "等待 Aria2 下载完成 (GID: {{.GID}})..."
|
||||||
|
aria2_downloading: "Aria2 正在下载 (GID: {{.GID}})\n"
|
||||||
|
aria2_done: "Aria2 下载完成并已转存 (GID: {{.GID}})\n"
|
||||||
|
ytdlp_start: "开始使用 yt-dlp 下载 ({{.Count}} 个链接)..."
|
||||||
|
ytdlp_downloading: "yt-dlp 正在下载 ({{.Count}} 个链接)\n"
|
||||||
|
ytdlp_done: "yt-dlp 下载完成并已转存 ({{.Count}} 个文件)\n"
|
||||||
|
downloaded_prefix: "\n已下载: "
|
||||||
|
current_speed_prefix: "\n当前速度: "
|
||||||
|
import_start_prefix: "正在导入: "
|
||||||
|
import_progress_prefix: "导入进度: "
|
||||||
|
import_uploaded_prefix: "\n已上传: "
|
||||||
|
import_speed_prefix: "\n速度: "
|
||||||
|
import_remaining_time_prefix: "\n剩余时间: "
|
||||||
|
import_processing_prefix: "\n正在处理:\n"
|
||||||
|
import_processing_more: "...和其他 {{.Count}} 个文件\n"
|
||||||
|
import_failed_prefix: "导入失败\n"
|
||||||
|
import_success_prefix: "导入完成\n"
|
||||||
|
import_total_files_prefix: "\n总文件数: "
|
||||||
|
import_total_size_prefix: "\n总大小: "
|
||||||
|
import_elapsed_time_prefix: "\n耗时: "
|
||||||
|
import_avg_speed_prefix: "\n平均速度: "
|
||||||
|
import_failed_files_prefix: "\n失败文件数: "
|
||||||
|
syncpeers:
|
||||||
|
start: "正在同步对话列表..."
|
||||||
|
success: "对话列表同步完成, 共同步 {{.Count}} 个对话"
|
||||||
|
failed: "对话列表同步失败: {{.Error}}"
|
||||||
|
aria2:
|
||||||
|
error_aria2_not_enabled: "Aria2 功能未启用, 请在配置文件中启用"
|
||||||
|
error_aria2_client_init_failed: "Aria2 客户端初始化失败: {{.Error}}"
|
||||||
|
info_adding_aria2_download: "正在添加 Aria2 下载任务..."
|
||||||
|
error_adding_aria2_download: "添加 Aria2 下载任务失败: {{.Error}}"
|
||||||
|
info_aria2_download_added: "Aria2 下载任务已添加, GID: {{.GID}}"
|
||||||
|
info_select_storage: "请选择存储位置, 选择后将添加到 Aria2 下载队列"
|
||||||
57
common/os.go
57
common/os.go
@@ -1,57 +0,0 @@
|
|||||||
package common
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/krau/SaveAny-Bot/logger"
|
|
||||||
)
|
|
||||||
|
|
||||||
// 创建文件, 自动创建目录
|
|
||||||
func MkFile(path string, data []byte) error {
|
|
||||||
err := os.MkdirAll(filepath.Dir(path), os.ModePerm)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return os.WriteFile(path, data, os.ModePerm)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 删除文件, 并清理空目录. 如果文件不存在则返回 nil
|
|
||||||
func PurgeFile(path string) error {
|
|
||||||
if err := os.Remove(path); err != nil {
|
|
||||||
if !errors.Is(err, os.ErrNotExist) {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return RemoveEmptyDirectories(filepath.Dir(path))
|
|
||||||
}
|
|
||||||
|
|
||||||
func RmFileAfter(path string, td time.Duration) {
|
|
||||||
_, err := os.Stat(path)
|
|
||||||
if err != nil {
|
|
||||||
logger.L.Errorf("Failed to create timer for %s: %s", path, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
logger.L.Debugf("Remove file after %s: %s", td, path)
|
|
||||||
time.AfterFunc(td, func() {
|
|
||||||
PurgeFile(path)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// 递归删除空目录
|
|
||||||
func RemoveEmptyDirectories(dirPath string) error {
|
|
||||||
entries, err := os.ReadDir(dirPath)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if len(entries) == 0 {
|
|
||||||
err := os.Remove(dirPath)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return RemoveEmptyDirectories(filepath.Dir(dirPath))
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
14
common/tdler/dler.go
Normal file
14
common/tdler/dler.go
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
package tdler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/gotd/td/telegram/downloader"
|
||||||
|
"github.com/krau/SaveAny-Bot/common/utils/dlutil"
|
||||||
|
"github.com/krau/SaveAny-Bot/config"
|
||||||
|
"github.com/krau/SaveAny-Bot/pkg/consts/tglimit"
|
||||||
|
"github.com/krau/SaveAny-Bot/pkg/tfile"
|
||||||
|
)
|
||||||
|
|
||||||
|
func NewDownloader(file tfile.TGFile) *downloader.Builder {
|
||||||
|
return downloader.NewDownloader().WithPartSize(tglimit.MaxPartSize).
|
||||||
|
Download(file.Dler(), file.Location()).WithThreads(dlutil.BestThreads(file.Size(), config.C().Threads))
|
||||||
|
}
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
package common
|
|
||||||
|
|
||||||
import (
|
|
||||||
"crypto/md5"
|
|
||||||
"encoding/hex"
|
|
||||||
)
|
|
||||||
|
|
||||||
func HashString(s string) string {
|
|
||||||
hash := md5.New()
|
|
||||||
hash.Write([]byte(s))
|
|
||||||
return hex.EncodeToString(hash.Sum(nil))
|
|
||||||
}
|
|
||||||
56
common/utils/dlutil/dl.go
Normal file
56
common/utils/dlutil/dl.go
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
package dlutil
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormatSize formats a byte size as a human-readable string
|
||||||
|
func FormatSize(bytes int64) string {
|
||||||
|
const (
|
||||||
|
KB = 1024
|
||||||
|
MB = KB * 1024
|
||||||
|
GB = MB * 1024
|
||||||
|
)
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case bytes >= GB:
|
||||||
|
return fmt.Sprintf("%.2f GB", float64(bytes)/float64(GB))
|
||||||
|
case bytes >= MB:
|
||||||
|
return fmt.Sprintf("%.2f MB", float64(bytes)/float64(MB))
|
||||||
|
case bytes >= KB:
|
||||||
|
return fmt.Sprintf("%.2f KB", float64(bytes)/float64(KB))
|
||||||
|
default:
|
||||||
|
return fmt.Sprintf("%d B", bytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
77
common/utils/fsutil/fs.go
Normal file
77
common/utils/fsutil/fs.go
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
package fsutil
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
|
||||||
|
"github.com/gabriel-vasile/mimetype"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 删除文件夹内的所有文件和子目录, 但不删除文件夹本身
|
||||||
|
func RemoveAllInDir(dirPath string) error {
|
||||||
|
entries, err := os.ReadDir(dirPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, entry := range entries {
|
||||||
|
entryPath := filepath.Join(dirPath, entry.Name())
|
||||||
|
if err := os.RemoveAll(entryPath); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func DetectFileExt(fp string) string {
|
||||||
|
mt, err := mimetype.DetectFile(fp)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return mt.Extension()
|
||||||
|
}
|
||||||
|
|
||||||
|
type File struct {
|
||||||
|
*os.File
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *File) Remove() error {
|
||||||
|
return os.Remove(f.Name())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *File) CloseAndRemove() error {
|
||||||
|
if err := f.Close(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return f.Remove()
|
||||||
|
}
|
||||||
|
|
||||||
|
func CreateFile(fp string) (*File, error) {
|
||||||
|
if err := os.MkdirAll(filepath.Dir(fp), os.ModePerm); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
file, err := os.Create(fp)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &File{File: file}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func NormalizePathname(s string) string {
|
||||||
|
specials := `\/:*?"<>|` + "\n\r\t"
|
||||||
|
var builder strings.Builder
|
||||||
|
for _, ch := range s {
|
||||||
|
if strings.ContainsRune(specials, ch) || unicode.IsControl(ch) {
|
||||||
|
builder.WriteRune('_')
|
||||||
|
} else {
|
||||||
|
builder.WriteRune(ch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result := strings.TrimRightFunc(builder.String(), func(r rune) bool {
|
||||||
|
return r == '.' || r == '_' || unicode.IsSpace(r)
|
||||||
|
})
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
46
common/utils/fsutil/normalize_pathname_test.go
Normal file
46
common/utils/fsutil/normalize_pathname_test.go
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
package fsutil_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/krau/SaveAny-Bot/common/utils/fsutil"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNormalizePathname(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
input string
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
input: "hello/world?.txt ",
|
||||||
|
expected: "hello_world_.txt",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: "bad|name:\nfile\r.",
|
||||||
|
expected: "bad_name__file",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: "normal.txt",
|
||||||
|
expected: "normal.txt",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: "test.... ",
|
||||||
|
expected: "test",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: "abc<>def",
|
||||||
|
expected: "abc__def",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: "with\tcontrol",
|
||||||
|
expected: "with_control",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
got := fsutil.NormalizePathname(tc.input)
|
||||||
|
if got != tc.expected {
|
||||||
|
t.Errorf("NormalizePathname(%q) = %q; want %q", tc.input, got, tc.expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
65
common/utils/ioutil/progress_reader.go
Normal file
65
common/utils/ioutil/progress_reader.go
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
package ioutil
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"sync/atomic"
|
||||||
|
)
|
||||||
|
|
||||||
|
var _ io.ReadSeeker = (*ProgressReadSeeker)(nil)
|
||||||
|
|
||||||
|
// ProgressReadSeeker wraps an io.ReadSeeker and tracks read progress
|
||||||
|
type ProgressReadSeeker struct {
|
||||||
|
reader io.ReadSeeker
|
||||||
|
total atomic.Int64
|
||||||
|
read atomic.Int64
|
||||||
|
onProgress func(read int64, total int64)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seek implements io.ReadSeeker.
|
||||||
|
func (pr *ProgressReadSeeker) Seek(offset int64, whence int) (int64, error) {
|
||||||
|
return pr.reader.Seek(offset, whence)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewProgressReader creates a new ProgressReader
|
||||||
|
func NewProgressReader(rs io.ReadSeeker, total int64, onProgress func(read int64, total int64)) *ProgressReadSeeker {
|
||||||
|
prs := &ProgressReadSeeker{
|
||||||
|
reader: rs,
|
||||||
|
total: atomic.Int64{},
|
||||||
|
read: atomic.Int64{},
|
||||||
|
onProgress: onProgress,
|
||||||
|
}
|
||||||
|
prs.total.Store(total)
|
||||||
|
return prs
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read implements io.Reader
|
||||||
|
func (pr *ProgressReadSeeker) Read(p []byte) (int, error) {
|
||||||
|
n, err := pr.reader.Read(p)
|
||||||
|
if n > 0 {
|
||||||
|
pr.read.Add(int64(n))
|
||||||
|
read := pr.read.Load()
|
||||||
|
|
||||||
|
if pr.onProgress != nil {
|
||||||
|
pr.onProgress(read, pr.total.Load())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Progress returns the current progress as a float64 between 0 and 1
|
||||||
|
func (pr *ProgressReadSeeker) Progress() float64 {
|
||||||
|
if pr.total.Load() <= 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return float64(pr.read.Load()) / float64(pr.total.Load())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read returns the number of bytes read so far
|
||||||
|
func (pr *ProgressReadSeeker) BytesRead() int64 {
|
||||||
|
return pr.read.Load()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Total returns the total number of bytes
|
||||||
|
func (pr *ProgressReadSeeker) Total() int64 {
|
||||||
|
return pr.total.Load()
|
||||||
|
}
|
||||||
51
common/utils/ioutil/writer.go
Normal file
51
common/utils/ioutil/writer.go
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
package ioutil
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ProgressWriterAt struct {
|
||||||
|
wrAt io.WriterAt
|
||||||
|
onWrite func(n int)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *ProgressWriterAt) WriteAt(buf []byte, off int64) (n int, err error) {
|
||||||
|
n, err = p.wrAt.WriteAt(buf, off)
|
||||||
|
if n > 0 {
|
||||||
|
p.onWrite(n)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewProgressWriterAt(
|
||||||
|
wrAt io.WriterAt,
|
||||||
|
onWrite func(n int),
|
||||||
|
) *ProgressWriterAt {
|
||||||
|
return &ProgressWriterAt{
|
||||||
|
wrAt: wrAt,
|
||||||
|
onWrite: onWrite,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProgressWriter struct {
|
||||||
|
wr io.Writer
|
||||||
|
onWrite func(n int)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *ProgressWriter) Write(buf []byte) (n int, err error) {
|
||||||
|
n, err = p.wr.Write(buf)
|
||||||
|
if n > 0 {
|
||||||
|
p.onWrite(n)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewProgressWriter(
|
||||||
|
wr io.Writer,
|
||||||
|
onWrite func(n int),
|
||||||
|
) *ProgressWriter {
|
||||||
|
return &ProgressWriter{
|
||||||
|
wr: wr,
|
||||||
|
onWrite: onWrite,
|
||||||
|
}
|
||||||
|
}
|
||||||
78
common/utils/netutil/proxy.go
Normal file
78
common/utils/netutil/proxy.go
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
package netutil
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/log"
|
||||||
|
"github.com/krau/SaveAny-Bot/config"
|
||||||
|
"golang.org/x/net/proxy"
|
||||||
|
)
|
||||||
|
|
||||||
|
func NewProxyHTTPClient(proxyUrl string) (*http.Client, error) {
|
||||||
|
if proxyUrl == "" {
|
||||||
|
return http.DefaultClient, nil
|
||||||
|
}
|
||||||
|
transport, err := NewProxyTransport(proxyUrl)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &http.Client{
|
||||||
|
Transport: transport,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
defaultProxyHttpClient *http.Client
|
||||||
|
onceLoadDefaultProxyHttpClient sync.Once
|
||||||
|
)
|
||||||
|
|
||||||
|
func DefaultParserHTTPClient() *http.Client {
|
||||||
|
onceLoadDefaultProxyHttpClient.Do(func() {
|
||||||
|
client, err := NewProxyHTTPClient(config.C().Parser.Proxy)
|
||||||
|
if err != nil {
|
||||||
|
log.Warn("Failed to create default proxy HTTP client, using http.DefaultClient", "error", err)
|
||||||
|
defaultProxyHttpClient = http.DefaultClient
|
||||||
|
} else {
|
||||||
|
defaultProxyHttpClient = client
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return defaultProxyHttpClient
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewProxyTransport(proxyStr string) (*http.Transport, error) {
|
||||||
|
proxyURL, err := url.Parse(proxyStr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
transport := &http.Transport{
|
||||||
|
ForceAttemptHTTP2: true,
|
||||||
|
MaxIdleConns: 100,
|
||||||
|
IdleConnTimeout: 90 * time.Second,
|
||||||
|
TLSHandshakeTimeout: 10 * time.Second,
|
||||||
|
ExpectContinueTimeout: 1 * time.Second,
|
||||||
|
}
|
||||||
|
switch proxyURL.Scheme {
|
||||||
|
case "http", "https":
|
||||||
|
transport.Proxy = http.ProxyURL(proxyURL)
|
||||||
|
|
||||||
|
case "socks5", "socks5h":
|
||||||
|
dialer, err := proxy.FromURL(proxyURL, proxy.Direct)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||||
|
return dialer.(proxy.ContextDialer).DialContext(ctx, network, addr)
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unsupported proxy type: %s", proxyURL.Scheme)
|
||||||
|
}
|
||||||
|
|
||||||
|
return transport, nil
|
||||||
|
}
|
||||||
93
common/utils/strutil/string.go
Normal file
93
common/utils/strutil/string.go
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
package strutil
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/md5"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/duke-git/lancet/v2/slice"
|
||||||
|
)
|
||||||
|
|
||||||
|
func HashString(s string) string {
|
||||||
|
hash := md5.New()
|
||||||
|
hash.Write([]byte(s))
|
||||||
|
return hex.EncodeToString(hash.Sum(nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
var TagRe = regexp.MustCompile(`(?:^|[\p{Zs}\s.,!?(){}[\]<>\"\',。!?():;、])#([\p{L}\d_]+)`)
|
||||||
|
|
||||||
|
func ExtractTagsFromText(text string) []string {
|
||||||
|
matches := TagRe.FindAllStringSubmatch(text, -1)
|
||||||
|
tags := make([]string, 0)
|
||||||
|
for _, match := range matches {
|
||||||
|
if len(match) > 1 {
|
||||||
|
tags = append(tags, match[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return slice.Compact(tags)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ParseIntStrRange(input string, sep string) (int64, int64, error) {
|
||||||
|
parts := strings.Split(input, sep)
|
||||||
|
if len(parts) != 2 {
|
||||||
|
return 0, 0, fmt.Errorf("invalid range format: %s", input)
|
||||||
|
}
|
||||||
|
min, err := strconv.ParseInt(strings.TrimSpace(parts[0]), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, fmt.Errorf("invalid minimum value: %s", parts[0])
|
||||||
|
}
|
||||||
|
max, err := strconv.ParseInt(strings.TrimSpace(parts[1]), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, fmt.Errorf("invalid maximum value: %s", parts[1])
|
||||||
|
}
|
||||||
|
if min > max {
|
||||||
|
min, max = max, min
|
||||||
|
}
|
||||||
|
return min, max, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ParseArgsRespectQuotes(input string) []string {
|
||||||
|
var args []string
|
||||||
|
var current strings.Builder
|
||||||
|
inQuotes := false
|
||||||
|
escaped := false
|
||||||
|
|
||||||
|
for _, r := range input {
|
||||||
|
switch {
|
||||||
|
case escaped:
|
||||||
|
if r == '"' || r == '\\' {
|
||||||
|
current.WriteRune(r)
|
||||||
|
} else {
|
||||||
|
current.WriteRune('\\')
|
||||||
|
current.WriteRune(r)
|
||||||
|
}
|
||||||
|
escaped = false
|
||||||
|
|
||||||
|
case r == '\\':
|
||||||
|
escaped = true
|
||||||
|
|
||||||
|
case r == '"':
|
||||||
|
inQuotes = !inQuotes
|
||||||
|
|
||||||
|
case r == ' ' || r == '\t':
|
||||||
|
if inQuotes {
|
||||||
|
current.WriteRune(r)
|
||||||
|
} else if current.Len() > 0 {
|
||||||
|
args = append(args, current.String())
|
||||||
|
current.Reset()
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
current.WriteRune(r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if current.Len() > 0 {
|
||||||
|
args = append(args, current.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
return args
|
||||||
|
}
|
||||||
148
common/utils/strutil/string_test.go
Normal file
148
common/utils/strutil/string_test.go
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
package strutil_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/krau/SaveAny-Bot/common/utils/strutil"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestExtractTagsFromText(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
text string
|
||||||
|
expected []string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
text: `初音ミクHappy 16th Birthday -Dear Creators-
|
||||||
|
✨エンドイラスト公開!✨
|
||||||
|
https://piapro.net/miku16thbd/
|
||||||
|
#初音ミク #miku16th`,
|
||||||
|
expected: []string{"初音ミク", "miku16th"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: `ひっつきむし
|
||||||
|
#創作百合`,
|
||||||
|
expected: []string{"創作百合"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: `#創作百合 #原创`,
|
||||||
|
expected: []string{"創作百合", "原创"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: `プラニャ #ブルアカ`,
|
||||||
|
expected: []string{"ブルアカ"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: `原神是一款#开放世界#冒险游戏,由中国著名游戏公司#miHoYo开发。`,
|
||||||
|
expected: []string{},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
result := strutil.ExtractTagsFromText(test.text)
|
||||||
|
if !reflect.DeepEqual(result, test.expected) {
|
||||||
|
t.Fatalf("ExtractTagsFromText(%s) = %v, expected %v", test.text, result, test.expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseIntStrRange(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
sep string
|
||||||
|
wantMin int64
|
||||||
|
wantMax int64
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "normal range",
|
||||||
|
input: "10-20",
|
||||||
|
sep: "-",
|
||||||
|
wantMin: 10,
|
||||||
|
wantMax: 20,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "reverse order",
|
||||||
|
input: "30 - 10",
|
||||||
|
sep: "-",
|
||||||
|
wantMin: 10,
|
||||||
|
wantMax: 30,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid format",
|
||||||
|
input: "10",
|
||||||
|
sep: "-",
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid number",
|
||||||
|
input: "a-b",
|
||||||
|
sep: "-",
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
min, max, err := strutil.ParseIntStrRange(tt.input, tt.sep)
|
||||||
|
if (err != nil) != tt.wantErr {
|
||||||
|
t.Errorf("ParseIntStrRange(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !tt.wantErr {
|
||||||
|
if min != tt.wantMin || max != tt.wantMax {
|
||||||
|
t.Errorf("ParseIntStrRange(%q) = (%d, %d), want (%d, %d)", tt.input, min, max, tt.wantMin, tt.wantMax)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseArgsRespectQuotes(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
want []string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "simple split",
|
||||||
|
input: `/rule add FILENAME-REGEX (?i)\.(mp4|mkv)$ "我的 Alist" /视频`,
|
||||||
|
want: []string{"/rule", "add", "FILENAME-REGEX", "(?i)\\.(mp4|mkv)$", "我的 Alist", "/视频"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "escaped quotes",
|
||||||
|
input: `/rule add "My \"Awesome\" Folder"`,
|
||||||
|
want: []string{"/rule", "add", `My "Awesome" Folder`},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "escaped backslash",
|
||||||
|
input: `/cmd "C:\\Users\\Admin" test`,
|
||||||
|
want: []string{"/cmd", `C:\Users\Admin`, "test"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "multiple quoted parts",
|
||||||
|
input: `"Hello World" "你好 世界"`,
|
||||||
|
want: []string{"Hello World", "你好 世界"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unquoted words",
|
||||||
|
input: "a b c",
|
||||||
|
want: []string{"a", "b", "c"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "mixed quotes and plain",
|
||||||
|
input: `cmd "quoted arg" plain`,
|
||||||
|
want: []string{"cmd", "quoted arg", "plain"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := strutil.ParseArgsRespectQuotes(tt.input)
|
||||||
|
if !reflect.DeepEqual(got, tt.want) {
|
||||||
|
t.Errorf("ParseArgsRespectQuotes(%q) = %#v, want %#v", tt.input, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
22
common/utils/tgutil/context.go
Normal file
22
common/utils/tgutil/context.go
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
package tgutil
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/celestix/gotgproto/ext"
|
||||||
|
)
|
||||||
|
|
||||||
|
type contextKey struct{}
|
||||||
|
|
||||||
|
var extKey = contextKey{}
|
||||||
|
|
||||||
|
func ExtFromContext(ctx context.Context) *ext.Context {
|
||||||
|
if extCtx, ok := ctx.Value(extKey).(*ext.Context); ok {
|
||||||
|
return extCtx
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ExtWithContext(ctx context.Context, extCtx *ext.Context) context.Context {
|
||||||
|
return context.WithValue(ctx, extKey, extCtx)
|
||||||
|
}
|
||||||
40
common/utils/tgutil/media.go
Normal file
40
common/utils/tgutil/media.go
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
package tgutil
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/gabriel-vasile/mimetype"
|
||||||
|
"github.com/gotd/td/tg"
|
||||||
|
)
|
||||||
|
|
||||||
|
func GetMediaFileName(media tg.MessageMediaClass) (string, error) {
|
||||||
|
switch v := media.(type) {
|
||||||
|
case *tg.MessageMediaPhoto:
|
||||||
|
f, ok := v.Photo.AsNotEmpty()
|
||||||
|
if !ok {
|
||||||
|
return "", fmt.Errorf("unknown type media: %T", media)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%d.png", f.ID), nil
|
||||||
|
case *tg.MessageMediaDocument:
|
||||||
|
f, ok := v.Document.AsNotEmpty()
|
||||||
|
if !ok {
|
||||||
|
return "", fmt.Errorf("unknown type media: %T", media)
|
||||||
|
}
|
||||||
|
fileName := ""
|
||||||
|
for _, attribute := range f.Attributes {
|
||||||
|
if name, ok := attribute.(*tg.DocumentAttributeFilename); ok {
|
||||||
|
fileName = name.GetFileName()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if fileName == "" {
|
||||||
|
mmt := mimetype.Lookup(f.GetMimeType())
|
||||||
|
if mmt != nil {
|
||||||
|
fileName = fmt.Sprintf("%d.%s", f.GetID(), mmt.Extension())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fileName, nil
|
||||||
|
default:
|
||||||
|
return "", fmt.Errorf("unsupported type media: %T", media)
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user