Compare commits
414 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
88a02aae8d | ||
|
|
ab374a870b | ||
|
|
3a1b8f34ea | ||
|
|
c4eb824457 | ||
|
|
692e970772 | ||
|
|
80696c9661 | ||
|
|
18cd480264 | ||
|
|
dfde65c28e | ||
|
|
968547b005 | ||
|
|
9367419156 | ||
|
|
f80c4d7d55 | ||
|
|
ccfde34666 | ||
|
|
2b23446123 | ||
|
|
7882185ee1 | ||
|
|
2d17a731c4 | ||
|
|
db69688722 | ||
|
|
ec09289d5f | ||
|
|
13c87debcc | ||
|
|
5f3b38c788 | ||
|
|
8ba0c623c9 | ||
|
|
6fa8e89191 | ||
|
|
3a4effab33 | ||
|
|
7692286d78 | ||
|
|
93ffc940ce | ||
|
|
4aadfc1273 | ||
|
|
d26a8df15f | ||
|
|
a746cc0fc7 | ||
|
|
0fb5634874 | ||
|
|
930e838b2e | ||
|
|
1701d1ab86 | ||
|
|
a17492d4ae | ||
|
|
a32bf43cdc | ||
|
|
a25a58f8a2 | ||
|
|
804a86cbdd | ||
|
|
cf1e9299c0 | ||
|
|
e3f7380341 | ||
|
|
6c6ee77067 | ||
|
|
f00aa189e3 | ||
|
|
48ceb87e29 | ||
|
|
cd663d05ca | ||
|
|
a778b0fa8c | ||
|
|
2a9c615819 | ||
|
|
2f60af858d | ||
|
|
741ceb87fb | ||
|
|
9795a85dd6 | ||
|
|
45974917f2 | ||
|
|
a146871a9d | ||
|
|
b76c1d5ccc | ||
|
|
79fba918cf | ||
|
|
e1bc80ab7d | ||
|
|
8d0851f37a | ||
|
|
4b5cabc76e | ||
|
|
3b42ba86b3 | ||
|
|
2b0bfac171 | ||
|
|
a76b289d17 | ||
|
|
4ac65622ee | ||
|
|
7bac97d533 | ||
|
|
169197c8fe | ||
|
|
a166ff1e64 | ||
|
|
f360052784 | ||
|
|
15f263cb50 | ||
|
|
420e0b1a4f | ||
|
|
71bd6a9480 | ||
|
|
5b9e177af4 | ||
|
|
9e226ae592 | ||
|
|
a91cce9680 | ||
|
|
1128ee2081 | ||
|
|
e3cd659eb3 | ||
|
|
454d69c9d4 | ||
|
|
71e5007228 | ||
|
|
3e5cd60cf7 | ||
|
|
0297fdce71 | ||
|
|
20e06fbf46 | ||
|
|
fbdfc04ad8 | ||
|
|
32bd391129 | ||
|
|
183edd0dbe | ||
|
|
75f6b444fc | ||
|
|
4294966ab7 | ||
|
|
edef778959 | ||
|
|
0bd9b77856 | ||
|
|
d5f214494c | ||
|
|
b5af39c132 | ||
|
|
4aba14c4c7 | ||
|
|
a66fa5df81 | ||
|
|
f58e28412f | ||
|
|
c8376f78a2 | ||
|
|
5d6b1003e9 | ||
|
|
5ef7c5ce60 | ||
|
|
03c833acbf | ||
|
|
0d369c7c79 |
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"
|
||||
]
|
||||
39
.github/ISSUE_TEMPLATE/bug.yml
vendored
Normal file
39
.github/ISSUE_TEMPLATE/bug.yml
vendored
Normal file
@@ -0,0 +1,39 @@
|
||||
name: "👾 Bug Report"
|
||||
description: "Report a bug or unexpected behavior"
|
||||
labels:
|
||||
- "bug"
|
||||
assignees:
|
||||
- krau
|
||||
body:
|
||||
- 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"
|
||||
33
.github/ISSUE_TEMPLATE/feature.yml
vendored
Normal file
33
.github/ISSUE_TEMPLATE/feature.yml
vendored
Normal file
@@ -0,0 +1,33 @@
|
||||
name: "⭐️ Feature Request"
|
||||
description: "Feature request"
|
||||
labels:
|
||||
- "enhancement"
|
||||
assignees:
|
||||
- krau
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
# Please describe the feature you want in detail
|
||||
- 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:
|
||||
86
.github/copilot-instructions.md
vendored
Normal file
86
.github/copilot-instructions.md
vendored
Normal file
@@ -0,0 +1,86 @@
|
||||
# SaveAny-Bot AI 协作说明
|
||||
|
||||
本项目是一个将 Telegram 文件/消息转存到多种存储端的 Bot,主要用 Go 实现,CLI 入口在 `cmd/`,核心逻辑分布在 `client/`、`core/`、`config/`、`database/`、`storage/` 等目录。下面是针对本仓库的专用约定,供 AI 编码助手参考。
|
||||
|
||||
## 总体架构与入口
|
||||
- **CLI 入口**:
|
||||
- 二进制入口:`main.go` 调用 `cmd.Execute(ctx)`。
|
||||
- 根命令:`cmd/root.go` 使用 `cobra` 定义 `saveany-bot`,`Run` 实现在 `cmd/run.go`。
|
||||
- **应用启动流程(非常重要)**:
|
||||
- `cmd/run.go::Run` 中按顺序完成:读取配置 `config.Init` → 初始化缓存 `common/cache` → 初始化 i18n `common/i18n` → 初始化数据库 `database.Init` → 加载存储 `storage.LoadStorages` → 加载解析器插件 `parsers.LoadPlugins` → (可选)Userbot 登录 → 启动 Telegram Bot `client/bot.Init` → 启动核心任务队列消费 `core.Run`。
|
||||
- 添加新的初始化步骤时,请遵循该顺序并放在 `initAll` 中,而不是分散在各处。
|
||||
|
||||
## 配置与约定
|
||||
- **配置系统**:
|
||||
- 使用 `viper` 读取 `config.toml`,核心结构体定义在 `config/viper.go::Config`。
|
||||
- 默认值在 `config.Init` 中通过 `viper.SetDefault` 定义,如 `workers`、`retry`、`telegram.*`、`db.*` 等。
|
||||
- `Config.C()` 返回的是全局配置副本(值类型),不要在返回值上修改字段;如果需要修改配置流程,应在 `config.Init` 内或通过 `viper` 进行。
|
||||
- 存储配置通过 `config/storage/factory.go::LoadStorageConfigs` 加载并校验,新增存储类型需:
|
||||
- 在 `pkg/enums/storage` 中增加枚举。
|
||||
- 在 `config/storage/` 下新增具体 `StorageConfig` 实现并实现 `Validate`。
|
||||
- 在 `storageFactories` 映射中注册工厂方法。
|
||||
- **环境变量**:
|
||||
- 所有配置键会被 `SAVEANY_` 前缀的环境变量覆盖,`.` 会被 `_` 替换(`SAVEANY_TELEGRAM_APP_ID` 等)。
|
||||
|
||||
## Telegram 客户端与中间件
|
||||
- **Bot 客户端**:
|
||||
- 入口在 `client/bot/bot.go::Init`,使用 `gotgproto.NewClient` 创建,Session 使用 `database.GetDialect(config.C().DB.Session)`,错误处理通过 `ErrorHandler` 回调完成。
|
||||
- Handlers 注册集中在 `client/bot/handlers` 目录,`handlers.Register` 负责统一挂载;新增命令/消息处理逻辑时优先在该目录按功能拆分文件,实现后在 `handlers.Register` 中注册。
|
||||
- Bot 命令列表依赖 `handlers.CommandHandlers` 来自动注册到 Telegram;新增命令时务必更新该切片,以保持 `/help` 与 Bot 命令列表一致。
|
||||
- **中间件**:
|
||||
- 通用中间件位于 `client/middleware/`,包含 floodwait、防崩溃、重试等;`middleware.NewDefaultMiddlewares` 在 `client/bot/bot.go` 中统一挂载。
|
||||
- 新增跨所有更新生效的行为(如日志、统计)时,应优先实现为中间件。
|
||||
|
||||
## 核心任务与队列
|
||||
- **任务接口与队列**:
|
||||
- 核心接口:`core/core.go::Executable`,包含 `Type() TaskType`、`Title()`、`TaskID()`、`Execute(ctx)`。
|
||||
- 任务队列:`pkg/queue.TaskQueue[Executable]`,由 `core.Run` 使用;`Workers` 数量来自配置 `config.C().Workers`。
|
||||
- 任务类型与实现示例位于 `core/tasks/**`,例如文件任务、Telegraph 任务等;新增任务类型应放在对应子目录并实现 `Executable` 接口,然后通过 `core.AddTask` 入队。
|
||||
- **生命周期 Hook**:
|
||||
- `core.worker` 在执行任务前后会根据 `config.C().Hook.Exec` 调用外部命令(`TaskBeforeStart` / `TaskSuccess` / `TaskFail` / `TaskCancel`)。
|
||||
- 修改任务执行流程时需保留这些 Hook 调用,以免破坏用户已有集成。
|
||||
|
||||
## 数据库与持久化
|
||||
- **数据库初始化**:
|
||||
- `database.Init` 使用配置 `config.C().DB.Path` 创建并连接 SQLite,使用 `GetDialect` 抽象驱动(见 `database/driver_*.go`)。
|
||||
- Migration 通过 `db.AutoMigrate(&User{}, &Dir{}, &Rule{}, &WatchChat{})` 完成,模型定义在 `database/*.go` 中。
|
||||
- **用户同步约定**:
|
||||
- `database.syncUsers` 会根据 `config.C().Users` 同步数据库用户表:在配置中新增/删除用户会自动在 DB 中创建/删除对应记录。
|
||||
- 开发涉及用户表逻辑时,请考虑该同步行为,避免在其他地方直接创建/删除用户记录而与配置冲突。
|
||||
|
||||
## 存储后端
|
||||
- **存储抽象**:
|
||||
- 抽象接口在 `config/storage/types.go` 与 `storage/` 顶层(以及子目录)中;`config/storage/*.go` 处理配置解析,`storage/*` 处理真正的上传/下载实现。
|
||||
- 现有实现包括 `local`、`alist`、`s3/minio`、`webdav`、`telegram` 等,每个后端都有对应子目录和配置结构体。
|
||||
- **新增存储实现的推荐路径**:
|
||||
- 在 `config/storage/` 下添加配置结构体 + `Validate`。
|
||||
- 在 `storage/` 下添加具体实现(例如 `storage/foo/`)。
|
||||
- 在 `pkg/enums/storage` 与 `storageFactories` 中注册,并确保 `storages` 配置示例被更新(`config.example.toml` / 文档)。
|
||||
|
||||
## 解析器插件(JS)
|
||||
- **插件运行时**:
|
||||
- 解析器接口和插件文档在 `plugins/README.md`,Go 端入口为 `parsers/` 目录,使用 `goja` 与 `playwright-go`。
|
||||
- 插件通过 `registerParser({ metadata, canHandle, parse })` 注册,使用 `ghttp`/`playwright` 进行 HTTP/浏览器请求。
|
||||
- **与核心交互约定**:
|
||||
- 插件 `parse` 返回的 `Item`/`Resource` 会被转化为内部任务(通常是下载/转存任务)并进入 `core` 队列。
|
||||
- 修改 `Item`/`Resource` 结构或解析逻辑时,要确保保持向后兼容,或在 `plugins/README.md` 中同步更新字段说明和示例。
|
||||
|
||||
## i18n 与日志
|
||||
- **国际化**:
|
||||
- 所有用户可见字符串(尤其是错误与提示)应使用 `common/i18n`:`i18n.T(i18nk.SomeKey, map[string]any{"Name": name})`。
|
||||
- 语言文件位于 `common/i18n/locale/`,`go:generate` 指令在 `main.go` 中生成 `i18nk/keys.go`;新增文案时需:添加到 YAML、运行 `go generate ./...`、再在代码中引用新 key。
|
||||
- **日志**:
|
||||
- 使用 `github.com/charmbracelet/log`,在 `cmd/run.go::Run` 中通过 `log.WithContext` 将 logger 注入 `context.Context`;后续代码优先通过 `log.FromContext(ctx)` 获取 logger。
|
||||
- 编写新代码时,如已有 `ctx`,请使用 `log.FromContext(ctx)` 而不是全局 logger。
|
||||
|
||||
## 开发与运行
|
||||
- **本地运行**:
|
||||
- 直接运行:`go run ./cmd`(`cmd/root.go` + `cmd/run.go`)。
|
||||
- 或通过 Docker:参见根目录 `README.md` 中的 `docker run ...` 示例及 `docker-compose.yml`。
|
||||
- **代码生成与文档**:
|
||||
- i18n key 生成:`go generate ./...` 会执行 `main.go` 顶部的 `//go:generate`,使用 `cmd/geni18n/main.go` 生成 `common/i18n/i18nk/keys.go`。
|
||||
- 文档站点在 `docs/`(Hugo),通常不需要在核心代码改动时同步修改,除非涉及文档内容。
|
||||
|
||||
---
|
||||
|
||||
如果你在实现某个功能时发现以上规则不够具体(例如某类任务在 `core/tasks` 中到底如何落地,或某个存储/解析器的边界不清晰),请在对应章节下扩展更精确的说明,并在 PR 描述中标注。也欢迎你告诉我有哪些部分需要补充或澄清,我可以进一步细化。
|
||||
163
.github/workflows/build-docker.yml
vendored
Normal file
163
.github/workflows/build-docker.yml
vendored
Normal file
@@ -0,0 +1,163 @@
|
||||
name: Build and Publish Docker Image
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: krau/saveany-bot
|
||||
|
||||
concurrency:
|
||||
group: docker-build-${{ github.repository }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
prepare:
|
||||
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:
|
||||
contents: read
|
||||
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:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push by digest
|
||||
id: build
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ${{ matrix.type == 'default' && './Dockerfile' || format('./Dockerfile.{0}', matrix.type) }}
|
||||
platforms: ${{ matrix.arch == 'amd64' && 'linux/amd64' || 'linux/arm64' }}
|
||||
# 关键修改:不再使用 tags,而是通过 image output 按摘要推送
|
||||
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
|
||||
@@ -1,3 +1,5 @@
|
||||
name: Build Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
@@ -13,7 +15,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -21,9 +23,9 @@ jobs:
|
||||
uses: softprops/action-gh-release@v2
|
||||
|
||||
- name: Setup node
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 20
|
||||
node-version: 22
|
||||
|
||||
- run: npx changelogithub
|
||||
env:
|
||||
@@ -36,6 +38,9 @@ jobs:
|
||||
matrix:
|
||||
goos: [linux, darwin, windows]
|
||||
goarch: [amd64, arm64]
|
||||
exclude:
|
||||
- goos: windows
|
||||
goarch: arm64
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -49,7 +54,7 @@ jobs:
|
||||
- name: Release Go Binary
|
||||
uses: wangyoucao577/go-release-action@v1
|
||||
with:
|
||||
pre_command: export
|
||||
pre_command: export CGO_ENABLED=0
|
||||
goos: ${{ matrix.goos }}
|
||||
goarch: ${{ matrix.goarch }}
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -58,9 +63,9 @@ jobs:
|
||||
README.md
|
||||
ldflags: >-
|
||||
-s -w
|
||||
-X "github.com/krau/SaveAny-Bot/common.Version=${{ env.VERSION }}"
|
||||
-X "github.com/krau/SaveAny-Bot/common.BuildTime=${{ format(github.event.repository.updated_at, 'yyyy-MM-dd HH:mm:ss') }}"
|
||||
-X "github.com/krau/SaveAny-Bot/common.GitCommit=${{ github.sha }}"
|
||||
-X "github.com/krau/SaveAny-Bot/config.Version=${{ env.VERSION }}"
|
||||
-X "github.com/krau/SaveAny-Bot/config.BuildTime=${{ format(github.event.repository.updated_at, 'yyyy-MM-dd HH:mm:ss') }}"
|
||||
-X "github.com/krau/SaveAny-Bot/config.GitCommit=${{ github.sha }}"
|
||||
binary_name: saveany-bot
|
||||
env:
|
||||
VERSION: ${{ env.VERSION }}
|
||||
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
|
||||
8
.gitignore
vendored
8
.gitignore
vendored
@@ -3,4 +3,10 @@ logs/
|
||||
tmp/
|
||||
data/
|
||||
downloads/
|
||||
cache/
|
||||
session.*
|
||||
cache.db
|
||||
.vscode/
|
||||
temp/
|
||||
.hugo_build.lock
|
||||
playwright/
|
||||
testplugins/
|
||||
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",
|
||||
}
|
||||
]
|
||||
}
|
||||
172
CLAUDE.md
Normal file
172
CLAUDE.md
Normal file
@@ -0,0 +1,172 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
SaveAny-Bot is a Telegram bot written in Go that saves files from Telegram and external websites to various storage backends (Local, Alist, S3, WebDAV, Telegram). It supports bypassing "restrict saving content" media, batch downloads, auto-organization, and JS parser plugins for extracting content from websites.
|
||||
|
||||
## Commands
|
||||
|
||||
### Development
|
||||
```bash
|
||||
# Run the bot directly
|
||||
go run ./cmd
|
||||
|
||||
# Run with specific config file
|
||||
go run ./cmd --config /path/to/config.toml
|
||||
|
||||
# Generate i18n keys after modifying locale files
|
||||
go generate ./...
|
||||
|
||||
# Run tests
|
||||
go test ./...
|
||||
|
||||
# Run specific package tests
|
||||
go test ./storage/s3
|
||||
go test ./pkg/queue
|
||||
```
|
||||
|
||||
### Docker
|
||||
```bash
|
||||
# Build and run locally
|
||||
docker-compose -f docker-compose.local.yml up
|
||||
|
||||
# Production deployment
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
### Application Initialization Flow (CRITICAL)
|
||||
|
||||
The application follows a strict initialization sequence in `cmd/run.go::initAll`:
|
||||
|
||||
1. **Config** (`config.Init`) - Load config.toml using viper
|
||||
2. **Cache** (`common/cache`) - Initialize in-memory cache
|
||||
3. **i18n** (`common/i18n`) - Load localization files from `common/i18n/locale/`
|
||||
4. **Database** (`database.Init`) - Connect to SQLite, auto-migrate models
|
||||
5. **Storage** (`storage.LoadStorages`) - Initialize all configured storage backends
|
||||
6. **Parsers** (`parsers.LoadPlugins`) - Load JS parser plugins if enabled
|
||||
7. **Userbot** (optional) - Login to Telegram userbot if configured
|
||||
8. **Bot** (`client/bot.Init`) - Initialize Telegram bot client
|
||||
9. **Core** (`core.Run`) - Start task queue workers
|
||||
|
||||
**When adding new initialization steps, add them to `initAll` in this order.**
|
||||
|
||||
### Core Components
|
||||
|
||||
#### Task Queue System (`core/`)
|
||||
- Central abstraction: `core.Executable` interface with `Type()`, `Title()`, `TaskID()`, `Execute(ctx)`
|
||||
- Task queue: `pkg/queue.TaskQueue[Executable]` managed by `core.Run`
|
||||
- Worker pool size controlled by `config.Workers`
|
||||
- Task implementations in `core/tasks/`:
|
||||
- `tfile/` - Telegram file downloads
|
||||
- `batchtfile/` - Batch downloads
|
||||
- `telegraph/` - Telegraph page saving
|
||||
- `parsed/` - Content from parser plugins
|
||||
- `directlinks/` - Direct URL downloads
|
||||
- Tasks support lifecycle hooks: `TaskBeforeStart`, `TaskSuccess`, `TaskFail`, `TaskCancel` (configured via `config.Hook.Exec`)
|
||||
|
||||
#### Telegram Client (`client/`)
|
||||
- **Bot**: `client/bot/bot.go` uses `gotgproto.NewClient`, handlers in `client/bot/handlers/`
|
||||
- **Userbot**: `client/user/` for bypassing restricted content (optional)
|
||||
- **Middleware**: `client/middleware/` handles flood wait, recovery, retry
|
||||
- Handler registration: All handlers registered in `handlers.Register()`, command list in `handlers.CommandHandlers`
|
||||
|
||||
#### Storage Backends (`storage/`)
|
||||
- Abstract interface: `Storage` with `Init()`, `Save()`, `Exists()`, `JoinStoragePath()`
|
||||
- Implementations: `local/`, `alist/`, `s3/`, `minio/`, `webdav/`, `telegram/`
|
||||
- Storage registry: `storage.Storages` map, constructed via `storageConstructors`
|
||||
- Add new storage: Define enum in `pkg/enums/storage`, config in `config/storage/`, implementation in `storage/`, register in `storageConstructors`
|
||||
|
||||
#### Parser Plugins (`parsers/`)
|
||||
- Native parsers: `parsers/native/twitter/`, `parsers/native/kemono/`
|
||||
- JS plugin runtime: `parsers/js/` using `goja` VM and `playwright-go`
|
||||
- Plugin interface: `registerParser({ metadata, canHandle, parse })` in JS
|
||||
- Plugins return `parser.Item` with `Resources[]`, converted to `core/tasks/parsed` tasks
|
||||
- Plugin docs: `plugins/README.md`
|
||||
|
||||
#### Configuration (`config/`)
|
||||
- Main config: `config/viper.go::Config` loaded via `viper`
|
||||
- Storage configs: `config/storage/factory.go::LoadStorageConfigs` with type-specific validation
|
||||
- Environment override: Prefix `SAVEANY_`, dots become underscores (e.g., `SAVEANY_TELEGRAM_TOKEN`)
|
||||
- **Important**: `config.C()` returns a copy, don't modify its fields; modify via `viper` in `config.Init`
|
||||
|
||||
#### Database (`database/`)
|
||||
- SQLite with GORM
|
||||
- Models: `User`, `Dir`, `Rule`, `WatchChat`
|
||||
- User sync: `database.syncUsers` syncs `config.Users` to DB (creates/deletes users based on config)
|
||||
- Don't create/delete users manually; use config file
|
||||
|
||||
#### Internationalization (`common/i18n/`)
|
||||
- Locale files: `common/i18n/locale/*.yaml`
|
||||
- Key generation: `go generate ./...` runs `cmd/geni18n/main.go` to generate `common/i18n/i18nk/keys.go`
|
||||
- Usage: `i18n.T(i18nk.KeyName, map[string]any{"Param": value})`
|
||||
- Add new strings: Edit YAML → run `go generate` → use new key in code
|
||||
|
||||
#### Logging
|
||||
- Uses `github.com/charmbracelet/log`
|
||||
- Logger injected into context in `cmd/run.go::Run` via `log.WithContext`
|
||||
- Prefer `log.FromContext(ctx)` over global logger when context is available
|
||||
|
||||
### Data Flow
|
||||
|
||||
1. User sends message/file to Telegram bot
|
||||
2. Handler in `client/bot/handlers/` processes update
|
||||
3. Handler creates task (implements `core.Executable`) and calls `core.AddTask()`
|
||||
4. Task enters `pkg/queue.TaskQueue`
|
||||
5. Worker goroutine picks up task, calls `Execute(ctx)`
|
||||
6. Task downloads content (from Telegram or parser) and saves to storage backend via `Storage.Save()`
|
||||
7. Task completion triggers hooks, sends result message back to user
|
||||
|
||||
For parsed content (URLs):
|
||||
1. Handler extracts URL, calls `parsers.ParseWithContext()`
|
||||
2. Parser (native or JS plugin) returns `parser.Item` with `Resources[]`
|
||||
3. `core/tasks/parsed.Task` created with resources
|
||||
4. Task downloads each resource and saves to storage
|
||||
|
||||
## Project-Specific Guidelines
|
||||
|
||||
### Adding New Storage Backend
|
||||
|
||||
1. Define enum in `pkg/enums/storage/storage.go`
|
||||
2. Create config struct in `config/storage/yourtype.go` implementing `StorageConfig` with `Validate()`
|
||||
3. Implement storage in `storage/yourtype/yourtype.go` with `Storage` interface
|
||||
4. Register in `config/storage/factory.go::storageFactories` and `storage/storage.go::storageConstructors`
|
||||
5. Update `config.example.toml` with example configuration
|
||||
|
||||
### Adding New Task Type
|
||||
|
||||
1. Create package in `core/tasks/yourtype/`
|
||||
2. Define struct implementing `core.Executable` interface
|
||||
3. Implement `Type()`, `Title()`, `TaskID()`, `Execute(ctx)`
|
||||
4. Add task type enum in `pkg/enums/tasktype/`
|
||||
5. Create task from handler and call `core.AddTask(ctx, task)`
|
||||
|
||||
### Adding New Bot Command
|
||||
|
||||
1. Create handler file in `client/bot/handlers/yourcommand.go`
|
||||
2. Implement handler function with signature matching gotgproto patterns
|
||||
3. Register in `handlers.Register()` function
|
||||
4. Add to `handlers.CommandHandlers` slice for command list
|
||||
5. Add i18n strings for command description and responses
|
||||
|
||||
### Adding i18n Strings
|
||||
|
||||
1. Edit locale files in `common/i18n/locale/` (e.g., `zh-Hans.yaml`, `en.yaml`)
|
||||
2. Run `go generate ./...` to generate `common/i18n/i18nk/keys.go`
|
||||
3. Use in code: `i18n.T(i18nk.YourNewKey, map[string]any{"Variable": value})`
|
||||
|
||||
### Working with Middleware
|
||||
|
||||
New cross-cutting concerns (logging, metrics, rate limiting) should be implemented as middleware in `client/middleware/`:
|
||||
1. Create middleware file with function signature matching gotgproto middleware
|
||||
2. Add to `middleware.NewDefaultMiddlewares()` in `client/bot/bot.go`
|
||||
|
||||
### Parser Plugin Development
|
||||
|
||||
- JS plugins go in directories specified by `config.parser.plugin_dirs` (default: `./plugins/`)
|
||||
- See `plugins/README.md` for plugin API documentation
|
||||
- Plugins have access to `ghttp` for HTTP requests and `playwright` for browser automation
|
||||
- Test plugins in `./testplugins/` directory
|
||||
39
Dockerfile
Normal file
39
Dockerfile
Normal file
@@ -0,0 +1,39 @@
|
||||
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 \
|
||||
-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 ffmpeg
|
||||
|
||||
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"]
|
||||
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"]
|
||||
143
LICENSE
143
LICENSE
@@ -1,5 +1,5 @@
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 19 November 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
@@ -7,17 +7,15 @@
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU General Public License is a free, copyleft license for
|
||||
software and other kinds of works.
|
||||
The GNU Affero General Public License is a free, copyleft license for
|
||||
software and other kinds of works, specifically designed to ensure
|
||||
cooperation with the community in the case of network server software.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
the GNU General Public License is intended to guarantee your freedom to
|
||||
our General Public Licenses are intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users. We, the Free Software Foundation, use the
|
||||
GNU General Public License for most of our software; it applies also to
|
||||
any other work released this way by its authors. You can apply it to
|
||||
your programs, too.
|
||||
software for all its users.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
@@ -26,44 +24,34 @@ them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
To protect your rights, we need to prevent others from denying you
|
||||
these rights or asking you to surrender the rights. Therefore, you have
|
||||
certain responsibilities if you distribute copies of the software, or if
|
||||
you modify it: responsibilities to respect the freedom of others.
|
||||
Developers that use our General Public Licenses protect your rights
|
||||
with two steps: (1) assert copyright on the software, and (2) offer
|
||||
you this License which gives you legal permission to copy, distribute
|
||||
and/or modify the software.
|
||||
|
||||
For example, if you distribute copies of such a program, whether
|
||||
gratis or for a fee, you must pass on to the recipients the same
|
||||
freedoms that you received. You must make sure that they, too, receive
|
||||
or can get the source code. And you must show them these terms so they
|
||||
know their rights.
|
||||
A secondary benefit of defending all users' freedom is that
|
||||
improvements made in alternate versions of the program, if they
|
||||
receive widespread use, become available for other developers to
|
||||
incorporate. Many developers of free software are heartened and
|
||||
encouraged by the resulting cooperation. However, in the case of
|
||||
software used on network servers, this result may fail to come about.
|
||||
The GNU General Public License permits making a modified version and
|
||||
letting the public access it on a server without ever releasing its
|
||||
source code to the public.
|
||||
|
||||
Developers that use the GNU GPL protect your rights with two steps:
|
||||
(1) assert copyright on the software, and (2) offer you this License
|
||||
giving you legal permission to copy, distribute and/or modify it.
|
||||
The GNU Affero General Public License is designed specifically to
|
||||
ensure that, in such cases, the modified source code becomes available
|
||||
to the community. It requires the operator of a network server to
|
||||
provide the source code of the modified version running there to the
|
||||
users of that server. Therefore, public use of a modified version, on
|
||||
a publicly accessible server, gives the public access to the source
|
||||
code of the modified version.
|
||||
|
||||
For the developers' and authors' protection, the GPL clearly explains
|
||||
that there is no warranty for this free software. For both users' and
|
||||
authors' sake, the GPL requires that modified versions be marked as
|
||||
changed, so that their problems will not be attributed erroneously to
|
||||
authors of previous versions.
|
||||
|
||||
Some devices are designed to deny users access to install or run
|
||||
modified versions of the software inside them, although the manufacturer
|
||||
can do so. This is fundamentally incompatible with the aim of
|
||||
protecting users' freedom to change the software. The systematic
|
||||
pattern of such abuse occurs in the area of products for individuals to
|
||||
use, which is precisely where it is most unacceptable. Therefore, we
|
||||
have designed this version of the GPL to prohibit the practice for those
|
||||
products. If such problems arise substantially in other domains, we
|
||||
stand ready to extend this provision to those domains in future versions
|
||||
of the GPL, as needed to protect the freedom of users.
|
||||
|
||||
Finally, every program is threatened constantly by software patents.
|
||||
States should not allow patents to restrict development and use of
|
||||
software on general-purpose computers, but in those that do, we wish to
|
||||
avoid the special danger that patents applied to a free program could
|
||||
make it effectively proprietary. To prevent this, the GPL assures that
|
||||
patents cannot be used to render the program non-free.
|
||||
An older license, called the Affero General Public License and
|
||||
published by Affero, was designed to accomplish similar goals. This is
|
||||
a different license, not a version of the Affero GPL, but Affero has
|
||||
released a new version of the Affero GPL which permits relicensing under
|
||||
this license.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
@@ -72,7 +60,7 @@ modification follow.
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU General Public License.
|
||||
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
@@ -549,35 +537,45 @@ to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Use with the GNU Affero General Public License.
|
||||
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, if you modify the
|
||||
Program, your modified version must prominently offer all users
|
||||
interacting with it remotely through a computer network (if your version
|
||||
supports such interaction) an opportunity to receive the Corresponding
|
||||
Source of your version by providing access to the Corresponding Source
|
||||
from a network server at no charge, through some standard or customary
|
||||
means of facilitating copying of software. This Corresponding Source
|
||||
shall include the Corresponding Source for any work covered by version 3
|
||||
of the GNU General Public License that is incorporated pursuant to the
|
||||
following paragraph.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU Affero General Public License into a single
|
||||
under version 3 of the GNU General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the special requirements of the GNU Affero General Public License,
|
||||
section 13, concerning interaction through a network will apply to the
|
||||
combination as such.
|
||||
but the work with which it is combined will remain governed by version
|
||||
3 of the GNU General Public License.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU General Public License from time to time. Such new versions will
|
||||
be similar in spirit to the present version, but may differ in detail to
|
||||
the GNU Affero General Public License from time to time. Such new versions
|
||||
will be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU General
|
||||
Program specifies that a certain numbered version of the GNU Affero General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU General Public License, you may choose any version ever published
|
||||
GNU Affero General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU General Public License can be used, that proxy's
|
||||
versions of the GNU Affero General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
@@ -635,40 +633,29 @@ the "copyright" line and a pointer to where the full notice is found.
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
it under the terms of the GNU Affero General Public License as published
|
||||
by the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If the program does terminal interaction, make it output a short
|
||||
notice like this when it starts in an interactive mode:
|
||||
|
||||
<program> Copyright (C) <year> <name of author>
|
||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it
|
||||
under certain conditions; type `show c' for details.
|
||||
|
||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||
parts of the General Public License. Of course, your program's commands
|
||||
might be different; for a GUI interface, you would use an "about box".
|
||||
If your software can interact with users remotely through a computer
|
||||
network, you should also make sure that it provides a way for users to
|
||||
get its source. For example, if your program is a web application, its
|
||||
interface could display a "Source" link that leads users to an archive
|
||||
of the code. There are many ways you could offer source, and different
|
||||
solutions will be better for different programs; see section 13 for the
|
||||
specific requirements.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU GPL, see
|
||||
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
|
||||
The GNU General Public License does not permit incorporating your program
|
||||
into proprietary programs. If your program is a subroutine library, you
|
||||
may consider it more useful to permit linking proprietary applications with
|
||||
the library. If this is what you want to do, use the GNU Lesser General
|
||||
Public License instead of this License. But first, please read
|
||||
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||
|
||||
97
README.md
97
README.md
@@ -1,27 +1,92 @@
|
||||
# Save Any Bot
|
||||
<div align="center">
|
||||
|
||||
把 Telegram 的文件保存到各类存储端.
|
||||
# <img src="docs/static/logo.png" width="45" align="center"> Save Any Bot
|
||||
|
||||
> *就像 PikPak Bot 一样*
|
||||
**English** | [简体中文](./README_zh.md)
|
||||
|
||||
## 部署
|
||||
> **Save Any Telegram File to Anywhere 📂. Support restrict saving content and beyond telegram.**
|
||||
|
||||
在 [Release](https://github.com/krau/SaveAny-Bot/releases) 页面下载对应平台的二进制文件.
|
||||
[](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)
|
||||
|
||||
在解压后目录新建 `config.toml` 文件, 参考 [config.toml.example](https://github.com/krau/SaveAny-Bot/blob/main/config.example.toml) 编辑配置文件.
|
||||
</div>
|
||||
|
||||
> [!TIP]
|
||||
> 由于 Telegram 官方 Bot API 的限制, Bot 无法下载大于 20MB 的文件. 你需要部署一个本地的 Telegram Bot API 来解决这个问题, 然后将配置文件中的 telegram.api 改为你自己的 api 地址.
|
||||
>
|
||||
> 参考: [telegram-bot-api-compose](https://github.com/krau/telegram-bot-api-compose)
|
||||
## 🎯 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)
|
||||
|
||||
```bash
|
||||
chmod +x saveany-bot
|
||||
./saveany-bot
|
||||
## 📦 Quick Start
|
||||
|
||||
Create a `config.toml` file with the following content:
|
||||
|
||||
```toml
|
||||
[telegram]
|
||||
token = "" # Your bot token, obtained from @BotFather
|
||||
[telegram.proxy]
|
||||
# Enable proxy for Telegram, currently only SOCKS5 is supported
|
||||
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:
|
||||
|
||||
向 Bot 发送(转发)文件, 按照提示操作.
|
||||
```bash
|
||||
docker run -d --name saveany-bot \
|
||||
-v ./config.toml:/app/config.toml \
|
||||
-v ./downloads:/app/downloads \
|
||||
ghcr.io/krau/saveany-bot:latest
|
||||
```
|
||||
|
||||
Please [**read the docs**](https://sabot.unv.app/en/) for more configuration options and usage.
|
||||
|
||||
## Sponsors
|
||||
|
||||
This project is supported by [YxVM](https://yxvm.com/) and [NodeSupport](https://github.com/NodeSeekDev/NodeSupport).
|
||||
|
||||
If this project is helpful to you, consider sponsoring me via:
|
||||
|
||||
- [Afdian](https://afdian.com/a/unvapp)
|
||||
|
||||
## Thanks To
|
||||
|
||||
- [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.
|
||||
|
||||
## Contact
|
||||
|
||||
- [](https://t.me/ProjectSaveAny)
|
||||
- [](https://github.com/krau/saveany-bot/discussions)
|
||||
- [](https://t.me/acherkrau)
|
||||
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, 当前只支持 socks5
|
||||
enable = false
|
||||
url = "socks5://127.0.0.1:7890"
|
||||
|
||||
[[storages]]
|
||||
name = "本地磁盘"
|
||||
type = "local"
|
||||
enable = true
|
||||
base_path = "./downloads"
|
||||
|
||||
[[users]]
|
||||
id = 114514 # 你的 Telegram 账号 id
|
||||
storages = []
|
||||
blacklist = true
|
||||
```
|
||||
|
||||
使用 Docker 运行 Save Any Bot:
|
||||
|
||||
```bash
|
||||
docker run -d --name saveany-bot \
|
||||
-v ./config.toml:/app/config.toml \
|
||||
-v ./downloads:/app/downloads \
|
||||
ghcr.io/krau/saveany-bot:latest
|
||||
```
|
||||
|
||||
请 [**查看文档**](https://sabot.unv.app/) 以获取更多配置选项和使用方法.
|
||||
|
||||
## 赞助
|
||||
|
||||
本项目受到 [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,21 +0,0 @@
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
"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() {
|
||||
config.Init()
|
||||
logger.InitLogger()
|
||||
logger.L.Info("Running...")
|
||||
|
||||
common.Init()
|
||||
storage.Init()
|
||||
dao.Init()
|
||||
bot.Init()
|
||||
}
|
||||
110
bot/bot.go
110
bot/bot.go
@@ -1,110 +0,0 @@
|
||||
package bot
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/duke-git/lancet/v2/slice"
|
||||
"github.com/krau/SaveAny-Bot/config"
|
||||
"github.com/krau/SaveAny-Bot/logger"
|
||||
"github.com/mymmrac/telego"
|
||||
"github.com/mymmrac/telego/telegohandler"
|
||||
"github.com/mymmrac/telego/telegoutil"
|
||||
)
|
||||
|
||||
var (
|
||||
Bot *telego.Bot
|
||||
)
|
||||
|
||||
func Init() {
|
||||
logger.L.Debug("Initializing bot...")
|
||||
var err error
|
||||
Bot, err = telego.NewBot(
|
||||
config.Cfg.Telegram.Token,
|
||||
telego.WithDefaultLogger(false, true),
|
||||
telego.WithAPIServer(config.Cfg.Telegram.API),
|
||||
)
|
||||
if err != nil {
|
||||
logger.L.Fatal("Failed to create bot: ", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
Bot.SetMyCommands(&telego.SetMyCommandsParams{
|
||||
Commands: []telego.BotCommand{
|
||||
{Command: "start", Description: "开始使用"},
|
||||
{Command: "help", Description: "显示帮助"},
|
||||
{Command: "silent", Description: "静默模式"},
|
||||
{Command: "storage", Description: "设置默认存储位置"},
|
||||
{Command: "save", Description: "保存文件"},
|
||||
{Command: "clean", Description: "清除文件记录"},
|
||||
},
|
||||
})
|
||||
logger.L.Debug("Bot initialized")
|
||||
}
|
||||
|
||||
func Run() {
|
||||
if Bot == nil {
|
||||
Init()
|
||||
}
|
||||
logger.L.Info("Start polling...")
|
||||
updates, err := Bot.UpdatesViaLongPolling(&telego.GetUpdatesParams{
|
||||
Offset: -1,
|
||||
AllowedUpdates: []string{
|
||||
telego.MessageUpdates,
|
||||
telego.CallbackQueryUpdates,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
logger.L.Fatal("Failed to start polling: ", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
botHandler, err := telegohandler.NewBotHandler(Bot, updates)
|
||||
if err != nil {
|
||||
logger.L.Fatal("Failed to create bot handler: ", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer botHandler.Stop()
|
||||
defer Bot.StopLongPolling()
|
||||
|
||||
botHandler.Use(telegohandler.PanicRecovery())
|
||||
baseGroup := botHandler.BaseGroup()
|
||||
|
||||
registerHandlers(baseGroup)
|
||||
|
||||
botHandler.Start()
|
||||
}
|
||||
|
||||
func registerHandlers(hg *telegohandler.HandlerGroup) {
|
||||
msgGroup := hg.Group(telegohandler.AnyMessage())
|
||||
msgGroup.Use(func(bot *telego.Bot, update telego.Update, next telegohandler.Handler) {
|
||||
if !slice.Contain(config.Cfg.Telegram.Admins, update.Message.From.ID) {
|
||||
bot.SendMessage(telegoutil.Message(update.Message.Chat.ChatID(), "抱歉, 该 Bot 为个人使用设计, 您可以部署自己的 SaveAnyBot 实例: https://github.com/krau/SaveAny-Bot"))
|
||||
return
|
||||
}
|
||||
next(bot, update)
|
||||
})
|
||||
|
||||
msgGroup.HandleMessageCtx(Start, telegohandler.CommandEqual("start"))
|
||||
msgGroup.HandleMessageCtx(Help, telegohandler.CommandEqual("help"))
|
||||
msgGroup.HandleMessageCtx(ChangeSilentMode, telegohandler.CommandEqual("silent"))
|
||||
msgGroup.HandleMessageCtx(SetDefaultStorage, telegohandler.CommandEqual("storage"))
|
||||
msgGroup.HandleMessageCtx(SaveFile, telegohandler.CommandEqual("save"))
|
||||
msgGroup.HandleMessageCtx(CleanReceivedFile, telegohandler.CommandEqual("clean"))
|
||||
|
||||
msgGroup.HandleMessageCtx(HandleFileMessage, func(update telego.Update) bool {
|
||||
return update.Message.Document != nil || update.Message.Video != nil || update.Message.Audio != nil
|
||||
})
|
||||
|
||||
callbackGroup := hg.Group(telegohandler.AnyCallbackQueryWithMessage())
|
||||
callbackGroup.Use(func(bot *telego.Bot, update telego.Update, next telegohandler.Handler) {
|
||||
if !slice.Contain(config.Cfg.Telegram.Admins, update.CallbackQuery.From.ID) {
|
||||
bot.AnswerCallbackQuery(telegoutil.
|
||||
CallbackQuery(update.CallbackQuery.ID).
|
||||
WithText("抱歉, 该 Bot 为个人使用设计, 您可以部署自己的 SaveAnyBot 实例: https://github.com/krau/SaveAny-Bot").
|
||||
WithShowAlert().
|
||||
WithCacheTime(60))
|
||||
return
|
||||
}
|
||||
next(bot, update)
|
||||
})
|
||||
|
||||
callbackGroup.HandleCallbackQueryCtx(AddToQueue, telegohandler.CallbackDataPrefix("add"))
|
||||
}
|
||||
265
bot/handlers.go
265
bot/handlers.go
@@ -1,265 +0,0 @@
|
||||
package bot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/duke-git/lancet/v2/slice"
|
||||
"github.com/gookit/goutil/maputil"
|
||||
"github.com/krau/SaveAny-Bot/dao"
|
||||
"github.com/krau/SaveAny-Bot/logger"
|
||||
"github.com/krau/SaveAny-Bot/model"
|
||||
"github.com/krau/SaveAny-Bot/queue"
|
||||
"github.com/krau/SaveAny-Bot/storage"
|
||||
"github.com/krau/SaveAny-Bot/types"
|
||||
"github.com/mymmrac/telego"
|
||||
"github.com/mymmrac/telego/telegoutil"
|
||||
)
|
||||
|
||||
func Start(ctx context.Context, bot *telego.Bot, message telego.Message) {
|
||||
if err := dao.CreateUser(message.From.ID); err != nil {
|
||||
logger.L.Errorf("Failed to create user: %s", err)
|
||||
return
|
||||
}
|
||||
Help(ctx, bot, message)
|
||||
}
|
||||
|
||||
func Help(ctx context.Context, bot *telego.Bot, message telego.Message) {
|
||||
helpText := `
|
||||
SaveAny Bot - 转存你的 Telegram 文件
|
||||
命令:
|
||||
/start - 开始使用
|
||||
/help - 显示帮助
|
||||
/silent - 静默模式
|
||||
/storage - 设置默认存储位置
|
||||
/save - 保存文件
|
||||
/clean - 清除文件记录
|
||||
|
||||
静默模式: 开启后 Bot 直接保存到收到的文件到默认位置, 不再询问
|
||||
`
|
||||
ReplyMessage(message, helpText)
|
||||
}
|
||||
|
||||
func ChangeSilentMode(ctx context.Context, bot *telego.Bot, message telego.Message) {
|
||||
user, err := dao.GetUserByUserID(message.From.ID)
|
||||
if err != nil {
|
||||
logger.L.Error(err)
|
||||
return
|
||||
}
|
||||
user.Silent = !user.Silent
|
||||
err = dao.UpdateUser(user)
|
||||
if err != nil {
|
||||
logger.L.Error(err)
|
||||
return
|
||||
}
|
||||
ReplyMessage(message, fmt.Sprintf("已%s静默模式", map[bool]string{true: "开启", false: "关闭"}[user.Silent]))
|
||||
}
|
||||
|
||||
func SetDefaultStorage(ctx context.Context, bot *telego.Bot, message telego.Message) {
|
||||
if len(storage.Storages) == 0 {
|
||||
ReplyMessage(message, "当前无可用存储端, 请检查配置.")
|
||||
return
|
||||
}
|
||||
_, _, args := telegoutil.ParseCommand(message.Text)
|
||||
availableStorages := maputil.Keys(storage.Storages)
|
||||
if len(args) == 0 {
|
||||
text := EscapeMarkdown("请提供存储位置名称, 可用项:")
|
||||
for _, name := range availableStorages {
|
||||
text += fmt.Sprintf("\n`%s`", name)
|
||||
}
|
||||
text += fmt.Sprintf("\n`all`")
|
||||
bot.SendMessage(telegoutil.Message(message.Chat.ChatID(), text).WithParseMode(telego.ModeMarkdownV2))
|
||||
return
|
||||
}
|
||||
storageName := args[0]
|
||||
if !slice.Contain(availableStorages, storageName) {
|
||||
ReplyMessage(message, "参数错误")
|
||||
return
|
||||
}
|
||||
user, err := dao.GetUserByUserID(message.From.ID)
|
||||
if err != nil {
|
||||
logger.L.Error(err)
|
||||
return
|
||||
}
|
||||
user.DefaultStorage = storageName
|
||||
err = dao.UpdateUser(user)
|
||||
if err != nil {
|
||||
logger.L.Error(err)
|
||||
return
|
||||
}
|
||||
ReplyMessage(message, fmt.Sprintf("已设置默认存储位置为: %s", storageName))
|
||||
}
|
||||
|
||||
func SaveFile(ctx context.Context, bot *telego.Bot, message telego.Message) {
|
||||
targetMessage := message.ReplyToMessage
|
||||
if targetMessage == nil {
|
||||
ReplyMessage(message, "请回复要保存的文件")
|
||||
return
|
||||
}
|
||||
if targetMessage.Document == nil && targetMessage.Video == nil && targetMessage.Audio == nil {
|
||||
ReplyMessage(message, "回复的消息不包含文件")
|
||||
return
|
||||
}
|
||||
ctx = context.WithValue(ctx, "force", true)
|
||||
HandleFileMessage(ctx, bot, *targetMessage)
|
||||
}
|
||||
|
||||
func CleanReceivedFile(ctx context.Context, bot *telego.Bot, message telego.Message) {
|
||||
targetMessage := message.ReplyToMessage
|
||||
if targetMessage == nil {
|
||||
ReplyMessage(message, "请回复要清除记录的文件")
|
||||
return
|
||||
}
|
||||
if targetMessage.Document == nil && targetMessage.Video == nil && targetMessage.Audio == nil {
|
||||
ReplyMessage(message, "回复的消息不包含文件")
|
||||
return
|
||||
}
|
||||
var fileUniqueID string
|
||||
if targetMessage.Document != nil {
|
||||
fileUniqueID = targetMessage.Document.FileUniqueID
|
||||
} else if targetMessage.Video != nil {
|
||||
fileUniqueID = targetMessage.Video.FileUniqueID
|
||||
} else if targetMessage.Audio != nil {
|
||||
fileUniqueID = targetMessage.Audio.FileUniqueID
|
||||
}
|
||||
|
||||
if fileUniqueID == "" {
|
||||
ReplyMessage(message, "文件信息获取失败")
|
||||
return
|
||||
}
|
||||
|
||||
if err := dao.DeleteReceivedFileByFileUniqueID(fileUniqueID); err != nil {
|
||||
logger.L.Error(err)
|
||||
ReplyMessage(message, "删除记录失败")
|
||||
return
|
||||
}
|
||||
ReplyMessage(message, "记录已删除")
|
||||
}
|
||||
|
||||
func HandleFileMessage(ctx context.Context, bot *telego.Bot, message telego.Message) {
|
||||
var fileID, fileName string
|
||||
if message.Document != nil {
|
||||
fileID = message.Document.FileID
|
||||
fileName = message.Document.FileName
|
||||
} else if message.Video != nil {
|
||||
fileID = message.Video.FileID
|
||||
fileName = message.Video.FileName
|
||||
} else if message.Audio != nil {
|
||||
fileID = message.Audio.FileID
|
||||
fileName = message.Audio.FileName
|
||||
}
|
||||
|
||||
if fileID == "" {
|
||||
logger.L.Error("File ID is empty")
|
||||
ReplyMessage(message, "文件信息获取失败")
|
||||
return
|
||||
}
|
||||
user, err := dao.GetUserByUserID(message.From.ID)
|
||||
if err != nil {
|
||||
logger.L.Error(err)
|
||||
return
|
||||
}
|
||||
msg, err := ReplyMessage(message, "正在获取文件信息")
|
||||
if err != nil {
|
||||
logger.L.Error(err)
|
||||
return
|
||||
}
|
||||
file, err := bot.GetFile(&telego.GetFileParams{FileID: fileID})
|
||||
if err != nil {
|
||||
logger.L.Error(err)
|
||||
ReplyMessage(message, "获取文件信息失败")
|
||||
return
|
||||
}
|
||||
if ctx.Value("force") == nil {
|
||||
receivedFile, _ := dao.GetReceivedFileByFileID(file.FileID)
|
||||
if receivedFile != nil && receivedFile.Processing {
|
||||
bot.EditMessageText(&telego.EditMessageTextParams{
|
||||
ChatID: message.Chat.ChatID(),
|
||||
MessageID: msg.MessageID,
|
||||
Text: "该文件或许正在处理中, 使用 /save 命令回复此文件以强制加入队列\n使用 /clean 命令回复此文件以清除对应的记录",
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
if fileName == "" {
|
||||
fileName = filepath.Base(file.FilePath)
|
||||
}
|
||||
|
||||
err = dao.AddReceivedFile(&model.ReceivedFile{
|
||||
FileUniqueID: file.FileUniqueID,
|
||||
FileID: file.FileID,
|
||||
Processing: false,
|
||||
FileName: fileName,
|
||||
FilePath: file.FilePath,
|
||||
FileSize: file.FileSize,
|
||||
MediaGroupID: message.MediaGroupID,
|
||||
ChatID: message.Chat.ChatID().ID,
|
||||
MessageID: message.MessageID,
|
||||
ReplyMessageID: msg.MessageID,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
logger.L.Error(err)
|
||||
ReplyMessage(message, "创建任务失败")
|
||||
return
|
||||
}
|
||||
|
||||
if !user.Silent {
|
||||
bot.EditMessageText(&telego.EditMessageTextParams{
|
||||
ChatID: message.Chat.ChatID(),
|
||||
MessageID: msg.MessageID,
|
||||
Text: "选择要保存的位置",
|
||||
ReplyMarkup: AddTaskReplyMarkup(message.MessageID),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if user.DefaultStorage == "" {
|
||||
bot.EditMessageText(&telego.EditMessageTextParams{
|
||||
ChatID: message.Chat.ChatID(),
|
||||
MessageID: msg.MessageID,
|
||||
Text: "请先使用 /storage 命令设置默认存储位置, 或者关闭静默模式",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
queue.AddTask(types.Task{
|
||||
Ctx: context.TODO(),
|
||||
FileID: file.FileID,
|
||||
Status: types.Pending,
|
||||
FileName: fileName,
|
||||
FilePath: file.FilePath,
|
||||
Storage: types.StorageType(user.DefaultStorage),
|
||||
ChatID: message.Chat.ChatID().ID,
|
||||
ReplyMessageID: msg.MessageID,
|
||||
})
|
||||
}
|
||||
|
||||
func AddToQueue(ctx context.Context, bot *telego.Bot, query telego.CallbackQuery) {
|
||||
args := strings.Split(query.Data, " ")
|
||||
messageID, _ := strconv.Atoi(args[1])
|
||||
receivedFile, err := dao.GetReceivedFileByChatAndMessageID(query.Message.GetChat().ID, messageID)
|
||||
if err != nil {
|
||||
logger.L.Error(err)
|
||||
bot.AnswerCallbackQuery(telegoutil.CallbackQuery(query.ID).WithShowAlert().WithText("获取文件信息失败").WithCacheTime(5))
|
||||
return
|
||||
}
|
||||
queue.AddTask(types.Task{
|
||||
Ctx: context.TODO(),
|
||||
FileID: receivedFile.FileID,
|
||||
Status: types.Pending,
|
||||
FileName: receivedFile.FileName,
|
||||
FilePath: receivedFile.FilePath,
|
||||
Storage: types.StorageType(args[2]),
|
||||
ChatID: receivedFile.ChatID,
|
||||
ReplyMessageID: receivedFile.ReplyMessageID,
|
||||
})
|
||||
bot.EditMessageText(&telego.EditMessageTextParams{
|
||||
Text: fmt.Sprintf("已添加到队列, 当前排队中的任务数: %d", queue.Len()),
|
||||
MessageID: query.Message.GetMessageID(),
|
||||
ChatID: telegoutil.ID(receivedFile.ChatID),
|
||||
})
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
package bot
|
||||
52
bot/utils.go
52
bot/utils.go
@@ -1,52 +0,0 @@
|
||||
package bot
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
|
||||
"github.com/krau/SaveAny-Bot/storage"
|
||||
"github.com/mymmrac/telego"
|
||||
"github.com/mymmrac/telego/telegoutil"
|
||||
)
|
||||
|
||||
func FileDownloadURL(filePath string) string {
|
||||
return Bot.FileDownloadURL(filePath)
|
||||
}
|
||||
|
||||
func ReplyMessage(replyTo telego.Message, format string, args ...any) (*telego.Message, error) {
|
||||
return Bot.SendMessage(telegoutil.Messagef(replyTo.Chat.ChatID(), format, args...).
|
||||
WithReplyParameters(&telego.ReplyParameters{
|
||||
MessageID: replyTo.MessageID,
|
||||
}))
|
||||
}
|
||||
|
||||
var StorageDisplayNames = map[string]string{
|
||||
"all": "全部",
|
||||
"local": "服务器磁盘",
|
||||
"alist": "Alist",
|
||||
"webdav": "WebDAV",
|
||||
}
|
||||
|
||||
func AddTaskReplyMarkup(messageID int) *telego.InlineKeyboardMarkup {
|
||||
storageButtons := make([]telego.InlineKeyboardButton, 0)
|
||||
for name := range storage.Storages {
|
||||
storageButtons = append(storageButtons, telegoutil.InlineKeyboardButton(StorageDisplayNames[string(name)]).
|
||||
WithCallbackData(fmt.Sprintf("add %d %s", messageID, name)))
|
||||
}
|
||||
|
||||
if len(storageButtons) > 1 {
|
||||
return telegoutil.InlineKeyboard(storageButtons, []telego.InlineKeyboardButton{
|
||||
telegoutil.InlineKeyboardButton("全部").WithCallbackData(fmt.Sprintf("add %d all", messageID)),
|
||||
})
|
||||
}
|
||||
if len(storageButtons) == 1 {
|
||||
return telegoutil.InlineKeyboard(storageButtons)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var markdownRe = regexp.MustCompile("([" + regexp.QuoteMeta(`\_*[]()~`+"`"+`>#+-=|{}.!`) + "])")
|
||||
|
||||
func EscapeMarkdown(text string) string {
|
||||
return markdownRe.ReplaceAllString(text, "\\$1")
|
||||
}
|
||||
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
|
||||
}
|
||||
97
client/bot/handlers/add_task.go
Normal file
97
client/bot/handlers/add_task.go
Normal file
@@ -0,0 +1,97 @@
|
||||
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)
|
||||
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
|
||||
}
|
||||
143
client/bot/handlers/config.go
Normal file
143
client/bot/handlers/config.go
Normal file
@@ -0,0 +1,143 @@
|
||||
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/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.FnameSTDisplay[st],
|
||||
}),
|
||||
})
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
opts := fnamest.FnameSTValues()
|
||||
buttons := make([]tg.KeyboardButtonClass, 0, len(opts))
|
||||
for _, opt := range opts {
|
||||
buttons = append(buttons, &tg.KeyboardButtonCallback{
|
||||
Text: fnamest.FnameSTDisplay[opt],
|
||||
Data: fmt.Appendf(nil, "%s %s %s", tcbdata.TypeConfig, "fnamest", opt),
|
||||
})
|
||||
}
|
||||
markup := &tg.ReplyInlineMarkup{Rows: []tg.KeyboardButtonRow{
|
||||
{Buttons: buttons},
|
||||
}}
|
||||
currentStStr := user.FilenameStrategy
|
||||
if currentStStr == "" {
|
||||
currentStStr = fnamest.Default.String()
|
||||
}
|
||||
currentSt, err := fnamest.ParseFnameST(currentStStr)
|
||||
if err != nil {
|
||||
currentSt = fnamest.Default
|
||||
}
|
||||
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
|
||||
ID: update.CallbackQuery.GetMsgID(),
|
||||
Message: i18n.T(i18nk.BotMsgConfigPromptSelectFilenameStrategy, map[string]any{
|
||||
"Strategy": fnamest.FnameSTDisplay[currentSt],
|
||||
}),
|
||||
ReplyMarkup: markup,
|
||||
})
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
|
||||
func handleConfigFnameTmpl(ctx *ext.Context, update *ext.Update) error {
|
||||
userID := update.GetUserChat().GetID()
|
||||
user, err := database.GetUserByChatID(ctx, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
args := strings.Fields(string(update.EffectiveMessage.Text))
|
||||
if len(args) <= 1 {
|
||||
text := 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
|
||||
}
|
||||
52
client/bot/handlers/dl.go
Normal file
52
client/bot/handlers/dl.go
Normal file
@@ -0,0 +1,52 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"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/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
|
||||
}
|
||||
20
client/bot/handlers/help.go
Normal file
20
client/bot/handlers/help.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/celestix/gotgproto/dispatcher"
|
||||
"github.com/celestix/gotgproto/ext"
|
||||
"github.com/krau/SaveAny-Bot/common/i18n"
|
||||
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
|
||||
"github.com/krau/SaveAny-Bot/config"
|
||||
)
|
||||
|
||||
func handleHelpCmd(ctx *ext.Context, update *ext.Update) error {
|
||||
shortHash := config.GitCommit
|
||||
if len(shortHash) > 7 {
|
||||
shortHash = shortHash[:7]
|
||||
}
|
||||
ctx.Reply(update, ext.ReplyTextString(fmt.Sprintf(i18n.T(i18nk.BotMsgHelpTextFmt), config.Version, shortHash)), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
63
client/bot/handlers/link.go
Normal file
63
client/bot/handlers/link.go
Normal file
@@ -0,0 +1,63 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"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
|
||||
}
|
||||
68
client/bot/handlers/register.go
Normal file
68
client/bot/handlers/register.go
Normal file
@@ -0,0 +1,68 @@
|
||||
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},
|
||||
{"task", i18nk.BotMsgCmdTask, handleTaskCmd},
|
||||
{"cancel", i18nk.BotMsgCmdCancel, handleCancelCmd},
|
||||
{"watch", i18nk.BotMsgCmdWatch, handleWatchCmd},
|
||||
{"unwatch", i18nk.BotMsgCmdUnwatch, handleUnwatchCmd},
|
||||
{"lswatch", i18nk.BotMsgCmdLswatch, handleLswatchCmd},
|
||||
{"config", i18nk.BotMsgCmdConfig, handleConfigCmd},
|
||||
{"fnametmpl", i18nk.BotMsgCmdFnametmpl, handleConfigFnameTmpl},
|
||||
{"help", i18nk.BotMsgCmdHelp, handleHelpCmd},
|
||||
{"parser", i18nk.BotMsgCmdParser, handleParserCmd},
|
||||
{"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
|
||||
}
|
||||
188
client/bot/handlers/save.go
Normal file
188
client/bot/handlers/save.go
Normal file
@@ -0,0 +1,188 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/celestix/gotgproto/dispatcher"
|
||||
"github.com/celestix/gotgproto/ext"
|
||||
"github.com/charmbracelet/log"
|
||||
"github.com/gotd/td/tg"
|
||||
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/dirutil"
|
||||
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/mediautil"
|
||||
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/msgelem"
|
||||
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/shortcut"
|
||||
"github.com/krau/SaveAny-Bot/common/i18n"
|
||||
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
|
||||
"github.com/krau/SaveAny-Bot/common/utils/strutil"
|
||||
"github.com/krau/SaveAny-Bot/common/utils/tgutil"
|
||||
"github.com/krau/SaveAny-Bot/database"
|
||||
"github.com/krau/SaveAny-Bot/pkg/tcbdata"
|
||||
"github.com/krau/SaveAny-Bot/pkg/tfile"
|
||||
|
||||
"github.com/krau/SaveAny-Bot/storage"
|
||||
)
|
||||
|
||||
func handleSaveCmd(ctx *ext.Context, update *ext.Update) error {
|
||||
logger := log.FromContext(ctx)
|
||||
args := strings.Split(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
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
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(ctx, 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, ctx.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
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
141
client/bot/handlers/utils/mediautil/media.go
Normal file
141
client/bot/handlers/utils/mediautil/media.go
Normal file
@@ -0,0 +1,141 @@
|
||||
package mediautil
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"text/template"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/log"
|
||||
"github.com/gotd/td/tg"
|
||||
"github.com/krau/SaveAny-Bot/common/utils/strutil"
|
||||
"github.com/krau/SaveAny-Bot/common/utils/tgutil"
|
||||
"github.com/krau/SaveAny-Bot/database"
|
||||
"github.com/krau/SaveAny-Bot/pkg/enums/fnamest"
|
||||
"github.com/krau/SaveAny-Bot/pkg/tfile"
|
||||
)
|
||||
|
||||
func IsSupported(media tg.MessageMediaClass) bool {
|
||||
switch media.(type) {
|
||||
case *tg.MessageMediaDocument, *tg.MessageMediaPhoto:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
type FilenameTemplateData struct {
|
||||
MsgID string `json:"msgid,omitempty"`
|
||||
MsgTags string `json:"msgtags,omitempty"`
|
||||
MsgGen string `json:"msggen,omitempty"`
|
||||
MsgDate string `json:"msgdate,omitempty"`
|
||||
OrigName string `json:"origname,omitempty"`
|
||||
ChatID string `json:"chatid,omitempty"`
|
||||
}
|
||||
|
||||
func (f FilenameTemplateData) ToMap() map[string]string {
|
||||
return map[string]string{
|
||||
"msgid": f.MsgID,
|
||||
"msgtags": f.MsgTags,
|
||||
"msggen": f.MsgGen,
|
||||
"msgdate": f.MsgDate,
|
||||
"origname": f.OrigName,
|
||||
"chatid": f.ChatID,
|
||||
}
|
||||
}
|
||||
|
||||
func TfileOptions(ctx context.Context, user *database.User, message *tg.Message) []tfile.TGFileOption {
|
||||
opts := make([]tfile.TGFileOption, 0)
|
||||
var fnameOpt tfile.TGFileOption
|
||||
switch user.FilenameStrategy {
|
||||
case fnamest.Message.String():
|
||||
fnameOpt = tfile.WithName(tgutil.GenFileNameFromMessage(*message))
|
||||
case fnamest.Template.String():
|
||||
if user.FilenameTemplate == "" {
|
||||
log.FromContext(ctx).Warnf("empty filename template")
|
||||
fnameOpt = tfile.WithNameIfEmpty(tgutil.GenFileNameFromMessage(*message))
|
||||
break
|
||||
}
|
||||
tmpl, err := template.New("filename").Parse(user.FilenameTemplate)
|
||||
if err != nil {
|
||||
log.FromContext(ctx).Errorf("failed to parse filename template: %s", err)
|
||||
fnameOpt = tfile.WithNameIfEmpty(tgutil.GenFileNameFromMessage(*message))
|
||||
break
|
||||
}
|
||||
data := BuildFilenameTemplateData(message)
|
||||
var sb strings.Builder
|
||||
err = tmpl.Execute(&sb, data)
|
||||
if err != nil {
|
||||
log.FromContext(ctx).Errorf("failed to execute filename template: %s", err)
|
||||
fnameOpt = tfile.WithNameIfEmpty(tgutil.GenFileNameFromMessage(*message))
|
||||
break
|
||||
}
|
||||
fnameOpt = tfile.WithName(sb.String())
|
||||
default:
|
||||
fnameOpt = tfile.WithNameIfEmpty(tgutil.GenFileNameFromMessage(*message))
|
||||
}
|
||||
opts = append(opts, fnameOpt, tfile.WithMessage(message))
|
||||
return opts
|
||||
}
|
||||
|
||||
func BuildFilenameTemplateData(message *tg.Message) map[string]string {
|
||||
data := FilenameTemplateData{
|
||||
MsgID: func() string {
|
||||
id := message.GetID()
|
||||
if id == 0 {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("%d", id)
|
||||
}(),
|
||||
MsgTags: func() string {
|
||||
tags := strutil.ExtractTagsFromText(message.GetMessage())
|
||||
if len(tags) == 0 {
|
||||
return ""
|
||||
}
|
||||
return strings.Join(tags, "_")
|
||||
}(),
|
||||
MsgGen: tgutil.GenFileNameFromMessage(*message),
|
||||
OrigName: func() string {
|
||||
f, _ := tgutil.GetMediaFileName(message.Media)
|
||||
return f
|
||||
}(),
|
||||
MsgDate: func() string {
|
||||
date := message.GetDate()
|
||||
if date == 0 {
|
||||
return ""
|
||||
}
|
||||
t := time.Unix(int64(date), 0)
|
||||
return t.Format("2006-01-02_15-04-05")
|
||||
}(),
|
||||
ChatID: func() string {
|
||||
// 如果消息是频道的(从消息链接中fetch的) 直接使用其chat id, 无论它是否是从其他来源转发的
|
||||
if message.GetPost() {
|
||||
peer := message.GetPeerID()
|
||||
switch p := peer.(type) {
|
||||
case *tg.PeerChannel:
|
||||
return intToStringOmitZero(p.ChannelID)
|
||||
default: // impossible case
|
||||
return intToStringOmitZero(tgutil.ChatIdFromPeer(peer))
|
||||
}
|
||||
}
|
||||
fwdHeader, ok := message.GetFwdFrom()
|
||||
if !ok {
|
||||
return intToStringOmitZero(tgutil.ChatIdFromPeer(message.GetPeerID()))
|
||||
}
|
||||
fwdFrom, ok := fwdHeader.GetFromID()
|
||||
if !ok {
|
||||
return intToStringOmitZero(tgutil.ChatIdFromPeer(message.GetPeerID()))
|
||||
}
|
||||
return intToStringOmitZero(tgutil.ChatIdFromPeer(fwdFrom))
|
||||
}(),
|
||||
}.ToMap()
|
||||
return data
|
||||
}
|
||||
|
||||
func intToStringOmitZero(i int64) string {
|
||||
if i == 0 {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("%d", i)
|
||||
}
|
||||
12
client/bot/handlers/utils/msgelem/callback.go
Normal file
12
client/bot/handlers/utils/msgelem/callback.go
Normal file
@@ -0,0 +1,12 @@
|
||||
package msgelem
|
||||
|
||||
import "github.com/gotd/td/tg"
|
||||
|
||||
func AlertCallbackAnswer(queryID int64, text string) *tg.MessagesSetBotCallbackAnswerRequest {
|
||||
return &tg.MessagesSetBotCallbackAnswerRequest{
|
||||
QueryID: queryID,
|
||||
Alert: true,
|
||||
Message: text,
|
||||
CacheTime: 5,
|
||||
}
|
||||
}
|
||||
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 {
|
||||
sb.WriteString(fmt.Sprintf("%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),
|
||||
}
|
||||
}
|
||||
203
client/bot/handlers/utils/msgelem/storage.go
Normal file
203
client/bot/handlers/utils/msgelem/storage.go
Normal file
@@ -0,0 +1,203 @@
|
||||
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,
|
||||
}
|
||||
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, "", ""
|
||||
}
|
||||
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
|
||||
}
|
||||
258
client/bot/handlers/watch.go
Normal file
258
client/bot/handlers/watch.go
Normal file
@@ -0,0 +1,258 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path"
|
||||
"regexp"
|
||||
"strings"
|
||||
"text/template"
|
||||
|
||||
"github.com/celestix/gotgproto/dispatcher"
|
||||
"github.com/celestix/gotgproto/ext"
|
||||
"github.com/charmbracelet/log"
|
||||
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/mediautil"
|
||||
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/ruleutil"
|
||||
userclient "github.com/krau/SaveAny-Bot/client/user"
|
||||
"github.com/krau/SaveAny-Bot/common/i18n"
|
||||
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
|
||||
"github.com/krau/SaveAny-Bot/common/utils/tgutil"
|
||||
"github.com/krau/SaveAny-Bot/core"
|
||||
"github.com/krau/SaveAny-Bot/core/tasks/tfile"
|
||||
"github.com/krau/SaveAny-Bot/database"
|
||||
"github.com/krau/SaveAny-Bot/pkg/enums/fnamest"
|
||||
"github.com/krau/SaveAny-Bot/storage"
|
||||
"github.com/rs/xid"
|
||||
)
|
||||
|
||||
func handleWatchCmd(ctx *ext.Context, update *ext.Update) error {
|
||||
logger := log.FromContext(ctx)
|
||||
args := strings.Split(update.EffectiveMessage.Text, " ")
|
||||
if len(args) < 2 {
|
||||
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgWatchHelpText)), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
userChatID := update.GetUserChat().GetID()
|
||||
user, err := database.GetUserByChatID(ctx, userChatID)
|
||||
if err != nil {
|
||||
logger.Errorf("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
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
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 := tfile.NewTGFileTask(taskid, injectCtx, file, stor, storagePath, nil)
|
||||
if err != nil {
|
||||
logger.Errorf("create task failed: %s", err)
|
||||
continue
|
||||
}
|
||||
if err := core.AddTask(injectCtx, task); err != nil {
|
||||
logger.Errorf("add task failed: %s", err)
|
||||
continue
|
||||
}
|
||||
logger.Infof("Added media message task for user %d in chat %d: %s", chat.UserID, event.ChatID, file.Name())
|
||||
}
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
19
client/middleware/floodwait.go
Normal file
19
client/middleware/floodwait.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/gotd/contrib/middleware/floodwait"
|
||||
"github.com/gotd/contrib/middleware/ratelimit"
|
||||
"github.com/gotd/td/telegram"
|
||||
"golang.org/x/time/rate"
|
||||
)
|
||||
|
||||
func NewFloodWaitMiddlewares(maxRetries uint) []telegram.Middleware {
|
||||
waiter := floodwait.NewSimpleWaiter().WithMaxRetries(maxRetries)
|
||||
ratelimiter := ratelimit.New(rate.Every(time.Millisecond*100), 5)
|
||||
return []telegram.Middleware{
|
||||
waiter,
|
||||
ratelimiter,
|
||||
}
|
||||
}
|
||||
61
client/middleware/recovery/recovery.go
Normal file
61
client/middleware/recovery/recovery.go
Normal file
@@ -0,0 +1,61 @@
|
||||
package recovery
|
||||
|
||||
import (
|
||||
"context"
|
||||
"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, "")
|
||||
}
|
||||
16
cmd/root.go
16
cmd/root.go
@@ -1,21 +1,27 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/krau/SaveAny-Bot/cmd/upload"
|
||||
"github.com/krau/SaveAny-Bot/config"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var rootCmd = &cobra.Command{
|
||||
Use: "saveany-bot",
|
||||
Short: "saveany-bot",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
Run()
|
||||
},
|
||||
Run: Run,
|
||||
}
|
||||
|
||||
func Execute() {
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
func init() {
|
||||
config.RegisterFlags(rootCmd)
|
||||
upload.Register(rootCmd)
|
||||
}
|
||||
|
||||
func Execute(ctx context.Context) {
|
||||
if err := rootCmd.ExecuteContext(ctx); err != nil {
|
||||
fmt.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
106
cmd/run.go
106
cmd/run.go
@@ -1,13 +1,107 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"github.com/krau/SaveAny-Bot/bootstrap"
|
||||
"github.com/krau/SaveAny-Bot/bot"
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"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/core"
|
||||
"github.com/krau/SaveAny-Bot/database"
|
||||
"github.com/krau/SaveAny-Bot/parsers"
|
||||
"github.com/krau/SaveAny-Bot/storage"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func Run() {
|
||||
bootstrap.InitAll()
|
||||
go core.Run()
|
||||
bot.Run()
|
||||
func Run(cmd *cobra.Command, _ []string) {
|
||||
ctx, cancel := context.WithCancel(cmd.Context())
|
||||
logger := log.NewWithOptions(os.Stdout, log.Options{
|
||||
Level: log.DebugLevel,
|
||||
ReportTimestamp: true,
|
||||
TimeFormat: time.TimeOnly,
|
||||
ReportCaller: true,
|
||||
})
|
||||
ctx = log.WithContext(ctx, logger)
|
||||
|
||||
exitChan, err := initAll(ctx, cmd)
|
||||
if err != nil {
|
||||
logger.Fatal("Init failed", "error", err)
|
||||
}
|
||||
go func() {
|
||||
<-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
|
||||
}
|
||||
if config.C().Temp.BasePath != "" && !config.C().Stream {
|
||||
if slices.Contains([]string{"/", ".", "\\", ".."}, filepath.Clean(config.C().Temp.BasePath)) {
|
||||
log.Error("Invalid cache directory", "path", config.C().Temp.BasePath)
|
||||
return
|
||||
}
|
||||
currentDir, err := os.Getwd()
|
||||
if err != nil {
|
||||
log.Error("Failed to get working directory", "error", err)
|
||||
return
|
||||
}
|
||||
cachePath := filepath.Join(currentDir, config.C().Temp.BasePath)
|
||||
cachePath, err = filepath.Abs(cachePath)
|
||||
if err != nil {
|
||||
log.Error("Failed to get absolute cache path", "error", err)
|
||||
return
|
||||
}
|
||||
log.Info("Cleaning cache directory", "path", cachePath)
|
||||
if err := fsutil.RemoveAllInDir(cachePath); err != nil {
|
||||
log.Error("Failed to clean cache directory", "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
128
cmd/upload/cmd.go
Normal file
128
cmd/upload/cmd.go
Normal file
@@ -0,0 +1,128 @@
|
||||
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/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)
|
||||
}
|
||||
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"
|
||||
"runtime"
|
||||
|
||||
"github.com/krau/SaveAny-Bot/common"
|
||||
"github.com/rhysd/go-github-selfupdate/selfupdate"
|
||||
"github.com/krau/SaveAny-Bot/config"
|
||||
"github.com/unvgo/ghselfupdate"
|
||||
|
||||
"github.com/blang/semver"
|
||||
"github.com/spf13/cobra"
|
||||
@@ -16,7 +16,7 @@ var VersionCmd = &cobra.Command{
|
||||
Aliases: []string{"v"},
|
||||
Short: "Print the version number of saveany-bot",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
fmt.Printf("saveany-bot version: %s %s/%s\nBuildTime: %s, Commit: %s\n", common.Version, runtime.GOOS, runtime.GOARCH, common.BuildTime, common.GitCommit)
|
||||
fmt.Printf("saveany-bot version: %s %s/%s\nBuildTime: %s, Commit: %s\n", config.Version, runtime.GOOS, runtime.GOARCH, config.BuildTime, config.GitCommit)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -25,18 +25,33 @@ var upgradeCmd = &cobra.Command{
|
||||
Aliases: []string{"up"},
|
||||
Short: "Upgrade saveany-bot to the latest version",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
v := semver.MustParse(common.Version)
|
||||
latest, err := selfupdate.UpdateSelf(v, "krau/SaveAny-Bot")
|
||||
v := semver.MustParse(config.Version)
|
||||
latest, found, err := ghselfupdate.DetectLatest(config.GitRepo)
|
||||
if err != nil {
|
||||
fmt.Println("Binary update failed:", err)
|
||||
fmt.Println("Error occurred while detecting latest version:", err)
|
||||
return
|
||||
}
|
||||
if latest.Version.Equals(v) {
|
||||
fmt.Println("Current binary is the latest version", common.Version)
|
||||
} else {
|
||||
fmt.Println("Successfully updated to version", latest.Version)
|
||||
fmt.Println("Release note:\n", latest.ReleaseNotes)
|
||||
if !found {
|
||||
fmt.Println("No releases found")
|
||||
return
|
||||
}
|
||||
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)
|
||||
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
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() {
|
||||
initClient()
|
||||
}
|
||||
111
common/i18n/i18n.go
Normal file
111
common/i18n/i18n.go
Normal file
@@ -0,0 +1,111 @@
|
||||
// [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 {
|
||||
panic("localizer or bundle is not initialized, call Init() first")
|
||||
}
|
||||
templateDataMap := make(map[string]any)
|
||||
for _, data := range templateData {
|
||||
maps.Copy(templateDataMap, data)
|
||||
}
|
||||
msg, err := localizer.Localize(&i18n.LocalizeConfig{
|
||||
MessageID: string(key),
|
||||
TemplateData: templateDataMap,
|
||||
})
|
||||
if err != nil {
|
||||
return string(key)
|
||||
}
|
||||
return msg
|
||||
}
|
||||
|
||||
func TWithLang(lang, key string, templateData ...map[string]any) string {
|
||||
if bundle == nil {
|
||||
panic("bundle is not initialized, call Init() first")
|
||||
}
|
||||
templateDataMap := make(map[string]any)
|
||||
for _, data := range templateData {
|
||||
maps.Copy(templateDataMap, data)
|
||||
}
|
||||
localizerWithLang := i18n.NewLocalizer(bundle, lang)
|
||||
msg, err := localizerWithLang.Localize(&i18n.LocalizeConfig{
|
||||
MessageID: key,
|
||||
TemplateData: templateDataMap,
|
||||
})
|
||||
if err != nil {
|
||||
return key
|
||||
}
|
||||
return msg
|
||||
}
|
||||
|
||||
// Only use in tests or packages that load before i18n
|
||||
func TWithoutInit(lang string, key i18nk.Key, templateData ...map[string]any) string {
|
||||
bundle := i18n.NewBundle(language.SimplifiedChinese)
|
||||
bundle.RegisterUnmarshalFunc("yaml", yaml.Unmarshal)
|
||||
files, err := localesFS.ReadDir("locale")
|
||||
if err != nil {
|
||||
return string(key)
|
||||
}
|
||||
for _, file := range files {
|
||||
if _, err := bundle.LoadMessageFileFS(localesFS, "locale/"+file.Name()); err != nil {
|
||||
return string(key)
|
||||
}
|
||||
}
|
||||
localizer := i18n.NewLocalizer(bundle, lang)
|
||||
if localizer == nil {
|
||||
return string(key)
|
||||
}
|
||||
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
|
||||
}
|
||||
234
common/i18n/i18nk/keys.go
Normal file
234
common/i18n/i18nk/keys.go
Normal file
@@ -0,0 +1,234 @@
|
||||
// Code generated by cmd/geni18n. DO NOT EDIT.
|
||||
package i18nk
|
||||
|
||||
type Key string
|
||||
|
||||
const (
|
||||
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"
|
||||
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"
|
||||
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"
|
||||
BotMsgCmdTask Key = "bot.msg.cmd.task"
|
||||
BotMsgCmdUnwatch Key = "bot.msg.cmd.unwatch"
|
||||
BotMsgCmdUpdate Key = "bot.msg.cmd.update"
|
||||
BotMsgCmdWatch Key = "bot.msg.cmd.watch"
|
||||
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"
|
||||
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"
|
||||
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"
|
||||
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"
|
||||
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"
|
||||
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"
|
||||
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"
|
||||
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"
|
||||
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"
|
||||
)
|
||||
325
common/i18n/locale/en.yaml
Normal file
325
common/i18n/locale/en.yaml
Normal file
@@ -0,0 +1,325 @@
|
||||
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
|
||||
/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)
|
||||
/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"
|
||||
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"
|
||||
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
|
||||
- {{"{{.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"
|
||||
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: "
|
||||
325
common/i18n/locale/zh-Hans.yaml
Normal file
325
common/i18n/locale/zh-Hans.yaml
Normal file
@@ -0,0 +1,325 @@
|
||||
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 [自定义文件名] - 保存文件
|
||||
/dir - 管理存储目录
|
||||
/rule - 管理规则
|
||||
/config - 修改配置
|
||||
/fnametmpl - 设置文件自定义命名模板
|
||||
/parser - 管理解析器插件
|
||||
/task - 管理任务队列
|
||||
/watch - 监听聊天并自动保存 (UserBot)
|
||||
/unwatch - 取消监听聊天 (UserBot)
|
||||
/lswatch - 列出正在监听的聊天 (UserBot)
|
||||
/update - 检查更新并升级
|
||||
|
||||
使用帮助: https://sabot.unv.app/usage
|
||||
cmd:
|
||||
start: "开始使用"
|
||||
silent: "切换静默模式"
|
||||
storage: "设置默认存储端"
|
||||
dir: "管理存储文件夹"
|
||||
rule: "管理自动存储规则"
|
||||
save: "保存文件"
|
||||
dl: "下载给定链接的文件"
|
||||
task: "管理任务队列"
|
||||
cancel: "取消任务"
|
||||
watch: "监听聊天(UserBot)"
|
||||
unwatch: "取消监听聊天(UserBot)"
|
||||
lswatch: "列出监听的聊天(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
|
||||
- {{"{{.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}} 个文件, 请选择存储位置"
|
||||
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错误: "
|
||||
71
common/os.go
71
common/os.go
@@ -1,71 +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)
|
||||
}
|
||||
|
||||
func FileExists(path string) bool {
|
||||
_, err := os.Stat(path)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// 删除文件, 并清理空目录. 如果文件不存在则返回 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 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
|
||||
}
|
||||
|
||||
// 在指定时间后删除和清理文件 (定时器)
|
||||
func PurgeFileAfter(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("Purge file after %s: %s", td, path)
|
||||
time.AfterFunc(td, func() {
|
||||
PurgeFile(path)
|
||||
})
|
||||
}
|
||||
|
||||
func MkCache(path string, data []byte, td time.Duration) {
|
||||
if err := MkFile(path, data); err != nil {
|
||||
logger.L.Errorf("failed to save cache file: %s", err)
|
||||
} else {
|
||||
go PurgeFileAfter(path, td)
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
|
||||
"github.com/imroc/req/v3"
|
||||
"github.com/krau/SaveAny-Bot/config"
|
||||
)
|
||||
|
||||
var ReqClient *req.Client
|
||||
|
||||
func initClient() {
|
||||
ReqClient = req.NewClient().SetOutputDirectory(config.Cfg.Temp.BasePath)
|
||||
}
|
||||
|
||||
func GetDownloadedFilePath(filename string) string {
|
||||
return filepath.Join(config.Cfg.Temp.BasePath, filename)
|
||||
}
|
||||
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))
|
||||
}
|
||||
33
common/utils/dlutil/dl.go
Normal file
33
common/utils/dlutil/dl.go
Normal file
@@ -0,0 +1,33 @@
|
||||
package dlutil
|
||||
|
||||
import "time"
|
||||
|
||||
var threadsLevels = []struct {
|
||||
threads int
|
||||
size int64
|
||||
}{
|
||||
{1, 10 << 20},
|
||||
{2, 50 << 20},
|
||||
{4, 200 << 20},
|
||||
{8, 500 << 20},
|
||||
}
|
||||
|
||||
func BestThreads(size int64, max int) int {
|
||||
for _, thread := range threadsLevels {
|
||||
if size < thread.size {
|
||||
return min(thread.threads, max)
|
||||
}
|
||||
}
|
||||
return max
|
||||
}
|
||||
|
||||
func GetSpeed(downloaded int64, startTime time.Time) float64 {
|
||||
if startTime.IsZero() {
|
||||
return 0
|
||||
}
|
||||
elapsed := time.Since(startTime).Seconds()
|
||||
if elapsed <= 0 {
|
||||
return 0
|
||||
}
|
||||
return float64(downloaded) / elapsed
|
||||
}
|
||||
77
common/utils/fsutil/fs.go
Normal file
77
common/utils/fsutil/fs.go
Normal file
@@ -0,0 +1,77 @@
|
||||
package fsutil
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"github.com/gabriel-vasile/mimetype"
|
||||
)
|
||||
|
||||
// 删除文件夹内的所有文件和子目录, 但不删除文件夹本身
|
||||
func RemoveAllInDir(dirPath string) error {
|
||||
entries, err := os.ReadDir(dirPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, entry := range entries {
|
||||
entryPath := filepath.Join(dirPath, entry.Name())
|
||||
if err := os.RemoveAll(entryPath); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func DetectFileExt(fp string) string {
|
||||
mt, err := mimetype.DetectFile(fp)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return mt.Extension()
|
||||
}
|
||||
|
||||
type File struct {
|
||||
*os.File
|
||||
}
|
||||
|
||||
func (f *File) Remove() error {
|
||||
return os.Remove(f.Name())
|
||||
}
|
||||
|
||||
func (f *File) CloseAndRemove() error {
|
||||
if err := f.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return f.Remove()
|
||||
}
|
||||
|
||||
func CreateFile(fp string) (*File, error) {
|
||||
if err := os.MkdirAll(filepath.Dir(fp), os.ModePerm); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
file, err := os.Create(fp)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &File{File: file}, nil
|
||||
}
|
||||
|
||||
func NormalizePathname(s string) string {
|
||||
specials := `\/:*?"<>|` + "\n\r\t"
|
||||
var builder strings.Builder
|
||||
for _, ch := range s {
|
||||
if strings.ContainsRune(specials, ch) || unicode.IsControl(ch) {
|
||||
builder.WriteRune('_')
|
||||
} else {
|
||||
builder.WriteRune(ch)
|
||||
}
|
||||
}
|
||||
|
||||
result := strings.TrimRightFunc(builder.String(), func(r rune) bool {
|
||||
return r == '.' || r == '_' || unicode.IsSpace(r)
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
46
common/utils/fsutil/normalize_pathname_test.go
Normal file
46
common/utils/fsutil/normalize_pathname_test.go
Normal file
@@ -0,0 +1,46 @@
|
||||
package fsutil_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/krau/SaveAny-Bot/common/utils/fsutil"
|
||||
)
|
||||
|
||||
func TestNormalizePathname(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
input: "hello/world?.txt ",
|
||||
expected: "hello_world_.txt",
|
||||
},
|
||||
{
|
||||
input: "bad|name:\nfile\r.",
|
||||
expected: "bad_name__file",
|
||||
},
|
||||
{
|
||||
input: "normal.txt",
|
||||
expected: "normal.txt",
|
||||
},
|
||||
{
|
||||
input: "test.... ",
|
||||
expected: "test",
|
||||
},
|
||||
{
|
||||
input: "abc<>def",
|
||||
expected: "abc__def",
|
||||
},
|
||||
{
|
||||
input: "with\tcontrol",
|
||||
expected: "with_control",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
got := fsutil.NormalizePathname(tc.input)
|
||||
if got != tc.expected {
|
||||
t.Errorf("NormalizePathname(%q) = %q; want %q", tc.input, got, tc.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
410
common/utils/tgutil/message.go
Normal file
410
common/utils/tgutil/message.go
Normal file
@@ -0,0 +1,410 @@
|
||||
package tgutil
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode"
|
||||
"unicode/utf16"
|
||||
|
||||
"github.com/celestix/gotgproto/ext"
|
||||
"github.com/duke-git/lancet/v2/maputil"
|
||||
"github.com/duke-git/lancet/v2/mathutil"
|
||||
"github.com/duke-git/lancet/v2/slice"
|
||||
lcstrutil "github.com/duke-git/lancet/v2/strutil"
|
||||
"github.com/duke-git/lancet/v2/validator"
|
||||
"github.com/gabriel-vasile/mimetype"
|
||||
"github.com/gotd/td/constant"
|
||||
"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/common/utils/strutil"
|
||||
"github.com/rs/xid"
|
||||
)
|
||||
|
||||
// generate a file name from the message content and media type
|
||||
//
|
||||
// it will never return an empty string
|
||||
func GenFileNameFromMessage(message tg.Message) string {
|
||||
ext := func(media tg.MessageMediaClass) string {
|
||||
switch media := media.(type) {
|
||||
case *tg.MessageMediaDocument:
|
||||
doc, ok := media.Document.AsNotEmpty()
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
mmt := mimetype.Lookup(doc.MimeType)
|
||||
if mmt == nil || mmt.Extension() == "" {
|
||||
return ""
|
||||
}
|
||||
return mmt.Extension()
|
||||
case *tg.MessageMediaPhoto:
|
||||
return ".jpg"
|
||||
}
|
||||
return ""
|
||||
}(message.Media)
|
||||
text := strings.TrimSpace(message.GetMessage())
|
||||
if text == "" {
|
||||
return fmt.Sprintf("%d_%s%s", message.GetID(), xid.New().String(), ext)
|
||||
}
|
||||
filename := func() string {
|
||||
tags := strutil.ExtractTagsFromText(text)
|
||||
if len(tags) > 0 {
|
||||
tagStrRunes := make([]rune, 0, 64)
|
||||
for i, tag := range tags {
|
||||
if i > 0 {
|
||||
tagStrRunes = append(tagStrRunes, '_')
|
||||
}
|
||||
tagStrRunes = append(tagStrRunes, []rune(tag)...)
|
||||
if len(tagStrRunes) >= 64 {
|
||||
break
|
||||
}
|
||||
}
|
||||
tagStr := string(tagStrRunes)
|
||||
return fmt.Sprintf("%s_%s", tagStr, strconv.Itoa(message.GetID()))
|
||||
}
|
||||
text = lcstrutil.Substring(strings.Map(func(r rune) rune {
|
||||
switch r {
|
||||
case '/', '\\',
|
||||
':', '*', '?', '"', '<', '>', '|':
|
||||
return '_'
|
||||
}
|
||||
if unicode.IsControl(r) || unicode.IsSpace(r) {
|
||||
return '_'
|
||||
}
|
||||
if validator.IsPrintable(string(r)) {
|
||||
return r
|
||||
}
|
||||
return '_'
|
||||
}, text), 0, 64)
|
||||
text = strings.Join(strings.FieldsFunc(text, func(r rune) bool {
|
||||
return r == '_' || r == ' '
|
||||
}), "_")
|
||||
return text
|
||||
}()
|
||||
|
||||
if filename == "" {
|
||||
mname, err := GetMediaFileName(message.Media)
|
||||
if err != nil {
|
||||
filename = fmt.Sprintf("%d_%s", message.GetID(), xid.New().String())
|
||||
} else {
|
||||
filename = mname
|
||||
}
|
||||
|
||||
}
|
||||
return filename + ext
|
||||
}
|
||||
|
||||
func BuildCancelButton(taskID string) tg.KeyboardButtonClass {
|
||||
return &tg.KeyboardButtonCallback{
|
||||
Text: i18n.T(i18nk.BotMsgCommonCancelButtonText, nil),
|
||||
Data: fmt.Appendf(nil, "cancel %s", taskID),
|
||||
}
|
||||
}
|
||||
|
||||
func InputMessageClassSliceFromInt(ids []int) []tg.InputMessageClass {
|
||||
result := make([]tg.InputMessageClass, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
result = append(result, &tg.InputMessageID{
|
||||
ID: id,
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func GetMessagesRange(ctx *ext.Context, chatID int64, minId, maxId int) ([]*tg.Message, error) {
|
||||
if msg, err := getMessagesRange(ctx, chatID, minId, maxId); err == nil {
|
||||
return msg, nil
|
||||
}
|
||||
in := constant.TDLibPeerID(chatID)
|
||||
plain := in.ToPlain()
|
||||
|
||||
var channel constant.TDLibPeerID
|
||||
channel.Channel(plain)
|
||||
if msg, err := getMessagesRange(ctx, int64(channel), minId, maxId); err == nil {
|
||||
return msg, nil
|
||||
}
|
||||
var userID constant.TDLibPeerID
|
||||
userID.User(plain)
|
||||
if msg, err := getMessagesRange(ctx, int64(userID), minId, maxId); err == nil {
|
||||
return msg, nil
|
||||
}
|
||||
var chat constant.TDLibPeerID
|
||||
chat.Chat(plain)
|
||||
if msg, err := getMessagesRange(ctx, int64(chat), minId, maxId); err == nil {
|
||||
return msg, nil
|
||||
}
|
||||
return nil, fmt.Errorf("failed to get messages range for chatID %d", chatID)
|
||||
}
|
||||
|
||||
func getMessagesRange(ctx *ext.Context, chatID int64, minId, maxId int) ([]*tg.Message, error) {
|
||||
if minId > maxId {
|
||||
return nil, fmt.Errorf("minId (%d) cannot be greater than maxId (%d)", minId, maxId)
|
||||
}
|
||||
total := maxId - minId + 1
|
||||
msgIds := mathutil.Range(minId, total)
|
||||
toFetchIds := make([]int, 0, total)
|
||||
cached := make(map[int]*tg.Message, total)
|
||||
for _, id := range msgIds {
|
||||
if msg, ok := cache.Get[*tg.Message](fmt.Sprintf("tgmsg:%d:%d:%d", ctx.Self.ID, chatID, id)); ok {
|
||||
cached[id] = msg
|
||||
} else {
|
||||
toFetchIds = append(toFetchIds, id)
|
||||
}
|
||||
}
|
||||
if len(toFetchIds) == 0 {
|
||||
return maputil.Values(cached), nil
|
||||
}
|
||||
|
||||
result := make([]*tg.Message, 0, total)
|
||||
chunks := slice.Chunk(toFetchIds, 100)
|
||||
for _, chunk := range chunks {
|
||||
msgs, err := ctx.GetMessages(chatID, InputMessageClassSliceFromInt(chunk))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(msgs) == 0 {
|
||||
continue
|
||||
}
|
||||
for _, msg := range msgs {
|
||||
if msg == nil {
|
||||
continue
|
||||
}
|
||||
tgMessage, ok := msg.(*tg.Message)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if tgMessage.GetID() < minId || tgMessage.GetID() > maxId {
|
||||
continue
|
||||
}
|
||||
result = append(result, tgMessage)
|
||||
}
|
||||
}
|
||||
|
||||
for _, msg := range result {
|
||||
cache.Set(fmt.Sprintf("tgmsg:%d:%d:%d", ctx.Self.ID, chatID, msg.GetID()), msg)
|
||||
}
|
||||
for _, msg := range cached {
|
||||
if msg == nil {
|
||||
continue
|
||||
}
|
||||
result = append(result, msg)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// [TODO]
|
||||
// type MessageItem struct {
|
||||
// Message *tg.Message
|
||||
// Error error
|
||||
// }
|
||||
|
||||
// func IterMessages(ctx *ext.Context, chatID int64, minId, maxId int) (<-chan MessageItem, error) {
|
||||
// total := maxId - minId + 1
|
||||
// ch := make(chan MessageItem, 100)
|
||||
|
||||
// go func() {
|
||||
// defer close(ch)
|
||||
// if !ctx.Self.Bot {
|
||||
// perr := ctx.PeerStorage.GetInputPeerById(chatID)
|
||||
// if perr == nil || perr.(*tg.InputPeerEmpty) != nil {
|
||||
// ch <- MessageItem{
|
||||
// Error: fmt.Errorf("peer not found: %d", chatID),
|
||||
// }
|
||||
// return
|
||||
// }
|
||||
|
||||
// for i := 0; i < total; i += 100 {
|
||||
// start := minId + i
|
||||
// end := min(start+100, maxId)
|
||||
// msgs, err := ctx.Raw.MessagesGetHistory(ctx, &tg.MessagesGetHistoryRequest{
|
||||
// Peer: perr,
|
||||
// OffsetID: start,
|
||||
// AddOffset: start - end,
|
||||
// Limit: 100,
|
||||
// })
|
||||
// if err != nil {
|
||||
// ch <- MessageItem{
|
||||
// Error: fmt.Errorf("failed to get messages: %w", err),
|
||||
// }
|
||||
// return
|
||||
// }
|
||||
// var msgClass []tg.MessageClass
|
||||
// switch msgsv := msgs.(type) {
|
||||
// case *tg.MessagesMessages:
|
||||
// msgClass = msgsv.GetMessages()
|
||||
// case *tg.MessagesMessagesSlice:
|
||||
// msgClass = msgsv.GetMessages()
|
||||
// case *tg.MessagesChannelMessages:
|
||||
// msgClass = msgsv.GetMessages()
|
||||
// default:
|
||||
// ch <- MessageItem{
|
||||
// Error: fmt.Errorf("unsupported message type: %T", msgsv),
|
||||
// }
|
||||
// continue
|
||||
// }
|
||||
// for _, msg := range msgClass {
|
||||
// msg, ok := msg.AsNotEmpty()
|
||||
// if !ok {
|
||||
// continue
|
||||
// }
|
||||
// switch msg := msg.(type) {
|
||||
// case *tg.Message:
|
||||
// key := fmt.Sprintf("tgmsg:%d:%d:%d", ctx.Self.ID, chatID, msg.GetID())
|
||||
// cache.Set(key, msg)
|
||||
// ch <- MessageItem{
|
||||
// Message: msg,
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// } else {
|
||||
// for i := 0; i < total; i += 100 {
|
||||
// start := minId + i
|
||||
// end := min(start+100, maxId)
|
||||
// msgs, err := GetMessagesRange(ctx, chatID, start, end)
|
||||
// if err != nil {
|
||||
// ch <- MessageItem{
|
||||
// Error: fmt.Errorf("failed to get messages: %w", err),
|
||||
// }
|
||||
// return
|
||||
// }
|
||||
// for _, msg := range msgs {
|
||||
// if msg == nil {
|
||||
// continue
|
||||
// }
|
||||
// ch <- MessageItem{
|
||||
// Message: msg,
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }()
|
||||
|
||||
// return ch, nil
|
||||
// }
|
||||
|
||||
func getMessageByID(ctx *ext.Context, chatID int64, msgID int) (*tg.Message, error) {
|
||||
key := fmt.Sprintf("tgmsg:%d:%d:%d", ctx.Self.ID, chatID, msgID)
|
||||
if msg, ok := cache.Get[*tg.Message](key); ok {
|
||||
return msg, nil
|
||||
}
|
||||
msgs, err := ctx.GetMessages(chatID, []tg.InputMessageClass{
|
||||
&tg.InputMessageID{ID: msgID},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get message by ID: %w", err)
|
||||
}
|
||||
if len(msgs) == 0 {
|
||||
return nil, fmt.Errorf("message not found: chatID=%d, msgID=%d", chatID, msgID)
|
||||
}
|
||||
msg := msgs[0]
|
||||
tgm, ok := msg.(*tg.Message)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unexpected message type: %T", msg)
|
||||
}
|
||||
cache.Set(key, tgm)
|
||||
return tgm, nil
|
||||
}
|
||||
|
||||
// f**k gotgproto's breaking changes
|
||||
func GetMessageByID(ctx *ext.Context, chatID int64, msgID int) (*tg.Message, error) {
|
||||
// we don't know what the input chatID is bot api style(e.g. channel with -100 prefix) or plain tdlib style(no any prefix and every id is positive)
|
||||
if msg, err := getMessageByID(ctx, chatID, msgID); err == nil {
|
||||
return msg, nil
|
||||
}
|
||||
in := constant.TDLibPeerID(chatID)
|
||||
plain := in.ToPlain()
|
||||
var channel constant.TDLibPeerID
|
||||
channel.Channel(plain)
|
||||
if msg, err := getMessageByID(ctx, int64(channel), msgID); err == nil {
|
||||
return msg, nil
|
||||
}
|
||||
var chat constant.TDLibPeerID
|
||||
chat.Chat(plain)
|
||||
if msg, err := getMessageByID(ctx, int64(chat), msgID); err == nil {
|
||||
return msg, nil
|
||||
}
|
||||
var userID constant.TDLibPeerID
|
||||
userID.User(plain)
|
||||
if msg, err := getMessageByID(ctx, int64(userID), msgID); err == nil {
|
||||
return msg, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("failed to get message by ID: chatID=%d, msgID=%d", chatID, msgID)
|
||||
}
|
||||
|
||||
func GetGroupedMessages(ctx *ext.Context, chatID int64, msg *tg.Message) ([]*tg.Message, error) {
|
||||
groupID, isGroup := msg.GetGroupedID()
|
||||
if !isGroup || groupID == 0 {
|
||||
return nil, fmt.Errorf("message %d is not grouped", msg.GetID())
|
||||
}
|
||||
msgID := msg.GetID()
|
||||
minID := msgID - 10
|
||||
maxID := msgID + 10
|
||||
if minID < 1 {
|
||||
minID = 1
|
||||
}
|
||||
msgs, err := GetMessagesRange(ctx, chatID, minID, maxID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
groupedMessages := make([]*tg.Message, 0, len(msgs))
|
||||
for _, m := range msgs {
|
||||
if m == nil {
|
||||
continue
|
||||
}
|
||||
mgid, isGroup := m.GetGroupedID()
|
||||
if isGroup && mgid == groupID {
|
||||
groupedMessages = append(groupedMessages, m)
|
||||
}
|
||||
}
|
||||
return groupedMessages, nil
|
||||
}
|
||||
|
||||
func ExtractMessageEntityUrls(msg *tg.Message) []string {
|
||||
if len(msg.Entities) == 0 {
|
||||
return nil
|
||||
}
|
||||
msgText := msg.GetMessage()
|
||||
if msgText == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
runes := []rune(msgText)
|
||||
utf16Codes := utf16.Encode(runes)
|
||||
|
||||
var urls []string
|
||||
for _, entity := range msg.Entities {
|
||||
switch ent := entity.(type) {
|
||||
case *tg.MessageEntityTextURL:
|
||||
urls = append(urls, ent.GetURL())
|
||||
case *tg.MessageEntityURL:
|
||||
start := ent.Offset
|
||||
end := ent.Offset + ent.Length
|
||||
if start < 0 || end > len(utf16Codes) {
|
||||
continue
|
||||
}
|
||||
subRunes := utf16.Decode(utf16Codes[start:end])
|
||||
urls = append(urls, string(subRunes))
|
||||
}
|
||||
}
|
||||
return urls
|
||||
}
|
||||
|
||||
func ExtractMessageEntityUrlsText(msg *tg.Message) string {
|
||||
if msg == nil {
|
||||
return ""
|
||||
}
|
||||
urls := ExtractMessageEntityUrls(msg)
|
||||
if len(urls) == 0 {
|
||||
return msg.GetMessage()
|
||||
}
|
||||
var sb strings.Builder
|
||||
for _, url := range urls {
|
||||
sb.WriteString(url)
|
||||
sb.WriteString(" ")
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
41
common/utils/tgutil/net.go
Normal file
41
common/utils/tgutil/net.go
Normal file
@@ -0,0 +1,41 @@
|
||||
package tgutil
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
|
||||
"github.com/gotd/td/telegram/dcs"
|
||||
"github.com/krau/SaveAny-Bot/config"
|
||||
"golang.org/x/net/proxy"
|
||||
)
|
||||
|
||||
func newProxyDialer(proxyUrl string) (proxy.Dialer, error) {
|
||||
url, err := url.Parse(proxyUrl)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return proxy.FromURL(url, proxy.Direct)
|
||||
}
|
||||
|
||||
func NewConfigProxyResolver() (dcs.Resolver, error) {
|
||||
resolver := dcs.DefaultResolver()
|
||||
if config.C().Proxy != "" {
|
||||
// gloabl proxy, which has lower priority
|
||||
dialer, err := newProxyDialer(config.C().Proxy)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resolver = dcs.Plain(dcs.PlainOptions{
|
||||
Dial: dialer.(proxy.ContextDialer).DialContext,
|
||||
})
|
||||
}
|
||||
if config.C().Telegram.Proxy.Enable && config.C().Telegram.Proxy.URL != "" {
|
||||
dialer, err := newProxyDialer(config.C().Telegram.Proxy.URL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resolver = dcs.Plain(dcs.PlainOptions{
|
||||
Dial: dialer.(proxy.ContextDialer).DialContext,
|
||||
})
|
||||
}
|
||||
return resolver, nil
|
||||
}
|
||||
16
common/utils/tgutil/peer.go
Normal file
16
common/utils/tgutil/peer.go
Normal file
@@ -0,0 +1,16 @@
|
||||
package tgutil
|
||||
|
||||
import "github.com/gotd/td/tg"
|
||||
|
||||
func ChatIdFromPeer(peer tg.PeerClass) int64 {
|
||||
switch peer := peer.(type) {
|
||||
case *tg.PeerChannel:
|
||||
return peer.ChannelID
|
||||
case *tg.PeerUser:
|
||||
return peer.UserID
|
||||
case *tg.PeerChat:
|
||||
return peer.ChatID
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
119
common/utils/tgutil/resolve.go
Normal file
119
common/utils/tgutil/resolve.go
Normal file
@@ -0,0 +1,119 @@
|
||||
package tgutil
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/celestix/gotgproto/ext"
|
||||
"github.com/duke-git/lancet/v2/validator"
|
||||
"github.com/gotd/td/tg"
|
||||
)
|
||||
|
||||
func ParseChatID(ctx *ext.Context, idOrUsername string) (int64, error) {
|
||||
idOrUsername = strings.TrimPrefix(idOrUsername, "@")
|
||||
if validator.IsIntStr(idOrUsername) {
|
||||
chatID, err := strconv.Atoi(idOrUsername)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return int64(chatID), nil
|
||||
}
|
||||
username := idOrUsername
|
||||
peer := ctx.PeerStorage.GetPeerByUsername(username)
|
||||
if peer != nil && peer.ID != 0 {
|
||||
return peer.ID, nil
|
||||
}
|
||||
chat, err := ctx.ResolveUsername(username)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if chat == nil {
|
||||
return 0, fmt.Errorf("no chat found for username: %s", idOrUsername)
|
||||
}
|
||||
chatID := chat.GetID()
|
||||
if chatID == 0 {
|
||||
return 0, fmt.Errorf("chat ID is zero for username: %s", idOrUsername)
|
||||
}
|
||||
return chatID, nil
|
||||
}
|
||||
|
||||
// return: ChatID, MessageID, error
|
||||
func ParseMessageLink(ctx *ext.Context, link string) (int64, int, error) {
|
||||
u, err := url.Parse(link)
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("invalid URL: %w", err)
|
||||
}
|
||||
paths := strings.Split(strings.TrimPrefix(u.Path, "/"), "/")
|
||||
|
||||
if cmt := u.Query().Get("comment"); cmt != "" {
|
||||
// 频道评论的消息链接
|
||||
// https://t.me/acherkrau/123?comment=2
|
||||
chid, err := ParseChatID(ctx, paths[0])
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("failed to parse chat ID: %w", err)
|
||||
}
|
||||
chatfull, err := ctx.GetChat(chid)
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("failed to get chat: %w", err)
|
||||
}
|
||||
chfull, ok := chatfull.(*tg.ChannelFull)
|
||||
if !ok {
|
||||
return 0, 0, fmt.Errorf("chat is not a channel: %s", chatfull.TypeName())
|
||||
}
|
||||
linkChatId, ok := chfull.GetLinkedChatID()
|
||||
if !ok {
|
||||
return 0, 0, fmt.Errorf("channel has no linked chat")
|
||||
}
|
||||
msgID, err := strconv.Atoi(cmt)
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("failed to parse comment ID: %w", err)
|
||||
}
|
||||
return linkChatId, msgID, nil
|
||||
}
|
||||
|
||||
switch len(paths) {
|
||||
case 2: // https://t.me/acherkrau/123
|
||||
chatID, err := ParseChatID(ctx, paths[0])
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("failed to parse chat ID: %w", err)
|
||||
}
|
||||
msgID, err := strconv.Atoi(paths[1])
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("failed to parse message ID: %w", err)
|
||||
}
|
||||
return chatID, msgID, nil
|
||||
case 3:
|
||||
// https://t.me/c/123456789/123
|
||||
// https://t.me/acherkrau/123/456 , 123: topic id
|
||||
chatPart, msgPart := paths[1], paths[2]
|
||||
if paths[0] != "c" {
|
||||
chatPart = paths[0]
|
||||
}
|
||||
chatID, err := ParseChatID(ctx, chatPart)
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("failed to parse chat ID: %w", err)
|
||||
}
|
||||
msgID, err := strconv.Atoi(msgPart)
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("failed to parse message ID: %w", err)
|
||||
}
|
||||
return chatID, msgID, nil
|
||||
case 4:
|
||||
// https://t.me/c/123456789/111/456 111: topic id
|
||||
if paths[0] != "c" {
|
||||
return 0, 0, fmt.Errorf("invalid message link format: %s", link)
|
||||
}
|
||||
chatID, err := ParseChatID(ctx, paths[1])
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("failed to parse chat ID: %w", err)
|
||||
}
|
||||
msgID, err := strconv.Atoi(paths[3])
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("failed to parse message ID: %w", err)
|
||||
}
|
||||
return chatID, msgID, nil
|
||||
}
|
||||
return 0, 0, fmt.Errorf("invalid message link format: %s", link)
|
||||
}
|
||||
65
common/utils/tphutil/tph.go
Normal file
65
common/utils/tphutil/tph.go
Normal file
@@ -0,0 +1,65 @@
|
||||
package tphutil
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/krau/SaveAny-Bot/config"
|
||||
"github.com/krau/SaveAny-Bot/pkg/telegraph"
|
||||
)
|
||||
|
||||
var (
|
||||
tphClient *telegraph.Client
|
||||
once sync.Once
|
||||
)
|
||||
|
||||
func DefaultClient() *telegraph.Client {
|
||||
once.Do(func() {
|
||||
tphClient = initDefault()
|
||||
})
|
||||
return tphClient
|
||||
}
|
||||
|
||||
func initDefault() *telegraph.Client {
|
||||
var client *telegraph.Client
|
||||
if config.C().Telegram.Proxy.Enable && config.C().Telegram.Proxy.URL != "" {
|
||||
proxyUrl := config.C().Telegram.Proxy.URL
|
||||
var err error
|
||||
client, err = telegraph.NewClientWithProxy(proxyUrl)
|
||||
if err != nil {
|
||||
client = telegraph.NewClient()
|
||||
}
|
||||
} else {
|
||||
client = telegraph.NewClient()
|
||||
}
|
||||
return client
|
||||
}
|
||||
|
||||
func GetNodeImages(node telegraph.Node) []string {
|
||||
var srcs []string
|
||||
|
||||
var nodeElement telegraph.NodeElement
|
||||
data, err := json.Marshal(node)
|
||||
if err != nil {
|
||||
return srcs
|
||||
}
|
||||
err = json.Unmarshal(data, &nodeElement)
|
||||
if err != nil {
|
||||
return srcs
|
||||
}
|
||||
|
||||
if nodeElement.Tag == "img" {
|
||||
if src, exists := nodeElement.Attrs["src"]; exists {
|
||||
if strings.HasPrefix(src, "/file/") {
|
||||
// handle images on telegra.ph server
|
||||
src = "https://telegra.ph" + src
|
||||
}
|
||||
srcs = append(srcs, src)
|
||||
}
|
||||
}
|
||||
for _, child := range nodeElement.Children {
|
||||
srcs = append(srcs, GetNodeImages(child)...)
|
||||
}
|
||||
return srcs
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
package common
|
||||
|
||||
var (
|
||||
Version string = "dev"
|
||||
BuildTime string = "unknown"
|
||||
GitCommit string = "unknown"
|
||||
)
|
||||
@@ -1,34 +1,54 @@
|
||||
# 创建文件时,若需要保留中文注释,请务必确保本文件编码为 UTF-8 ,否则会无法读取。
|
||||
# 更详细的配置请在 https://sabot.unv.app/deployment/configuration 查看
|
||||
workers = 4 # 同时下载文件数
|
||||
retry = 3 # 下载失败重试次数
|
||||
threads = 4 # 单个任务下载使用的最大线程数
|
||||
stream = false # 使用流式传输模式, 建议仅在硬盘空间十分有限时使用.
|
||||
|
||||
[telegram]
|
||||
token = "" # Bot Token
|
||||
admins = [777000] # 你的 user_id
|
||||
api = "https://api.telegram.org"
|
||||
# Bot Token
|
||||
# 更换 Bot Token 后请删除会话数据库文件 (默认路径为 data/session.db )
|
||||
token = ""
|
||||
# Telegram API 配置, 若不配置也可运行, 将使用默认的 API ID 和 API HASH
|
||||
# 推荐使用自己的 API ID 和 API HASH (https://my.telegram.org)
|
||||
# app_id = 1025907
|
||||
# app_hash = "452b0359b988148995f22ff0f4229750"
|
||||
[telegram.proxy]
|
||||
# 启用代理连接 telegram, 只支持 socks5
|
||||
enable = false
|
||||
url = "socks5://127.0.0.1:7890"
|
||||
|
||||
[log]
|
||||
level = "DEBUG" # 日志等级
|
||||
|
||||
[temp]
|
||||
base_path = "cache/" # 临时目录, 请不要在此目录下存放任何其他文件
|
||||
cache_ttl = 30 # 临时文件保存时间, 单位: 秒
|
||||
|
||||
[db]
|
||||
path = "data/data.db" # 数据库文件路径
|
||||
|
||||
[storage]
|
||||
[storage.alist] # Alist
|
||||
# 存储列表
|
||||
[[storages]]
|
||||
# 标识名, 需要唯一
|
||||
name = "本机1"
|
||||
# 存储类型, 目前可用: local, alist, webdav, s3, telegram
|
||||
type = "local"
|
||||
# 启用存储
|
||||
enable = true
|
||||
base_path = "/telegram" # 保存路径
|
||||
username = "admin" # 用户名
|
||||
password = "password" # 密码
|
||||
url = "https://alist.com" # Alist 地址
|
||||
token_exp = 86400 # token 过期时间, 单位: 秒
|
||||
# 文件保存根路径
|
||||
base_path = "./downloads"
|
||||
|
||||
[storage.local] # 本地磁盘
|
||||
enable = true
|
||||
base_path = "downloads/" # 保存路径
|
||||
[[storages]]
|
||||
name = "MyWebdav"
|
||||
type = "webdav"
|
||||
enable = false
|
||||
base_path = '/path/telegram'
|
||||
url = 'https://example.com/dav'
|
||||
username = 'username'
|
||||
password = 'password'
|
||||
|
||||
[storage.webdav] # WebDav
|
||||
enable = true
|
||||
base_path = "/telegram"
|
||||
username = "admin"
|
||||
password = "password"
|
||||
url = "https://alist.com/dav"
|
||||
# 用户列表
|
||||
[[users]]
|
||||
# telegram user id
|
||||
id = 114514
|
||||
# 存储过滤列表, 元素为存储标识名.
|
||||
# 将该列表留空并开启黑名单过滤模式以允许使用所有存储,此处示例为黑名单模式,用户 114514 可使用所有存储
|
||||
storages = []
|
||||
# 使用列表过滤黑名单模式,反之则为白名单,白名单请在列表中指定可用的存储.
|
||||
blacklist = true
|
||||
|
||||
[[users]]
|
||||
id = 123456
|
||||
storages = ["本机1"]
|
||||
blacklist = false # 使用白名单模式,此时,用户 123456 仅可使用标识名为 '本地1' 的存储
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user